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:
2026-07-19 10:23:36 +02:00
parent 66d5c9a173
commit 60912ebde4
15 changed files with 1237 additions and 9 deletions

View 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});
}