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:
@ -24,6 +24,39 @@ When `PORT` is not set, it listens on `8080`.
|
|||||||
PORT=9090 dart run bin/server.dart
|
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:
|
Healthcheck:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -39,7 +72,8 @@ Expected response:
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
|
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:
|
Upcoming tickets will fill the empty adapters and use cases:
|
||||||
|
|
||||||
|
|||||||
14
server/bin/migrate.dart
Normal file
14
server/bin/migrate.dart
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import 'package:gametime_server/infrastructure/postgres/postgres_database.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> arguments) async {
|
||||||
|
final connection = await PostgresConnectionFactory(
|
||||||
|
PostgresConnectionConfig.fromEnvironment(),
|
||||||
|
).open();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await PostgresMigrator(connection).applyMigrations();
|
||||||
|
print('PostgreSQL migrations applied.');
|
||||||
|
} finally {
|
||||||
|
await connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
# PostgreSQL infrastructure
|
# 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.
|
||||||
|
|||||||
176
server/lib/infrastructure/postgres/postgres_database.dart
Normal file
176
server/lib/infrastructure/postgres/postgres_database.dart
Normal 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;
|
||||||
|
}
|
||||||
114
server/migrations/0001_initial_schema.sql
Normal file
114
server/migrations/0001_initial_schema.sql
Normal file
@ -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);
|
||||||
@ -41,6 +41,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -209,6 +225,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ environment:
|
|||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
postgres: ^3.5.12
|
||||||
shelf: ^1.4.2
|
shelf: ^1.4.2
|
||||||
shelf_router: ^1.1.4
|
shelf_router: ^1.1.4
|
||||||
|
|
||||||
|
|||||||
48
server/test/postgres_migrations_test.dart
Normal file
48
server/test/postgres_migrations_test.dart
Normal file
@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user