2026-05-20 10:22:58 -05:00

134 lines
4.3 KiB
Dart

import 'dart:io';
import 'package:postgres/postgres.dart';
class ProfileDb {
ProfileDb(this._connection);
final Connection _connection;
static Future<ProfileDb> 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);
}
Future<void> migrate() async {
final String sql = await File('migrations/001_users.sql').readAsString();
final List<String> 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<Map<String, dynamic>?> 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: <String, dynamic>{'uid': firebaseUid},
);
if (result.isEmpty) {
return null;
}
final ResultRow row = result.first;
return _rowToJson(row);
}
Future<Map<String, dynamic>> upsertProfile({
required String firebaseUid,
required Map<String, dynamic> body,
required int clientRevision,
}) async {
final Result existing = await _connection.execute(
Sql.named('SELECT revision FROM users WHERE firebase_uid = @uid'),
parameters: <String, dynamic>{'uid': firebaseUid},
);
if (existing.isNotEmpty) {
final int serverRevision = (existing.first[0]! as num).toInt();
if (clientRevision < serverRevision) {
final Map<String, dynamic>? 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: <String, dynamic>{
'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<void> close() => _connection.close();
Map<String, dynamic> _rowToJson(ResultRow row) {
final DateTime updatedAt = row[8]! as DateTime;
return <String, dynamic>{
'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<String, dynamic> serverProfile;
}