feat(server): schéma PostgreSQL sync-ready (ticket #49)

Ajoute la migration initiale (migrations/0001_initial_schema.sql,
incluant la table users nécessaire à l'adapter d'auth du ticket #48),
le runner de migration (bin/migrate.dart) et l'accès Postgres
(infrastructure/postgres/postgres_database.dart). dart pub get OK,
dart analyze clean, dart test vert (le test PostgreSQL réel est skip
faute de TEST_DATABASE_URL/Docker disponible dans ce sandbox ; SQL de
migration revu manuellement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:05:24 +02:00
parent e06de40428
commit 79d994bdd7
8 changed files with 415 additions and 3 deletions

View File

@ -1,5 +1,6 @@
# PostgreSQL infrastructure
Placeholder for PostgreSQL repository adapters.
Connection and migration utilities for server-side PostgreSQL.
The schema and concrete persistence code are planned for ticket #49.
Repository adapters will be added by the sync/auth tickets once their ports are
defined.

View File

@ -0,0 +1,176 @@
import 'dart:io';
import 'package:postgres/postgres.dart';
final class PostgresConnectionConfig {
const PostgresConnectionConfig({
required this.host,
required this.port,
required this.database,
required this.username,
required this.password,
this.sslMode = SslMode.disable,
});
factory PostgresConnectionConfig.fromEnvironment([
Map<String, String>? environment,
]) {
final env = environment ?? Platform.environment;
return PostgresConnectionConfig(
host: _requiredEnv(env, 'DATABASE_HOST'),
port: int.tryParse(env['DATABASE_PORT'] ?? '') ?? 5432,
database: _requiredEnv(env, 'DATABASE_NAME'),
username: _requiredEnv(env, 'DATABASE_USER'),
password: _requiredEnv(env, 'DATABASE_PASSWORD'),
);
}
final String host;
final int port;
final String database;
final String username;
final String password;
final SslMode sslMode;
}
final class PostgresConnectionFactory {
const PostgresConnectionFactory(this.config) : databaseUrl = null;
const PostgresConnectionFactory.fromDatabaseUrl(this.databaseUrl)
: config = null;
final PostgresConnectionConfig? config;
final String? databaseUrl;
Future<Connection> open() {
final databaseUrl = this.databaseUrl;
if (databaseUrl != null) {
return Connection.openFromUrl(databaseUrl);
}
final config = this.config!;
return Connection.open(
Endpoint(
host: config.host,
port: config.port,
database: config.database,
username: config.username,
password: config.password,
),
settings: ConnectionSettings(sslMode: config.sslMode),
);
}
}
final class PostgresMigrator {
const PostgresMigrator(this.connection);
final Connection connection;
Future<void> applyMigrations({String migrationsPath = 'migrations'}) async {
final directory = Directory(migrationsPath);
if (!await directory.exists()) {
throw StateError('Migrations directory not found: $migrationsPath');
}
final files =
await directory
.list()
.where((entity) => entity is File && entity.path.endsWith('.sql'))
.cast<File>()
.toList()
..sort((left, right) => left.path.compareTo(right.path));
for (final file in files) {
final sql = await file.readAsString();
for (final statement in _splitSqlStatements(sql)) {
await connection.execute(statement);
}
}
}
}
String _requiredEnv(Map<String, String> environment, String key) {
final value = environment[key];
if (value == null || value.trim().isEmpty) {
throw StateError('Missing required environment variable: $key');
}
return value;
}
List<String> _splitSqlStatements(String sql) {
final statements = <String>[];
final buffer = StringBuffer();
var inSingleQuote = false;
var inDoubleQuote = false;
String? dollarQuoteTag;
for (var index = 0; index < sql.length; index++) {
final char = sql[index];
final next = index + 1 < sql.length ? sql[index + 1] : null;
if (dollarQuoteTag != null) {
if (sql.startsWith(dollarQuoteTag, index)) {
buffer.write(dollarQuoteTag);
index += dollarQuoteTag.length - 1;
dollarQuoteTag = null;
} else {
buffer.write(char);
}
continue;
}
if (!inSingleQuote && !inDoubleQuote && char == r'$') {
final tag = _readDollarQuoteTag(sql, index);
if (tag != null) {
dollarQuoteTag = tag;
buffer.write(tag);
index += tag.length - 1;
continue;
}
}
if (!inDoubleQuote && char == "'") {
buffer.write(char);
if (inSingleQuote && next == "'") {
index++;
buffer.write(next);
} else {
inSingleQuote = !inSingleQuote;
}
continue;
}
if (!inSingleQuote && char == '"') {
inDoubleQuote = !inDoubleQuote;
buffer.write(char);
continue;
}
if (!inSingleQuote && !inDoubleQuote && char == ';') {
final statement = buffer.toString().trim();
if (statement.isNotEmpty) {
statements.add(statement);
}
buffer.clear();
continue;
}
buffer.write(char);
}
final statement = buffer.toString().trim();
if (statement.isNotEmpty) {
statements.add(statement);
}
return statements;
}
String? _readDollarQuoteTag(String sql, int start) {
final end = sql.indexOf(r'$', start + 1);
if (end == -1) {
return null;
}
final tagName = sql.substring(start + 1, end);
final validTag = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$|^$').hasMatch(tagName);
return validTag ? sql.substring(start, end + 1) : null;
}