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:
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});
|
||||
}
|
||||
Reference in New Issue
Block a user