From 79d994bdd71b69948040dc9b0feefe96e753e160 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 10:05:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20sch=C3=A9ma=20PostgreSQL=20sync?= =?UTF-8?q?-ready=20(ticket=20#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/README.md | 36 +++- server/bin/migrate.dart | 14 ++ server/lib/infrastructure/postgres/README.md | 5 +- .../postgres/postgres_database.dart | 176 ++++++++++++++++++ server/migrations/0001_initial_schema.sql | 114 ++++++++++++ server/pubspec.lock | 24 +++ server/pubspec.yaml | 1 + server/test/postgres_migrations_test.dart | 48 +++++ 8 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 server/bin/migrate.dart create mode 100644 server/lib/infrastructure/postgres/postgres_database.dart create mode 100644 server/migrations/0001_initial_schema.sql create mode 100644 server/test/postgres_migrations_test.dart diff --git a/server/README.md b/server/README.md index 7eaad89..8b29a32 100644 --- a/server/README.md +++ b/server/README.md @@ -24,6 +24,39 @@ When `PORT` is not set, it listens on `8080`. PORT=9090 dart run bin/server.dart ``` +## PostgreSQL + +Ticket #49 adds the initial sync-ready PostgreSQL schema and a minimal +connection/migration utility. + +Database environment variables: + +- `DATABASE_HOST`: PostgreSQL host. +- `DATABASE_PORT`: PostgreSQL port, defaults to `5432` when omitted. +- `DATABASE_NAME`: database name. +- `DATABASE_USER`: database user. +- `DATABASE_PASSWORD`: database password. + +Apply migrations from `server/`: + +```bash +DATABASE_HOST=localhost \ +DATABASE_PORT=5432 \ +DATABASE_NAME=gametime \ +DATABASE_USER=gametime \ +DATABASE_PASSWORD=gametime \ +dart run bin/migrate.dart +``` + +Migrations are read from `server/migrations/` in alphabetical order. The v1 +runner is intentionally simple; SQL files use idempotent DDL where practical. + +The PostgreSQL integration test is skipped unless `TEST_DATABASE_URL` is set: + +```bash +TEST_DATABASE_URL=postgres://gametime:gametime@localhost:5432/gametime dart test +``` + Healthcheck: ```bash @@ -39,7 +72,8 @@ Expected response: ## Scope Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and -the `/health` endpoint. +the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema only; auth, +sync endpoints and sharing behavior are still implemented in later tickets. Upcoming tickets will fill the empty adapters and use cases: diff --git a/server/bin/migrate.dart b/server/bin/migrate.dart new file mode 100644 index 0000000..0dc7bfd --- /dev/null +++ b/server/bin/migrate.dart @@ -0,0 +1,14 @@ +import 'package:gametime_server/infrastructure/postgres/postgres_database.dart'; + +Future main(List arguments) async { + final connection = await PostgresConnectionFactory( + PostgresConnectionConfig.fromEnvironment(), + ).open(); + + try { + await PostgresMigrator(connection).applyMigrations(); + print('PostgreSQL migrations applied.'); + } finally { + await connection.close(); + } +} diff --git a/server/lib/infrastructure/postgres/README.md b/server/lib/infrastructure/postgres/README.md index 5afddc0..4730f87 100644 --- a/server/lib/infrastructure/postgres/README.md +++ b/server/lib/infrastructure/postgres/README.md @@ -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. diff --git a/server/lib/infrastructure/postgres/postgres_database.dart b/server/lib/infrastructure/postgres/postgres_database.dart new file mode 100644 index 0000000..627baef --- /dev/null +++ b/server/lib/infrastructure/postgres/postgres_database.dart @@ -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? 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 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 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() + .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 environment, String key) { + final value = environment[key]; + if (value == null || value.trim().isEmpty) { + throw StateError('Missing required environment variable: $key'); + } + return value; +} + +List _splitSqlStatements(String sql) { + final statements = []; + 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; +} diff --git a/server/migrations/0001_initial_schema.sql b/server/migrations/0001_initial_schema.sql new file mode 100644 index 0000000..6b73c81 --- /dev/null +++ b/server/migrations/0001_initial_schema.sql @@ -0,0 +1,114 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL UNIQUE, + password_hash text NOT NULL, + display_name text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + disabled_at timestamptz, + CONSTRAINT users_email_not_blank CHECK (length(trim(email::text)) > 0), + CONSTRAINT users_password_hash_not_blank CHECK (length(trim(password_hash)) > 0) +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + issued_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + user_agent text, + device_label text, + CONSTRAINT auth_sessions_token_hash_not_blank CHECK (length(trim(token_hash)) > 0), + CONSTRAINT auth_sessions_expires_after_issued CHECK (expires_at > issued_at) +); + +CREATE TABLE IF NOT EXISTS synced_resources ( + server_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource_type text NOT NULL, + client_id text NOT NULL, + payload_json jsonb NOT NULL, + schema_version integer NOT NULL, + client_updated_at timestamptz NOT NULL, + server_updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + origin_device_id text, + CONSTRAINT synced_resources_type_check CHECK ( + resource_type IN ( + 'exercise', + 'program', + 'workoutTemplate', + 'workoutHistory', + 'mediaAsset' + ) + ), + CONSTRAINT synced_resources_client_id_not_blank CHECK (length(trim(client_id)) > 0), + CONSTRAINT synced_resources_schema_version_positive CHECK (schema_version > 0), + CONSTRAINT synced_resources_owner_type_client_unique UNIQUE ( + owner_user_id, + resource_type, + client_id + ) +); + +CREATE INDEX IF NOT EXISTS idx_synced_resources_owner_type_updated + ON synced_resources (owner_user_id, resource_type, server_updated_at); + +CREATE INDEX IF NOT EXISTS idx_synced_resources_owner_updated + ON synced_resources (owner_user_id, server_updated_at); + +CREATE OR REPLACE FUNCTION set_synced_resources_server_updated_at() +RETURNS trigger AS $$ +BEGIN + NEW.server_updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_synced_resources_set_server_updated_at + ON synced_resources; + +CREATE TRIGGER trg_synced_resources_set_server_updated_at + BEFORE UPDATE ON synced_resources + FOR EACH ROW + EXECUTE FUNCTION set_synced_resources_server_updated_at(); + +CREATE TABLE IF NOT EXISTS shares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + sender_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource_type text NOT NULL, + payload_json jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + CONSTRAINT shares_resource_type_check CHECK ( + resource_type IN ('program', 'workoutTemplate') + ) +); + +CREATE TABLE IF NOT EXISTS share_recipients ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + recipient_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'pending', + responded_at timestamptz, + CONSTRAINT share_recipients_status_check CHECK ( + status IN ('pending', 'accepted', 'declined', 'revoked') + ), + CONSTRAINT share_recipients_share_user_unique UNIQUE ( + share_id, + recipient_user_id + ) +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id + ON auth_sessions (user_id); + +CREATE INDEX IF NOT EXISTS idx_shares_sender_user_id + ON shares (sender_user_id); + +CREATE INDEX IF NOT EXISTS idx_share_recipients_recipient_status + ON share_recipients (recipient_user_id, status); diff --git a/server/pubspec.lock b/server/pubspec.lock index e14a0b3..0c3dbb2 100644 --- a/server/pubspec.lock +++ b/server/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" cli_config: dependency: transitive description: @@ -209,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + postgres: + dependency: "direct main" + description: + name: postgres + sha256: "123de5cbadc56a7e8d9fa485c780b6b56940b4081f4c74f3a5578682757c299b" + url: "https://pub.dev" + source: hosted + version: "3.5.12" pub_semver: dependency: transitive description: diff --git a/server/pubspec.yaml b/server/pubspec.yaml index 99945d0..ddf0ff2 100644 --- a/server/pubspec.yaml +++ b/server/pubspec.yaml @@ -6,6 +6,7 @@ environment: sdk: ^3.10.0 dependencies: + postgres: ^3.5.12 shelf: ^1.4.2 shelf_router: ^1.1.4 diff --git a/server/test/postgres_migrations_test.dart b/server/test/postgres_migrations_test.dart new file mode 100644 index 0000000..dd5622b --- /dev/null +++ b/server/test/postgres_migrations_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:gametime_server/infrastructure/postgres/postgres_database.dart'; +import 'package:test/test.dart'; + +void main() { + test('PostgreSQL migrations create the sync-ready schema', () async { + final databaseUrl = Platform.environment['TEST_DATABASE_URL']; + if (databaseUrl == null || databaseUrl.trim().isEmpty) { + markTestSkipped( + 'Set TEST_DATABASE_URL to run PostgreSQL migration tests.', + ); + return; + } + + final connection = await PostgresConnectionFactory.fromDatabaseUrl( + databaseUrl, + ).open(); + addTearDown(() => connection.close()); + + await PostgresMigrator(connection).applyMigrations(); + + final result = await connection.execute(''' + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'users', + 'auth_sessions', + 'synced_resources', + 'shares', + 'share_recipients' + ) + '''); + final tableNames = result.map((row) => row[0] as String).toSet(); + + expect( + tableNames, + containsAll({ + 'users', + 'auth_sessions', + 'synced_resources', + 'shares', + 'share_recipients', + }), + ); + }); +}