import 'dart:io'; import 'package:postgres/postgres.dart'; class ProfileDb { ProfileDb(this._connection); final Connection _connection; static Future connect(String databaseUrl) async { final Uri uri = Uri.parse(databaseUrl); final Connection connection = await Connection.open( Endpoint( host: uri.host.isEmpty ? 'localhost' : uri.host, port: uri.hasPort ? uri.port : 5432, database: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'postgres', username: uri.userInfo.isNotEmpty ? uri.userInfo.split(':').first : null, password: uri.userInfo.contains(':') ? uri.userInfo.split(':').skip(1).join(':') : null, ), settings: const ConnectionSettings(sslMode: SslMode.disable), ); return ProfileDb(connection); } Connection get connection => _connection; Future migrate() async { final List files = Directory('migrations') .listSync() .whereType() .where((File f) => f.path.endsWith('.sql')) .toList() ..sort((File a, File b) => a.path.compareTo(b.path)); for (final File file in files) { final String sql = await file.readAsString(); final List statements = sql .split(';') .map((String s) => s.trim()) .where((String s) => s.isNotEmpty) .toList(); for (final String statement in statements) { await _connection.execute(statement); } } } Future?> getProfile(String firebaseUid) async { final Result result = await _connection.execute( Sql.named( 'SELECT firebase_uid, email, display_name, photo_url, locale, timezone, ' 'onboarding_done, revision, updated_at ' 'FROM users WHERE firebase_uid = @uid', ), parameters: {'uid': firebaseUid}, ); if (result.isEmpty) { return null; } final ResultRow row = result.first; return _rowToJson(row); } Future> upsertProfile({ required String firebaseUid, required Map body, required int clientRevision, }) async { final Result existing = await _connection.execute( Sql.named('SELECT revision FROM users WHERE firebase_uid = @uid'), parameters: {'uid': firebaseUid}, ); if (existing.isNotEmpty) { final int serverRevision = (existing.first[0]! as num).toInt(); if (clientRevision < serverRevision) { final Map? current = await getProfile(firebaseUid); throw StaleRevisionException(current!); } } await _connection.execute( Sql.named( ''' INSERT INTO users ( firebase_uid, email, display_name, photo_url, locale, timezone, onboarding_done, revision, updated_at ) VALUES ( @uid, @email, @display_name, @photo_url, @locale, @timezone, @onboarding_done, @revision, @updated_at ) ON CONFLICT (firebase_uid) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name, photo_url = EXCLUDED.photo_url, locale = EXCLUDED.locale, timezone = EXCLUDED.timezone, onboarding_done = EXCLUDED.onboarding_done, revision = EXCLUDED.revision, updated_at = EXCLUDED.updated_at ''', ), parameters: { 'uid': firebaseUid, 'email': body['email'], 'display_name': body['displayName'], 'photo_url': body['photoUrl'], 'locale': body['locale'] ?? 'en', 'timezone': body['timezone'], 'onboarding_done': body['onboardingCompleted'] ?? false, 'revision': clientRevision, 'updated_at': DateTime.parse(body['updatedAt'] as String).toUtc(), }, ); return (await getProfile(firebaseUid))!; } Future close() => _connection.close(); Map _rowToJson(ResultRow row) { final DateTime updatedAt = row[8]! as DateTime; return { 'firebaseUid': row[0]! as String, 'email': row[1] as String?, 'displayName': row[2] as String?, 'photoUrl': row[3] as String?, 'locale': row[4]! as String, 'timezone': row[5] as String?, 'onboardingCompleted': row[6]! as bool, 'revision': (row[7]! as num).toInt(), 'updatedAt': updatedAt.toUtc().toIso8601String(), }; } } class StaleRevisionException implements Exception { StaleRevisionException(this.serverProfile); final Map serverProfile; }