feat(server): API de synchronisation incrémentale LWW (ticket #50)
Ajoute l'endpoint api/sync_api.dart, les use cases de synchronisation (application/sync_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/synced_resource_repository.dart) implémentant un upsert LWW atomique via CTE (INSERT ... ON CONFLICT ... WHERE client_updated_at < EXCLUDED.client_updated_at, avec fallback UNION ALL pour le cas ignoré). dart pub get OK, dart analyze clean, dart test 15/15 vert. SQL d'upsert relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -4,8 +4,9 @@ import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import 'auth_api.dart';
|
||||
import 'sync_api.dart';
|
||||
|
||||
Handler buildApiHandler({AuthApi? authApi}) {
|
||||
Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) {
|
||||
final router = Router()
|
||||
..get('/health', (Request request) {
|
||||
return Response.ok(
|
||||
@ -26,5 +27,27 @@ Handler buildApiHandler({AuthApi? authApi}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (syncApi != null) {
|
||||
router
|
||||
..post(
|
||||
'/sync/push',
|
||||
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||
syncApi.push,
|
||||
),
|
||||
)
|
||||
..get(
|
||||
'/sync/pull',
|
||||
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||
syncApi.pull,
|
||||
),
|
||||
)
|
||||
..post(
|
||||
'/sync/exchange',
|
||||
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||
syncApi.exchange,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
||||
}
|
||||
|
||||
216
server/lib/api/sync_api.dart
Normal file
216
server/lib/api/sync_api.dart
Normal file
@ -0,0 +1,216 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'auth_api.dart';
|
||||
|
||||
final class SyncApi {
|
||||
const SyncApi({
|
||||
required this.pushSync,
|
||||
required this.pullSync,
|
||||
required this.exchangeSync,
|
||||
required this.authenticateRequest,
|
||||
});
|
||||
|
||||
final PushSyncUseCase pushSync;
|
||||
final PullSyncUseCase pullSync;
|
||||
final ExchangeSyncUseCase exchangeSync;
|
||||
final AuthenticateRequestUseCase authenticateRequest;
|
||||
|
||||
Future<Response> push(Request request) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
final body = await _readJsonObject(request);
|
||||
final result = await pushSync.execute(
|
||||
ownerUserId: authenticated.user.id,
|
||||
deviceId: _optionalString(body, 'deviceId'),
|
||||
items: _pushItems(body['items']),
|
||||
);
|
||||
return _jsonResponse(200, _pushResultJson(result));
|
||||
} on FormatException catch (error) {
|
||||
return _errorResponse(400, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> pull(Request request) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
final since = _optionalDateTime(request.url.queryParameters['since']);
|
||||
final result = await pullSync.execute(
|
||||
ownerUserId: authenticated.user.id,
|
||||
since: since,
|
||||
);
|
||||
return _jsonResponse(200, _pullResultJson(result));
|
||||
} on FormatException catch (error) {
|
||||
return _errorResponse(400, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> exchange(Request request) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
final body = await _readJsonObject(request);
|
||||
final result = await exchangeSync.execute(
|
||||
ownerUserId: authenticated.user.id,
|
||||
deviceId: _optionalString(body, 'deviceId'),
|
||||
items: _pushItems(body['items']),
|
||||
since: _optionalDateTime(_optionalString(body, 'since')),
|
||||
);
|
||||
return _jsonResponse(200, {
|
||||
'serverCursor': result.pull.serverCursor.toIso8601String(),
|
||||
'pushResults': _pushResultsJson(result.push.results),
|
||||
'items': _resourcesJson(result.pull.items),
|
||||
});
|
||||
} on FormatException catch (error) {
|
||||
return _errorResponse(400, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<PushSyncItemInput> _pushItems(Object? rawItems) {
|
||||
if (rawItems is! List) {
|
||||
throw const FormatException('items must be a JSON array.');
|
||||
}
|
||||
return [
|
||||
for (final rawItem in rawItems)
|
||||
if (rawItem is Map)
|
||||
_pushItem(Map<String, Object?>.from(rawItem))
|
||||
else
|
||||
const PushSyncItemInput.invalid(
|
||||
message: 'Sync item must be a JSON object.',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
PushSyncItemInput _pushItem(Map<String, Object?> item) {
|
||||
try {
|
||||
final payload = item['payload'];
|
||||
if (payload is! Map) {
|
||||
return PushSyncItemInput.invalid(
|
||||
resourceType: _stringOrNull(item['resourceType']),
|
||||
clientId: _stringOrNull(item['clientId']),
|
||||
message: 'payload must be a JSON object.',
|
||||
);
|
||||
}
|
||||
return PushSyncItemInput(
|
||||
resourceType: _stringOrNull(item['resourceType']),
|
||||
clientId: _stringOrNull(item['clientId']),
|
||||
schemaVersion: item['schemaVersion'] is int
|
||||
? item['schemaVersion'] as int
|
||||
: null,
|
||||
clientUpdatedAt: _optionalDateTime(
|
||||
_stringOrNull(item['clientUpdatedAt']),
|
||||
),
|
||||
deletedAt: _optionalDateTime(_stringOrNull(item['deletedAt'])),
|
||||
payloadJson: Map<String, Object?>.from(payload),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
return PushSyncItemInput.invalid(
|
||||
resourceType: _stringOrNull(item['resourceType']),
|
||||
clientId: _stringOrNull(item['clientId']),
|
||||
message: error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _pushResultJson(PushSyncResult result) {
|
||||
return {
|
||||
'serverCursor': result.serverCursor.toIso8601String(),
|
||||
'results': _pushResultsJson(result.results),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> _pushResultsJson(List<PushSyncResultItem> results) {
|
||||
return [
|
||||
for (final item in results)
|
||||
{
|
||||
'resourceType': item.resourceType,
|
||||
'clientId': item.clientId,
|
||||
'serverId': item.serverId,
|
||||
'status': item.status,
|
||||
'serverUpdatedAt': item.serverUpdatedAt?.toIso8601String(),
|
||||
if (item.message != null) 'message': item.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Map<String, Object?> _pullResultJson(PullSyncResult result) {
|
||||
return {
|
||||
'serverCursor': result.serverCursor.toIso8601String(),
|
||||
'items': _resourcesJson(result.items),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> _resourcesJson(List<SyncedResource> resources) {
|
||||
return [
|
||||
for (final resource in resources)
|
||||
{
|
||||
'resourceType': resource.resourceType.wireName,
|
||||
'clientId': resource.clientId,
|
||||
'serverId': resource.serverId,
|
||||
'schemaVersion': resource.schemaVersion,
|
||||
'clientUpdatedAt': resource.clientUpdatedAt.toIso8601String(),
|
||||
'serverUpdatedAt': resource.serverUpdatedAt.toIso8601String(),
|
||||
'deletedAt': resource.deletedAt?.toIso8601String(),
|
||||
'payload': resource.payloadJson,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readJsonObject(Request request) async {
|
||||
final raw = await request.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('Request body must be a JSON object.');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is! String) {
|
||||
throw FormatException('$key must be a string.');
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
String? _stringOrNull(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
DateTime? _optionalDateTime(String? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.parse(value).toUtc();
|
||||
}
|
||||
|
||||
Response _jsonResponse(int statusCode, Map<String, Object?> body) {
|
||||
return Response(
|
||||
statusCode,
|
||||
body: jsonEncode(body),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
Response _errorResponse(int statusCode, String message) {
|
||||
return _jsonResponse(statusCode, {'error': message});
|
||||
}
|
||||
@ -2,5 +2,5 @@
|
||||
|
||||
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
|
||||
|
||||
Authentication use cases live here and depend only on repository/security
|
||||
ports. Concrete adapters are wired from `bin/server.dart`.
|
||||
Authentication and sync use cases live here and depend only on repository /
|
||||
security ports. Concrete adapters are wired from `bin/server.dart`.
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export 'ports.dart';
|
||||
export 'sync_use_cases.dart';
|
||||
export 'use_cases.dart';
|
||||
|
||||
@ -22,6 +22,24 @@ abstract interface class AuthSessionRepository {
|
||||
Future<void> revoke({required String sessionId, required DateTime revokedAt});
|
||||
}
|
||||
|
||||
enum SyncWriteStatus { accepted, ignoredOlder }
|
||||
|
||||
final class SyncWriteResult {
|
||||
const SyncWriteResult({required this.status, required this.resource});
|
||||
|
||||
final SyncWriteStatus status;
|
||||
final SyncedResource resource;
|
||||
}
|
||||
|
||||
abstract interface class SyncedResourceRepository {
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource);
|
||||
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class PasswordHasher {
|
||||
Future<String> hash(String password);
|
||||
|
||||
|
||||
237
server/lib/application/sync_use_cases.dart
Normal file
237
server/lib/application/sync_use_cases.dart
Normal file
@ -0,0 +1,237 @@
|
||||
import '../domain/domain.dart';
|
||||
import 'ports.dart';
|
||||
|
||||
final class PushSyncItemInput {
|
||||
const PushSyncItemInput({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
required this.schemaVersion,
|
||||
required this.clientUpdatedAt,
|
||||
required this.deletedAt,
|
||||
required this.payloadJson,
|
||||
this.validationError,
|
||||
});
|
||||
|
||||
const PushSyncItemInput.invalid({
|
||||
required String message,
|
||||
this.resourceType,
|
||||
this.clientId,
|
||||
}) : schemaVersion = null,
|
||||
clientUpdatedAt = null,
|
||||
deletedAt = null,
|
||||
payloadJson = null,
|
||||
validationError = message;
|
||||
|
||||
final String? resourceType;
|
||||
final String? clientId;
|
||||
final int? schemaVersion;
|
||||
final DateTime? clientUpdatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final Map<String, Object?>? payloadJson;
|
||||
final String? validationError;
|
||||
}
|
||||
|
||||
final class PushSyncResultItem {
|
||||
const PushSyncResultItem({
|
||||
required this.resourceType,
|
||||
required this.clientId,
|
||||
this.serverId,
|
||||
required this.status,
|
||||
this.serverUpdatedAt,
|
||||
this.message,
|
||||
});
|
||||
|
||||
final String? resourceType;
|
||||
final String? clientId;
|
||||
final String? serverId;
|
||||
final String status;
|
||||
final DateTime? serverUpdatedAt;
|
||||
final String? message;
|
||||
}
|
||||
|
||||
final class PushSyncResult {
|
||||
const PushSyncResult({required this.serverCursor, required this.results});
|
||||
|
||||
final DateTime serverCursor;
|
||||
final List<PushSyncResultItem> results;
|
||||
}
|
||||
|
||||
final class PullSyncResult {
|
||||
const PullSyncResult({required this.serverCursor, required this.items});
|
||||
|
||||
final DateTime serverCursor;
|
||||
final List<SyncedResource> items;
|
||||
}
|
||||
|
||||
final class ExchangeSyncResult {
|
||||
const ExchangeSyncResult({required this.push, required this.pull});
|
||||
|
||||
final PushSyncResult push;
|
||||
final PullSyncResult pull;
|
||||
}
|
||||
|
||||
final class PushSyncUseCase {
|
||||
const PushSyncUseCase({
|
||||
required this.resources,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
final SyncedResourceRepository resources;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
|
||||
Future<PushSyncResult> execute({
|
||||
required String ownerUserId,
|
||||
required String? deviceId,
|
||||
required List<PushSyncItemInput> items,
|
||||
}) async {
|
||||
final results = <PushSyncResultItem>[];
|
||||
final normalizedDeviceId = _blankToNull(deviceId);
|
||||
|
||||
for (final item in items) {
|
||||
try {
|
||||
final validationError = item.validationError;
|
||||
if (validationError != null) {
|
||||
throw ValidationException(validationError);
|
||||
}
|
||||
if (normalizedDeviceId == null) {
|
||||
throw const ValidationException('deviceId must not be blank.');
|
||||
}
|
||||
final resource = SyncedResource(
|
||||
serverId: ids.newId(),
|
||||
ownerUserId: ownerUserId,
|
||||
resourceType: SyncedResourceType.parse(
|
||||
_required(item.resourceType, 'resourceType'),
|
||||
),
|
||||
clientId: _required(item.clientId, 'clientId'),
|
||||
payloadJson: item.payloadJson ?? _missing('payload'),
|
||||
schemaVersion: item.schemaVersion ?? _missing('schemaVersion'),
|
||||
clientUpdatedAt: item.clientUpdatedAt ?? _missing('clientUpdatedAt'),
|
||||
serverUpdatedAt: clock.now(),
|
||||
deletedAt: item.deletedAt,
|
||||
originDeviceId: normalizedDeviceId,
|
||||
);
|
||||
final written = await resources.upsertWithLww(resource);
|
||||
results.add(
|
||||
PushSyncResultItem(
|
||||
resourceType: written.resource.resourceType.wireName,
|
||||
clientId: written.resource.clientId,
|
||||
serverId: written.resource.serverId,
|
||||
status: written.status == SyncWriteStatus.accepted
|
||||
? 'accepted'
|
||||
: 'ignoredOlder',
|
||||
serverUpdatedAt: written.resource.serverUpdatedAt,
|
||||
),
|
||||
);
|
||||
} on ValidationException catch (error) {
|
||||
results.add(
|
||||
PushSyncResultItem(
|
||||
resourceType: item.resourceType,
|
||||
clientId: item.clientId,
|
||||
status: 'error',
|
||||
message: error.message,
|
||||
),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
results.add(
|
||||
PushSyncResultItem(
|
||||
resourceType: item.resourceType,
|
||||
clientId: item.clientId,
|
||||
status: 'error',
|
||||
message: error.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return PushSyncResult(
|
||||
serverCursor: await _currentCursor(resources, ownerUserId, clock),
|
||||
results: results,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class PullSyncUseCase {
|
||||
const PullSyncUseCase({required this.resources, required this.clock});
|
||||
|
||||
final SyncedResourceRepository resources;
|
||||
final Clock clock;
|
||||
|
||||
Future<PullSyncResult> execute({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final items = await resources.findAllForUserSince(
|
||||
ownerUserId: ownerUserId,
|
||||
since: since?.toUtc(),
|
||||
);
|
||||
return PullSyncResult(
|
||||
serverCursor: _cursorFromItems(items, clock.now()),
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ExchangeSyncUseCase {
|
||||
const ExchangeSyncUseCase({required this.push, required this.pull});
|
||||
|
||||
final PushSyncUseCase push;
|
||||
final PullSyncUseCase pull;
|
||||
|
||||
Future<ExchangeSyncResult> execute({
|
||||
required String ownerUserId,
|
||||
required String? deviceId,
|
||||
required List<PushSyncItemInput> items,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final pushResult = await push.execute(
|
||||
ownerUserId: ownerUserId,
|
||||
deviceId: deviceId,
|
||||
items: items,
|
||||
);
|
||||
final pullResult = await pull.execute(
|
||||
ownerUserId: ownerUserId,
|
||||
since: since,
|
||||
);
|
||||
return ExchangeSyncResult(push: pushResult, pull: pullResult);
|
||||
}
|
||||
}
|
||||
|
||||
Future<DateTime> _currentCursor(
|
||||
SyncedResourceRepository resources,
|
||||
String ownerUserId,
|
||||
Clock clock,
|
||||
) async {
|
||||
final allItems = await resources.findAllForUserSince(
|
||||
ownerUserId: ownerUserId,
|
||||
);
|
||||
return _cursorFromItems(allItems, clock.now());
|
||||
}
|
||||
|
||||
DateTime _cursorFromItems(List<SyncedResource> items, DateTime fallback) {
|
||||
if (items.isEmpty) {
|
||||
return fallback.toUtc();
|
||||
}
|
||||
return items
|
||||
.map((item) => item.serverUpdatedAt)
|
||||
.reduce((left, right) => left.isAfter(right) ? left : right)
|
||||
.toUtc();
|
||||
}
|
||||
|
||||
String _required(String? value, String label) {
|
||||
final normalized = _blankToNull(value);
|
||||
if (normalized == null) {
|
||||
throw ValidationException('$label must not be blank.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Never _missing(String label) {
|
||||
throw ValidationException('$label is required.');
|
||||
}
|
||||
|
||||
String? _blankToNull(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
return trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
@ -5,4 +5,4 @@ Pure server domain entities and invariants.
|
||||
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
||||
other infrastructure concerns.
|
||||
|
||||
Current entities: `UserAccount` and `AuthSession`.
|
||||
Current entities: `UserAccount`, `AuthSession` and `SyncedResource`.
|
||||
|
||||
@ -88,6 +88,63 @@ final class AuthSession {
|
||||
}
|
||||
}
|
||||
|
||||
enum SyncedResourceType {
|
||||
exercise('exercise'),
|
||||
program('program'),
|
||||
workoutTemplate('workoutTemplate'),
|
||||
workoutHistory('workoutHistory'),
|
||||
mediaAsset('mediaAsset');
|
||||
|
||||
const SyncedResourceType(this.wireName);
|
||||
|
||||
final String wireName;
|
||||
|
||||
static SyncedResourceType parse(String value) {
|
||||
for (final type in values) {
|
||||
if (type.wireName == value) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw ValidationException('Unsupported resource type: $value.');
|
||||
}
|
||||
}
|
||||
|
||||
final class SyncedResource {
|
||||
SyncedResource({
|
||||
required String serverId,
|
||||
required String ownerUserId,
|
||||
required this.resourceType,
|
||||
required String clientId,
|
||||
required Map<String, Object?> payloadJson,
|
||||
required this.schemaVersion,
|
||||
required DateTime clientUpdatedAt,
|
||||
required DateTime serverUpdatedAt,
|
||||
DateTime? deletedAt,
|
||||
this.originDeviceId,
|
||||
}) : serverId = _nonBlank(serverId, 'Server resource id'),
|
||||
ownerUserId = _nonBlank(ownerUserId, 'Owner user id'),
|
||||
clientId = _nonBlank(clientId, 'Client resource id'),
|
||||
payloadJson = Map.unmodifiable(payloadJson),
|
||||
clientUpdatedAt = clientUpdatedAt.toUtc(),
|
||||
serverUpdatedAt = serverUpdatedAt.toUtc(),
|
||||
deletedAt = deletedAt?.toUtc() {
|
||||
if (schemaVersion <= 0) {
|
||||
throw const ValidationException('Schema version must be positive.');
|
||||
}
|
||||
}
|
||||
|
||||
final String serverId;
|
||||
final String ownerUserId;
|
||||
final SyncedResourceType resourceType;
|
||||
final String clientId;
|
||||
final Map<String, Object?> payloadJson;
|
||||
final int schemaVersion;
|
||||
final DateTime clientUpdatedAt;
|
||||
final DateTime serverUpdatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final String? originDeviceId;
|
||||
}
|
||||
|
||||
String _nonBlank(String value, String label) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -2,5 +2,5 @@
|
||||
|
||||
Connection and migration utilities for server-side PostgreSQL.
|
||||
|
||||
Repository adapters will be added by the sync/auth tickets once their ports are
|
||||
defined.
|
||||
Current adapters cover auth users/sessions and the generic `synced_resources`
|
||||
sync table.
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export 'auth_repositories.dart';
|
||||
export 'postgres_database.dart';
|
||||
export 'synced_resource_repository.dart';
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:postgres/postgres.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
import '../../domain/domain.dart';
|
||||
|
||||
final class PostgresSyncedResourceRepository
|
||||
implements SyncedResourceRepository {
|
||||
const PostgresSyncedResourceRepository(this.connection);
|
||||
|
||||
final Connection connection;
|
||||
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
final result = await connection.execute(
|
||||
Sql.named('''
|
||||
WITH upserted AS (
|
||||
INSERT INTO synced_resources (
|
||||
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||
schema_version, client_updated_at, deleted_at, origin_device_id
|
||||
)
|
||||
VALUES (
|
||||
@server_id::uuid, @owner_user_id::uuid, @resource_type, @client_id,
|
||||
@payload_json::jsonb, @schema_version, @client_updated_at,
|
||||
@deleted_at, @origin_device_id
|
||||
)
|
||||
ON CONFLICT (owner_user_id, resource_type, client_id)
|
||||
DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
client_updated_at = EXCLUDED.client_updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at,
|
||||
origin_device_id = EXCLUDED.origin_device_id
|
||||
WHERE synced_resources.client_updated_at < EXCLUDED.client_updated_at
|
||||
RETURNING
|
||||
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||
schema_version, client_updated_at, server_updated_at, deleted_at,
|
||||
origin_device_id, 'accepted'::text AS write_status
|
||||
)
|
||||
SELECT * FROM upserted
|
||||
UNION ALL
|
||||
SELECT
|
||||
existing.server_id, existing.owner_user_id, existing.resource_type,
|
||||
existing.client_id, existing.payload_json, existing.schema_version,
|
||||
existing.client_updated_at, existing.server_updated_at,
|
||||
existing.deleted_at, existing.origin_device_id,
|
||||
'ignoredOlder'::text AS write_status
|
||||
FROM synced_resources existing
|
||||
WHERE existing.owner_user_id = @owner_user_id::uuid
|
||||
AND existing.resource_type = @resource_type
|
||||
AND existing.client_id = @client_id
|
||||
AND NOT EXISTS (SELECT 1 FROM upserted)
|
||||
LIMIT 1
|
||||
'''),
|
||||
parameters: {
|
||||
'server_id': resource.serverId,
|
||||
'owner_user_id': resource.ownerUserId,
|
||||
'resource_type': resource.resourceType.wireName,
|
||||
'client_id': resource.clientId,
|
||||
'payload_json': jsonEncode(resource.payloadJson),
|
||||
'schema_version': resource.schemaVersion,
|
||||
'client_updated_at': resource.clientUpdatedAt,
|
||||
'deleted_at': resource.deletedAt,
|
||||
'origin_device_id': resource.originDeviceId,
|
||||
},
|
||||
);
|
||||
final row = result.single;
|
||||
final values = row.toColumnMap() as Map<String, Object?>;
|
||||
return SyncWriteResult(
|
||||
status: values['write_status'] == 'accepted'
|
||||
? SyncWriteStatus.accepted
|
||||
: SyncWriteStatus.ignoredOlder,
|
||||
resource: _resourceFromValues(values),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final result = since == null
|
||||
? await connection.execute(
|
||||
Sql.named('''
|
||||
SELECT server_id, owner_user_id, resource_type, client_id,
|
||||
payload_json, schema_version, client_updated_at,
|
||||
server_updated_at, deleted_at, origin_device_id
|
||||
FROM synced_resources
|
||||
WHERE owner_user_id = @owner_user_id::uuid
|
||||
ORDER BY server_updated_at ASC, server_id ASC
|
||||
'''),
|
||||
parameters: {'owner_user_id': ownerUserId},
|
||||
)
|
||||
: await connection.execute(
|
||||
Sql.named('''
|
||||
SELECT server_id, owner_user_id, resource_type, client_id,
|
||||
payload_json, schema_version, client_updated_at,
|
||||
server_updated_at, deleted_at, origin_device_id
|
||||
FROM synced_resources
|
||||
WHERE owner_user_id = @owner_user_id::uuid
|
||||
AND server_updated_at > @since
|
||||
ORDER BY server_updated_at ASC, server_id ASC
|
||||
'''),
|
||||
parameters: {'owner_user_id': ownerUserId, 'since': since.toUtc()},
|
||||
);
|
||||
return [
|
||||
for (final row in result)
|
||||
_resourceFromValues(row.toColumnMap() as Map<String, Object?>),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
SyncedResource _resourceFromValues(Map<String, Object?> values) {
|
||||
return SyncedResource(
|
||||
serverId: _stringValue(values['server_id']),
|
||||
ownerUserId: _stringValue(values['owner_user_id']),
|
||||
resourceType: SyncedResourceType.parse(
|
||||
_stringValue(values['resource_type']),
|
||||
),
|
||||
clientId: _stringValue(values['client_id']),
|
||||
payloadJson: _payloadValue(values['payload_json']),
|
||||
schemaVersion: values['schema_version'] as int,
|
||||
clientUpdatedAt: _dateTimeValue(values['client_updated_at']),
|
||||
serverUpdatedAt: _dateTimeValue(values['server_updated_at']),
|
||||
deletedAt: _nullableDateTimeValue(values['deleted_at']),
|
||||
originDeviceId: values['origin_device_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _payloadValue(Object? value) {
|
||||
if (value is Map<String, Object?>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
if (value is String) {
|
||||
final decoded = jsonDecode(value);
|
||||
if (decoded is Map<String, Object?>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
}
|
||||
throw const FormatException('Expected JSON object payload.');
|
||||
}
|
||||
|
||||
String _stringValue(Object? value) {
|
||||
if (value == null) {
|
||||
throw const FormatException('Expected non-null string value.');
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
DateTime _dateTimeValue(Object? value) {
|
||||
final result = _nullableDateTimeValue(value);
|
||||
if (result == null) {
|
||||
throw const FormatException('Expected non-null DateTime value.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
DateTime? _nullableDateTimeValue(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value.toUtc();
|
||||
}
|
||||
return DateTime.parse(value.toString()).toUtc();
|
||||
}
|
||||
Reference in New Issue
Block a user