feat: Implement the sync protocol (#54)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Marcelo Mendonça Soares 2026-05-29 01:54:23 -03:00 • committed by GitHub
commit f85239c3a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 3031 additions and 306 deletions

View file

@ -2,7 +2,7 @@ include: ../../analysis_options.yaml
analyzer:
exclude:
- lib/src/protocol/**
# - lib/src/protocol/**
formatter:
trailing_commas: preserve

View file

@ -1,7 +1,10 @@
export 'src/crdt/client_sync.dart';
export 'src/crdt/exceptions.dart';
export 'src/crdt/merge.dart';
export 'src/crdt/sync.dart';
export 'src/database/database.dart';
export 'src/database/recorder.dart';
export 'src/database/schema.dart';
export 'src/database/session.dart' show CrdtDatabaseSession;
export 'src/database/session.dart' show CrdtDatabaseAccess, CrdtDatabaseSession;
export 'src/managers/hlc.dart';
export 'src/managers/user.dart';

View file

@ -0,0 +1,127 @@
import 'dart:async';
import 'package:serverpod_client/serverpod_client.dart';
import 'package:serverpod_database/serverpod_database.dart';
import '../database/session.dart';
import '../protocol/protocol.dart';
import 'sync.dart';
/// A running continuous CRDT sync session.
class CrdtSyncSession {
CrdtSyncSession._({
required this.done,
required Future<void> Function() cancel,
}) : _cancel = cancel;
/// Completes when the sync session ends normally or after [cancel].
final Future<void> done;
final Future<void> Function() _cancel;
/// Stops the sync session and closes the transport. This method is idempotent
/// and can be called multiple times.
Future<void> cancel() => _cancel();
}
/// High-level client sync helpers built on top of the generated module caller.
class CrdtSyncClient {
/// Creates a [CrdtSyncClient] for the provided module caller.
CrdtSyncClient(this._caller);
final Caller _caller;
/// Pushes local pending changes for [session] to the remote peer once.
///
/// The [session] must be wrapped in a [CrdtDatabaseSession].
Future<void> syncOnce(DatabaseSession session) async {
await _startSyncSession(session, once: true).done;
}
/// Keeps synchronizing [session] with the remote peer until cancelled through
/// [CrdtSyncSession.cancel] or the remote stream closes.
///
/// The [session] must be wrapped in a [CrdtDatabaseSession].
CrdtSyncSession syncContinuously(
DatabaseSession session, {
CrdtSyncOnMergeSuccess? onMergeSuccess,
}) => _startSyncSession(session, once: false, onMergeSuccess: onMergeSuccess);
CrdtSyncSession _startSyncSession(
DatabaseSession session, {
required bool once,
CrdtSyncOnMergeSuccess? onMergeSuccess,
}) {
// The stream will be closed using [outboundChanges.closeOrSkip].
// ignore: close_sinks
final outboundChanges = StreamController<CrdtSyncStreamEvent>();
final doneCompleter = Completer<void>();
var cancelled = false;
try {
final remoteStream = _caller.crdtSync.sync(
changes: outboundChanges.stream,
once: once,
);
final subscription = session.crdtDb
.sync(inbound: remoteStream, once: once, onMergeSuccess: onMergeSuccess)
.listen(
outboundChanges.addIfNotClosed,
onDone: doneCompleter.completeOrSkip,
onError: doneCompleter.completeErrorOrSkip,
cancelOnError: true,
);
return CrdtSyncSession._(
done: doneCompleter.future.whenComplete(outboundChanges.closeOrSkip),
cancel: () async {
if (cancelled) return;
cancelled = true;
unawaited(outboundChanges.closeOrSkip());
if (doneCompleter.isCompleted) return;
const waitTimeout = Duration(milliseconds: 200);
// Prefer graceful shutdown: closing outbound lets the server finish
// and complete [done] via [subscription.onDone].
await doneCompleter.future.timeout(waitTimeout, onTimeout: () {});
await subscription.cancel().timeout(waitTimeout, onTimeout: () {});
doneCompleter.completeOrSkip();
},
);
} catch (_) {
unawaited(outboundChanges.closeOrSkip());
rethrow;
}
}
}
/// Exposes CRDT sync helpers from a generated client.
extension CrdtSyncClientExtension on ServerpodClientShared {
/// Returns CRDT sync helpers bound to this client.
CrdtSyncClient get crdt => CrdtSyncClient(Caller(this));
}
extension on StreamController<CrdtSyncStreamEvent> {
void addIfNotClosed(CrdtSyncStreamEvent event) {
if (isClosed) return;
add(event);
}
Future<void> closeOrSkip() async {
if (isClosed) return;
await close();
}
}
extension on Completer<void> {
void completeOrSkip() {
if (isCompleted) return;
complete();
}
void completeErrorOrSkip(Object error, StackTrace stackTrace) {
if (isCompleted) return;
completeError(error, stackTrace);
}
}

View file

@ -0,0 +1,94 @@
import 'package:serverpod_serialization/serverpod_serialization.dart';
import '../protocol/protocol.dart';
/// Base type for CRDT sync session failures.
sealed class CrdtSyncException implements Exception {
const CrdtSyncException();
}
/// Thrown when the inbound sync stream closes before an expected event arrives.
final class CrdtSyncStreamClosedException extends CrdtSyncException {
/// Creates a [CrdtSyncStreamClosedException].
const CrdtSyncStreamClosedException({required this.phase});
/// The protocol phase that was interrupted.
final String phase;
@override
String toString() =>
'CrdtSyncStreamClosedException: sync stream closed before $phase event.';
}
/// Thrown when the inbound sync stream delivers an event of the wrong type.
final class CrdtSyncUnexpectedEventException<T extends CrdtSyncStreamEvent>
extends CrdtSyncException {
/// Creates a [CrdtSyncUnexpectedEventException].
const CrdtSyncUnexpectedEventException({
required this.expected,
required this.received,
});
/// A description of the expected event type or types.
final String expected;
/// The event that was received instead.
final CrdtSyncStreamEvent received;
@override
String toString() =>
'CrdtSyncUnexpectedEventException: expected $expected, but '
'received "${received.runtimeType.className}" instead.';
}
/// Thrown when a peer's since-HLC frame does not reference the local node id.
final class CrdtSyncInvalidSinceHlcException extends CrdtSyncException {
/// Creates a [CrdtSyncInvalidSinceHlcException].
const CrdtSyncInvalidSinceHlcException({
required this.receivedNodeId,
required this.expectedNodeId,
});
/// The node id carried by the peer's since-HLC frame.
final UuidValue receivedNodeId;
/// The local node id that was expected.
final UuidValue expectedNodeId;
@override
String toString() =>
'CrdtSyncInvalidSinceHlcException: peer since HLC node id '
'"$receivedNodeId" does not match local node id "$expectedNodeId".';
}
/// Thrown when the sync tables hash sent by a peer does not match locally.
final class SyncTablesHashMismatchException extends CrdtSyncException {
/// Creates a [SyncTablesHashMismatchException].
const SyncTablesHashMismatchException({
required this.received,
required this.expected,
});
/// The hash received from the remote peer.
final String received;
/// The hash computed locally from the configured sync tables.
final String expected;
@override
String toString() =>
'SyncTablesHashMismatchException: schema hash mismatch. Received '
'"$received", expected "$expected". Ensure both sides are on the same '
'schema version before syncing.';
}
extension on Type {
/// The name of the class without the leading underscore and without the 'Impl' suffix.
String get className {
final name = toString();
if (name.startsWith('_') && name.endsWith('Impl')) {
return name.substring(1, name.length - 4);
}
return name;
}
}

View file

@ -1,8 +1,14 @@
import 'dart:async';
import 'package:serverpod_client/serverpod_client.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import '../protocol/protocol.dart';
import 'exceptions.dart';
/// A group of CRDT merge changes collected for one sync batch.
typedef CrdtMergeSet = List<CrdtMergeChange>;
/// Merge metadata extracted from a [CrdtMergeSet].
typedef CrdtMergeMetadataLookup = ({
@ -10,21 +16,95 @@ typedef CrdtMergeMetadataLookup = ({
Map<String, Set<String>> columnNamesByTable,
});
/// CRDT merge helpers built on top of the generated Serverpod models.
extension CrdtMergeSetExtension on CrdtMergeSet {
/// Whether this merge set has no changes.
bool get isEmpty => inserts.isEmpty && updates.isEmpty && deletes.isEmpty;
/// Extensions for stream iterators of [CrdtSyncStreamEvent].
extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> {
/// Collects the next framed sync batch from this iterator.
///
/// Each batch is zero or more [CrdtSyncMergeChunk] events followed by
/// [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
/// empty batch is returned.
///
/// When [allowCloseBeforeBatch] is true, returns `null` if the stream closes
/// before the next batch starts. Otherwise, closing before a batch starts is
/// treated as a [CrdtSyncStreamClosedException].
///
/// If the stream closes after a merge change was already received, the
/// partial batch is still treated as an error.
Future<CrdtMergeSet?> collectNextBatch({
bool allowCloseBeforeBatch = false,
}) async {
final mergeSet = <CrdtMergeChange>[];
/// All merge changes in this set.
Iterable<CrdtMergeChange> get changes sync* {
yield* inserts;
yield* updates;
yield* deletes;
while (await moveNext()) {
switch (current) {
case CrdtSyncMergeChunk(changes: final changes):
mergeSet.addAll(changes);
case CrdtSyncIdleTimeout():
if (mergeSet.isEmpty) return mergeSet;
case CrdtSyncEndOfBatch():
return mergeSet;
default:
throw CrdtSyncUnexpectedEventException(
expected: '"CrdtSyncMergeChunk" or "CrdtSyncEndOfBatch"',
received: current,
);
}
}
if (mergeSet.isEmpty && allowCloseBeforeBatch) return null;
throw const CrdtSyncStreamClosedException(phase: 'end-of-batch');
}
/// Moves the iterator to the next event and throws if the stream is closed or
/// the next event is not of type [T].
Future<T> moveAndThrowIfNot<T extends CrdtSyncStreamEvent>() async {
while (await moveNext()) {
if (current is CrdtSyncIdleTimeout) continue;
if (current is T) return current as T;
throw CrdtSyncUnexpectedEventException(
expected: '"$T"',
received: current,
);
}
throw CrdtSyncStreamClosedException(phase: '"$T"');
}
}
/// Helpers for grouping merge changes into stream payload batches.
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
/// Emits lists with at most [batchSize] changes from this stream.
Stream<CrdtMergeSet> chunked(int batchSize) async* {
var batch = <CrdtMergeChange>[];
await for (final change in this) {
batch.add(change);
if (batch.length < batchSize) continue;
yield batch;
batch = <CrdtMergeChange>[];
}
if (batch.isNotEmpty) yield batch;
}
}
/// CRDT merge helpers for [CrdtMergeSet].
extension CrdtMergeSetExtension on CrdtMergeSet {
/// The greatest HLC represented by the changes in this merge set.
Hlc? get maxHlc => fold<Hlc?>(
null,
(current, change) => change.hlc.maxBetween(current),
);
/// Insert changes in this set.
Iterable<CrdtMergeInsert> get inserts => whereType<CrdtMergeInsert>();
/// Update changes in this set.
Iterable<CrdtMergeUpdate> get updates => whereType<CrdtMergeUpdate>();
/// Delete changes in this set.
Iterable<CrdtMergeDelete> get deletes => whereType<CrdtMergeDelete>();
/// All merge changes in this set sorted by causal order.
List<CrdtMergeChange> get causallyOrderedChanges {
final operations = changes.toList()
final operations = List<CrdtMergeChange>.from(this)
..sort((left, right) => left.hlc.compareTo(right.hlc));
return operations;
}
@ -36,7 +116,7 @@ extension CrdtMergeSetExtension on CrdtMergeSet {
final rowIdsToLoadByTable = <String, Set<UuidValue>>{};
final columnNamesToLoadByTable = <String, Set<String>>{};
for (final change in changes) {
for (final change in this) {
final availableColumnNames = columnNamesByTableName[change.tableName];
if (availableColumnNames == null) continue;

View file

@ -0,0 +1,626 @@
import 'dart:async';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import '../managers/user.dart';
import '../protocol/protocol.dart';
import 'exceptions.dart';
import 'merge.dart';
/// Callback function for when a merge is successful.
typedef CrdtSyncOnMergeSuccess = FutureOr<void> Function(Hlc syncedHlc);
/// The shared CRDT synchronization logic used by both client and server nodes.
class CrdtSync {
/// Creates a new [CrdtSync] instance.
CrdtSync({
/// The list of tables to sync with CRDT.
required List<Table> syncTables,
/// The serialization manager to use for deserializing merge changes.
required DatabaseSerializationManager serializationManager,
/// Maximum number of merge changes sent in one sync stream message.
int syncBatchSize = defaultSyncBatchSize,
/// Delay between continuous sync rounds.
Duration continuousSyncInterval = defaultContinuousSyncInterval,
}) : _syncTables = syncTables,
_serializationManager = serializationManager,
_syncBatchSize = syncBatchSize,
_continuousSyncInterval = continuousSyncInterval {
if (syncBatchSize < 1) {
throw ArgumentError.value(syncBatchSize, 'syncBatchSize', 'Must be >= 1');
}
}
/// Default maximum number of merge changes sent in one stream message.
static const defaultSyncBatchSize = 100;
/// Default delay between continuous sync rounds.
static const defaultContinuousSyncInterval = Duration(milliseconds: 200);
final List<Table> _syncTables;
final DatabaseSerializationManager _serializationManager;
final int _syncBatchSize;
final Duration _continuousSyncInterval;
static CrdtSync? _instance;
/// The singleton instance of [CrdtSync]. Throws a [StateError] if it is not
/// initialized.
static CrdtSync get instance =>
_instance ??
(throw StateError(
'The CrdtSync has not been initialized. Call pod.initializeCrdtSync(...) '
'during server startup to configure the CRDT sync.',
));
/// Configures the shared singleton used by server endpoints.
///
/// Use [syncBatchSize] to control the maximum number of merge changes carried
/// by each [CrdtSyncMergeChunk] stream event.
///
/// The [continuousSyncInterval] controls how long a continuous sync session
/// waits after completing one sync round before checking for local changes.
static void initialize({
required List<Table> syncTables,
required DatabaseSerializationManager serializationManager,
int syncBatchSize = defaultSyncBatchSize,
Duration continuousSyncInterval = defaultContinuousSyncInterval,
}) {
_instance = CrdtSync(
syncTables: syncTables,
serializationManager: serializationManager,
syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval,
);
}
late final Map<String, Table> _syncTablesByName = {
for (final table in _syncTables) table.tableName: table,
};
late final Map<String, String> _classNamesByTableName = {
for (final definition in _serializationManager.getTargetTableDefinitions())
if (definition.dartName != null) definition.name: definition.dartName!,
};
late final Map<String, Map<String, ColumnDefinition>> _columnDefinitionsByTableName =
{
for (final definition in _serializationManager.getTargetTableDefinitions())
definition.name: {
for (final column in definition.columns) column.name: column,
},
};
/// The deterministic hash representing the current synchronized schema.
late final String currentSyncTablesHash = computeSyncTablesHash(
_syncTables,
tableDefinitions: _serializationManager.getTargetTableDefinitions(),
);
/// Computes a deterministic fixed-size hash of the synchronized schema.
///
/// The [tableDefinitions] is the list of all table definitions in the
/// database, which must include the definitions for all [syncTables].
static String computeSyncTablesHash(
List<Table> syncTables, {
required List<TableDefinition> tableDefinitions,
}) {
final canonicalSignature = _computeCanonicalSyncTablesSignature(
syncTables,
tableDefinitions: tableDefinitions,
);
// Use two deterministic namespace-based UUIDv5 hashes to keep the payload
// fixed-size while substantially reducing the practical collision risk.
const uuid = Uuid();
return '${uuid.v5(Namespace.url.value, canonicalSignature)}:'
'${uuid.v5(Namespace.oid.value, canonicalSignature)}';
}
/// Streams local CRDT changes since the last sync checkpoint with [otherNodeId].
///
/// Changes are emitted in insert, update, then delete order. Domain row and
/// column payloads are resolved incrementally as each change is yielded.
///
/// When [sinceHlc] is provided, it is used instead of the stored checkpoint.
Stream<CrdtMergeChange> collectPendingChanges(
DatabaseSession session, {
required UuidValue userId,
required UuidValue otherNodeId,
Hlc? sinceHlc,
}) async* {
final crdtUser = await CrdtUserManager(session).getOrCreate(userId);
final syncCheckpoint =
sinceHlc ??
await _syncCheckpointForNode(
session,
crdtUser,
otherNodeId,
);
yield* _streamInserts(session, crdtUser, syncCheckpoint);
yield* _streamUpdates(session, crdtUser, syncCheckpoint);
yield* _streamDeletes(session, crdtUser, syncCheckpoint);
}
/// Creates the [CrdtSyncSinceHlc] checkpoint for the post-connect handshake.
///
/// [CrdtSyncSinceHlc.lastSyncFromPeer] reflects the latest change this node
/// has received from [peerNodeId], tagged with the peer's node id.
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
}) async {
final crdtUser = await CrdtUserManager(session).getOrCreate(userId);
final lastSeenHlc = await _syncCheckpointForNode(session, crdtUser, peerNodeId);
return CrdtSyncSinceHlc(lastSyncFromPeer: lastSeenHlc);
}
/// Merges a remote [mergeSet] and records the sync checkpoint for [otherNodeId].
///
/// Throws if the merge fails. The sync stream should be closed so the next
/// attempt resumes from the last persisted checkpoint.
///
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
/// empty.
Future<Hlc?> mergeInboundBatch(
DatabaseSession session, {
required UuidValue userId,
required UuidValue otherNodeId,
required CrdtMergeSet mergeSet,
}) async {
if (mergeSet.isEmpty) return null;
final maxSyncedHlc = mergeSet.maxHlc;
final crdtDb = await _openCrdtDatabase(session);
await crdtDb.mergeChanges(mergeSet, userId: userId);
if (maxSyncedHlc != null) {
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: userId);
}
return maxSyncedHlc;
}
/// Streams a framed outbound sync batch of merge changes.
Stream<CrdtSyncStreamEvent> streamOutboundBatch(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required Hlc sinceHlc,
}) async* {
yield* collectPendingChanges(
session,
userId: userId,
otherNodeId: peerNodeId,
sinceHlc: sinceHlc,
)
.chunked(_syncBatchSize)
.map(
(changes) => CrdtSyncMergeChunk(changes: changes),
);
yield CrdtSyncEndOfBatch();
}
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
///
/// Both peers send [CrdtSyncConnect], read the peer connect frame, then send
/// and read [CrdtSyncSinceHlc] once. After that, both sides exchange framed
/// data batches using (1) the peer's authoritative since HLC for the first
/// send, and (2) [CrdtSyncSinceHlc.lastSyncFromPeer] for subsequent sends
/// within the session.
///
/// If a batch fails to merge, the exception propagates and the stream closes.
/// The next sync attempt resumes from the last persisted checkpoint on the
/// side that failed to merge.
///
/// When [once] is true, each peer sends one batch, reads one batch, sends a
/// [CrdtSyncClose] frame and awaits for the other to send its closing event
/// to complete the session.
Stream<CrdtSyncStreamEvent> sync(
DatabaseSession session, {
required UuidValue userId,
required Stream<CrdtSyncStreamEvent> inbound,
bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess,
}) async* {
final inboundIterator = StreamIterator(
// Inject a timeout event if the inbound stream is idle for too long to
// allow returning an empty batch and waiting for a change without closing
// the stream.
inbound.timeout(
const Duration(seconds: 1),
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
),
);
var sessionCompleted = false;
try {
final crdtUser = await CrdtUserManager(session).getOrCreate(userId);
final localNodeId = crdtUser.currentNode!.uuidNodeId;
yield CrdtSyncConnect(
localNodeId: localNodeId,
syncTablesHash: currentSyncTablesHash,
);
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
final peerNodeId = peerConnect.localNodeId;
_validateSyncTablesHash(peerConnect.syncTablesHash);
yield await createSyncSinceHlc(
session,
userId: userId,
peerNodeId: peerNodeId,
);
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>();
if (peerSinceHlc.lastSyncFromPeer.nodeId != localNodeId) {
throw CrdtSyncInvalidSinceHlcException(
receivedNodeId: peerSinceHlc.lastSyncFromPeer.nodeId,
expectedNodeId: localNodeId,
);
}
var lastSentToPeer = peerSinceHlc.lastSyncFromPeer;
while (true) {
final pendingLocalChanges = collectPendingChanges(
session,
userId: userId,
otherNodeId: peerNodeId,
sinceHlc: lastSentToPeer,
);
var hasChanges = false;
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
hasChanges = true;
lastSentToPeer = lastSentToPeer.maxBetween(changes.maxHlc);
yield CrdtSyncMergeChunk(changes: changes);
}
if (hasChanges || once) {
yield CrdtSyncEndOfBatch();
}
final inboundMergeSet = await inboundIterator.collectNextBatch(
allowCloseBeforeBatch: !once,
);
if (inboundMergeSet == null) {
sessionCompleted = true;
return;
}
final lastReceivedFromPeer = await mergeInboundBatch(
session,
userId: userId,
otherNodeId: peerNodeId,
mergeSet: inboundMergeSet,
);
if (hasChanges || inboundMergeSet.isNotEmpty) {
await onMergeSuccess?.call(
lastSentToPeer.maxBetween(lastReceivedFromPeer),
);
}
if (once) {
yield CrdtSyncClose();
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
sessionCompleted = true;
return;
}
// Wait for the configured interval before checking for local changes again.
await Future<void>.delayed(_continuousSyncInterval);
}
} finally {
// Cancelling inbound on normal completion races with WebSocket stream
// teardown and produces "connection closed" errors on the peer. Keep
// cleanup for abnormal exits so listener cancellation can unblock.
if (!sessionCompleted) {
// Best-effort cleanup, since the transport will close the socket anyway.
const waitTimeout = Duration(milliseconds: 200);
await inboundIterator.cancel().timeout(waitTimeout, onTimeout: () {});
}
}
}
void _validateSyncTablesHash(String syncTablesHash) {
if (syncTablesHash != currentSyncTablesHash) {
throw SyncTablesHashMismatchException(
received: syncTablesHash,
expected: currentSyncTablesHash,
);
}
}
Future<CrdtDatabase> _openCrdtDatabase(DatabaseSession session) async {
final db = session.db;
if (db is CrdtDatabase) {
return db;
}
final crdtDb = CrdtDatabase(db, syncTables: _syncTables);
await crdtDb.initialize();
return crdtDb;
}
Future<Hlc> _syncCheckpointForNode(
DatabaseSession session,
CrdtUser crdtUser,
UuidValue otherNodeId,
) async {
final otherNode = await CrdtNode.db.findFirstRow(
session,
where: (t) => t.userId.equals(crdtUser.id) & t.uuidNodeId.equals(otherNodeId),
);
return otherNode?.lastReceivedHlc ?? Hlc.zero(otherNodeId);
}
Stream<CrdtMergeInsert> _streamInserts(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) async* {
final rows = await CrdtDataRow.db.find(
session,
where: (t) => _rowHlcAfterFilter(t, crdtUser, lastSyncHlc),
include: CrdtDataRow.include(
tbl: CrdtSchemaTable.include(),
node: CrdtNode.include(),
),
);
for (final row in rows) {
final tableName = row.tbl!.name;
if (!_syncTablesByName.containsKey(tableName)) continue;
final table = _syncTablesByName[tableName]!;
final dartName = _classNamesByTableName[tableName];
if (dartName == null) continue;
final domainRow = await _fetchDomainRow(
session,
tableName,
row.uuidRowId,
table,
dartName,
);
if (domainRow == null) continue;
yield CrdtMergeInsert(
hlcDatetime: row.hlcDatetime,
hlcCounter: row.hlcCounter,
tableName: tableName,
uuidRowId: row.uuidRowId,
uuidNodeId: row.node!.uuidNodeId,
data: domainRow,
);
}
}
Stream<CrdtMergeUpdate> _streamUpdates(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) async* {
final fields = await CrdtDataField.db.find(
session,
where: (t) => _fieldHlcAfterFilter(t, crdtUser, lastSyncHlc),
include: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
node: CrdtNode.include(),
),
);
for (final field in fields) {
final tableName = field.row!.tbl!.name;
if (!_syncTablesByName.containsKey(tableName)) continue;
final columnName = field.column!.name;
final decodedValue = await _fetchColumnValue(
session,
tableName,
field.row!.uuidRowId,
columnName,
);
yield CrdtMergeUpdate(
hlcDatetime: field.hlcDatetime,
hlcCounter: field.hlcCounter,
tableName: tableName,
uuidRowId: field.row!.uuidRowId,
uuidNodeId: field.node!.uuidNodeId,
columnName: columnName,
value: decodedValue,
);
}
}
Stream<CrdtMergeDelete> _streamDeletes(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) async* {
final tombstones = await CrdtDataDeleted.db.find(
session,
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, lastSyncHlc),
include: CrdtDataDeleted.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
node: CrdtNode.include(),
),
);
for (final tombstone in tombstones) {
final tableName = tombstone.row!.tbl!.name;
if (!_syncTablesByName.containsKey(tableName)) continue;
yield CrdtMergeDelete(
hlcDatetime: tombstone.hlcDatetime,
hlcCounter: tombstone.hlcCounter,
tableName: tableName,
uuidRowId: tombstone.row!.uuidRowId,
uuidNodeId: tombstone.node!.uuidNodeId,
isDeleted: tombstone.isDeleted,
);
}
}
Expression _rowHlcAfterFilter(
CrdtDataRowTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) =>
t.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
Expression _fieldHlcAfterFilter(
CrdtDataFieldTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) =>
t.row.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
Expression _tombstoneHlcAfterFilter(
CrdtDataDeletedTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
) =>
t.row.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
Future<dynamic> _fetchDomainRow(
DatabaseSession session,
String tableName,
UuidValue rowId,
Table table,
String dartName,
) async {
final cols = table.columns.map((column) => '"${column.columnName}"').join(', ');
final encodedRowId = ValueEncoder.instance.convert(rowId);
final result = await session.db.unsafeQuery(
'SELECT $cols FROM "$tableName" WHERE "id" = $encodedRowId',
);
if (result.isEmpty) return null;
final columnMap = result.first.toColumnMap();
return session.db.serializationManager.deserializeByClassName({
'className': dartName,
'data': columnMap,
});
}
Future<dynamic> _fetchColumnValue(
DatabaseSession session,
String tableName,
UuidValue rowId,
String columnName,
) async {
final encodedValue = ValueEncoder.instance.convert(rowId);
final result = await session.db.unsafeQuery(
'SELECT "$columnName" FROM "$tableName" WHERE "id" = $encodedValue',
);
if (result.isEmpty) return null;
return _decodeColumnValue(tableName, columnName, result.first[0]);
}
dynamic _decodeColumnValue(String tableName, String columnName, Object? value) {
if (value == null) return null;
final definition = _columnDefinitionsByTableName[tableName]?[columnName];
final dartType = definition?.dartType;
if (dartType == null) return value;
final className = _classNameForDartType(dartType);
return switch (className) {
'bool' || 'double' || 'int' || 'String' => value,
_ => _serializationManager.deserializeByClassName({
'className': className,
'data': value,
}),
};
}
String _classNameForDartType(String dartType) {
final withoutNullable = dartType.endsWith('?')
? dartType.substring(0, dartType.length - 1)
: dartType;
return withoutNullable.split(':').last;
}
static String _computeCanonicalSyncTablesSignature(
List<Table> syncTables, {
required List<TableDefinition> tableDefinitions,
}) {
final tableDefinitionsByName = {
for (final definition in tableDefinitions) definition.name: definition,
};
final sortedTables = syncTables.toList()
..sort((left, right) => left.tableName.compareTo(right.tableName));
return sortedTables
.map((table) {
final definition = tableDefinitionsByName[table.tableName];
final columns = [
for (final column in definition?.columns ?? const <ColumnDefinition>[])
column.name,
if (definition == null) ...table.columns.map((column) => column.columnName),
]..sort();
final foreignKeys = _canonicalForeignKeys(definition);
final uniqueIndexes = _canonicalUniqueIndexes(definition);
return '${table.tableName}:'
'${columns.join(',')}|'
'fk[${foreignKeys.join(';')}]|'
'uq[${uniqueIndexes.join(';')}]';
})
.join(';');
}
static List<String> _canonicalForeignKeys(TableDefinition? definition) {
if (definition == null) return const [];
final entries = <String>[
for (final fk in definition.foreignKeys)
// Each foreign key must map all parameters.
// ignore: no_adjacent_strings_in_list
'${(fk.columns.toList()..sort()).join(',')}->'
'${fk.referenceTableSchema}.${fk.referenceTable}'
'(${(fk.referenceColumns.toList()..sort()).join(',')})'
'|u:${fk.onUpdate?.toString() ?? '-'}'
'|d:${fk.onDelete?.toString() ?? '-'}'
'|m:${fk.matchType?.toString() ?? '-'}',
]..sort();
return entries;
}
static List<String> _canonicalUniqueIndexes(TableDefinition? definition) {
if (definition == null) return const [];
final entries = <String>[
for (final index in definition.indexes)
if (index.isUnique && !index.isPrimary)
() {
final sortedElements = [
for (final element in index.elements)
'${element.type}:${element.definition}',
]..sort();
return sortedElements.join(',');
}(),
]..sort();
return entries;
}
}

View file

@ -3,10 +3,11 @@
// ignore_for_file: invalid_use_of_internal_member
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:uuid/uuid.dart';
import '../crdt/merge.dart';
import '../managers/user.dart';
import '../crdt/sync.dart';
import '../protocol/protocol.dart';
import 'recorder.dart';
import 'session.dart';
@ -25,25 +26,85 @@ class CrdtDatabase implements Database {
/// The list of tables to sync with CRDT.
required List<Table> syncTables,
/// Maximum number of merge changes sent in one sync stream message.
int syncBatchSize = CrdtSync.defaultSyncBatchSize,
/// Delay between continuous sync rounds.
Duration continuousSyncInterval = CrdtSync.defaultContinuousSyncInterval,
/// The user ID to use for all CRDT operations. This should only be used for
/// databases operating on the client side, where all data is for the same user.
/// Otherwise, the user ID must be passed through the transaction.
UuidValue? persistentUserId,
}) : _recorder = CrdtMutationRecorder(
}) : _syncTables = syncTables,
_syncBatchSize = syncBatchSize,
_continuousSyncInterval = continuousSyncInterval,
_recorder = CrdtMutationRecorder(
_delegate,
persistentUserId: persistentUserId,
syncTables: syncTables,
);
final Database _delegate;
final List<Table> _syncTables;
final int _syncBatchSize;
final Duration _continuousSyncInterval;
final CrdtMutationRecorder _recorder;
late final CrdtSync _sync = CrdtSync(
syncTables: _syncTables,
serializationManager: serializationManager,
syncBatchSize: _syncBatchSize,
continuousSyncInterval: _continuousSyncInterval,
);
/// Initializes the CRDT database.
Future<void> initialize() async {
await _recorder.initialize();
}
/// The hash describing the synchronized schema configured for this database.
String get syncTablesHash => _sync.currentSyncTablesHash;
/// Returns the current node identifier for the effective user.
Future<UuidValue> currentNodeId({UuidValue? userId}) async {
final effectiveUserId = await _requireUserId(userId);
final user = await _recorder.getOrCreateUser(effectiveUserId);
return user.currentNode!.uuidNodeId;
}
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
Stream<CrdtSyncStreamEvent> sync({
required Stream<CrdtSyncStreamEvent> inbound,
UuidValue? userId,
bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess,
}) async* {
final effectiveUserId = await _requireUserId(userId);
yield* _sync.sync(
_delegate.session,
userId: effectiveUserId,
inbound: inbound,
once: once,
onMergeSuccess: onMergeSuccess,
);
}
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
Future<void> recordSyncCheckpoint(
UuidValue otherNodeId,
Hlc syncedHlc, {
UuidValue? userId,
}) async {
final effectiveUserId = await _requireUserId(userId);
await _recorder.recordSyncCheckpoint(
effectiveUserId,
otherNodeId,
syncedHlc,
);
}
/// Merges remote CRDT changes into the local database for the given user.
///
/// When [userId] is omitted, this uses the recorder's persistent user id.
@ -200,6 +261,30 @@ class CrdtDatabase implements Database {
);
}
@override
Future<List<T>> upsert<T extends TableRow>(
List<T> rows, {
required List<Column> conflictColumns,
List<Column>? updateColumns,
Expression? updateWhere,
Transaction? transaction,
}) async {
// TODO: Implement CRDT upsert.
throw UnimplementedError('CRDT upsert is not implemented.');
}
@override
Future<T?> upsertRow<T extends TableRow>(
T row, {
required List<Column> conflictColumns,
List<Column>? updateColumns,
Expression? updateWhere,
Transaction? transaction,
}) async {
// TODO: Implement CRDT upsert.
throw UnimplementedError('CRDT upsert is not implemented.');
}
@override
Future<List<T>> update<T extends TableRow>(
List<T> rows, {
@ -433,7 +518,7 @@ class CrdtDatabase implements Database {
TransactionSettings? settings,
}) async {
// Ensure that the user exists with a node before starting the transaction.
final user = await CrdtUserManager.getOrCreate(_delegate.session, userId);
final user = await _recorder.getOrCreateUser(userId);
return transaction<R>(
(tx) async {
@ -448,6 +533,14 @@ class CrdtDatabase implements Database {
);
}
Future<UuidValue> _requireUserId(UuidValue? userId) async {
return userId ??
_recorder.persistentUserId ??
(throw StateError(
'A user ID is required when syncing without a persistent user.',
));
}
@override
Future<int> unsafeExecute(
String query, {

View file

@ -69,18 +69,24 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
}
}
_updateHlcFromIncomingOperations(operations, transaction);
await _updateHlcFromIncomingOperations(
operations,
remoteNodes,
transaction,
);
}
void _updateHlcFromIncomingOperations(
Future<void> _updateHlcFromIncomingOperations(
List<CrdtMergeChange> operations,
Map<UuidValue, CrdtNode> remoteNodes,
Transaction transaction,
) {
) async {
final maxIncomingHlc = operations.fold<Hlc?>(
null,
(current, change) =>
current == null || change.hlc > current ? change.hlc : current,
(current, change) => change.hlc.maxBetween(current),
);
final nodesToUpdate = <CrdtNode>[];
if (maxIncomingHlc != null) {
final hlcManager = _getHlcManager(transaction);
if (maxIncomingHlc.nodeId == hlcManager.uuidNodeId) {
@ -90,6 +96,37 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
} else {
hlcManager.merge(maxIncomingHlc);
}
nodesToUpdate.add(hlcManager.getNode());
}
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
for (final operation in operations) {
maxIncomingHlcByNode[operation.uuidNodeId] = operation.hlc.maxBetween(
maxIncomingHlcByNode[operation.uuidNodeId],
);
}
for (final MapEntry(key: nodeId, value: incomingHlc)
in maxIncomingHlcByNode.entries) {
final remoteNode = remoteNodes[nodeId];
if (remoteNode == null) continue;
final updatedNode = remoteNode.copyWith(
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
);
if (updatedNode.lastReceivedHlc == remoteNode.lastReceivedHlc) continue;
nodesToUpdate.add(updatedNode);
remoteNodes[nodeId] = updatedNode;
}
if (nodesToUpdate.isNotEmpty) {
await CrdtNode.db.update(
_session,
nodesToUpdate,
columns: (t) => [t.lastReceivedHlc],
transaction: transaction,
);
}
}
@ -466,7 +503,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
try {
await _db.updateRow(
row,
columns: _syncTableByName[insert.tableName]!.managedColumns
columns: row.table.managedColumns
.where((c) => data.containsKey(c.columnName))
.toList(),
transaction: transaction,

View file

@ -54,12 +54,18 @@ class CrdtMutationRecorder {
final Database _db;
late final DatabaseSession _dbSession = _db.session;
late final _session = CrdtDatabaseSession(
_db,
syncTables: syncTables,
persistentUserId: persistentUserId,
);
late final CrdtUserManager _userManager = CrdtUserManager(_dbSession);
final Map<UuidValue, HlcManager> _hlcManagers = {};
late Map<String, (int, Map<String, CrdtSchemaColumn>)> _schema;
late final _tableDefinitionsByName = {
@ -103,13 +109,14 @@ class CrdtMutationRecorder {
/// Safe to call again after the database was wiped (e.g. test `tearDown`)
/// for in-memory schema ids to match new rows.
Future<void> initialize() async {
final session = _db.session;
_userManager.clearCache();
_hlcManagers.clear();
if (persistentUserId != null) {
await CrdtUserManager.getOrCreate(session, persistentUserId!);
await _userManager.getOrCreate(persistentUserId!);
}
final schemaRegistry = CrdtSchemaRegistry(session, syncTables: syncTables);
final schemaRegistry = CrdtSchemaRegistry(_dbSession, syncTables: syncTables);
final (tableRows, columnRows) = await schemaRegistry.syncAndGetSchema();
final columnsByTableId = <int, Map<String, CrdtSchemaColumn>>{};
@ -155,6 +162,45 @@ class CrdtMutationRecorder {
return _syncTableByName.containsKey(tableName);
}
/// Returns the [CrdtUser] for the given user ID, creating it when needed.
Future<CrdtUser> getOrCreateUser(UuidValue userId) {
return _userManager.getOrCreate(userId);
}
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
Future<void> recordSyncCheckpoint(
UuidValue userId,
UuidValue otherNodeId,
Hlc syncedHlc,
) async {
final user = await _userManager.getOrCreate(userId);
await _db.transaction((transaction) async {
var node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.userId.equals(user.id) & t.uuidNodeId.equals(otherNodeId),
transaction: transaction,
);
node ??= await CrdtNode.db.insertRow(
_session,
CrdtNode(userId: user.id!, uuidNodeId: otherNodeId),
transaction: transaction,
);
final currentSyncHlc = node.lastReceivedHlc;
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
return;
}
await CrdtNode.db.updateRow(
_session,
node.copyWith(lastReceivedHlc: syncedHlc),
columns: (t) => [t.lastReceivedHlc],
transaction: transaction,
);
});
}
/// Insert the CRDT metadata for the inserted rows.
Future<void> afterInsert<T extends TableRow>(
List<T> insertedRows,
@ -995,7 +1041,10 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
HlcManager _getHlcManager(Transaction transaction) {
final user = _getEffectiveUser(transaction);
return HlcManager.forUser(user);
return _hlcManagers.putIfAbsent(
user.uuidUserId,
() => HlcManager.forUser(user),
);
}
CrdtUser _getEffectiveUser(Transaction transaction) {
@ -1004,7 +1053,7 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
if (persistentUserId == null) {
throw StateError('No user ID found for transaction or persistent user ID.');
}
return CrdtUserManager.getCached(persistentUserId!);
return _userManager.getCached(persistentUserId!);
}
}

View file

@ -2,6 +2,7 @@ import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:uuid/uuid.dart';
import '../crdt/sync.dart';
import 'database.dart';
/// Wraps a [DatabaseSession] to provide a [CrdtDatabase] as [DatabaseSession.db].
@ -13,6 +14,12 @@ class CrdtDatabaseSession implements DatabaseSession {
/// The list of tables to sync with CRDT.
required List<Table> syncTables,
/// Maximum number of merge changes sent in one sync stream message.
int syncBatchSize = CrdtSync.defaultSyncBatchSize,
/// Delay between continuous sync rounds.
Duration continuousSyncInterval = CrdtSync.defaultContinuousSyncInterval,
/// The user ID to use for all CRDT operations. This should only be used for
/// databases operating on the client side, where all data is for the same user.
/// Otherwise, the user ID must be passed through the transaction.
@ -20,6 +27,8 @@ class CrdtDatabaseSession implements DatabaseSession {
}) : _db = CrdtDatabase(
db,
syncTables: syncTables,
syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval,
persistentUserId: persistentUserId,
);
@ -30,6 +39,12 @@ class CrdtDatabaseSession implements DatabaseSession {
/// The list of tables to sync with CRDT.
required List<Table> syncTables,
/// Maximum number of merge changes sent in one sync stream message.
int syncBatchSize = CrdtSync.defaultSyncBatchSize,
/// Delay between continuous sync rounds.
Duration continuousSyncInterval = CrdtSync.defaultContinuousSyncInterval,
/// The user ID to use for all CRDT operations. This should only be used for
/// databases operating on the client side, where all data is for the same user.
/// Otherwise, the user ID must be passed through the transaction.
@ -37,6 +52,8 @@ class CrdtDatabaseSession implements DatabaseSession {
}) => CrdtDatabaseSession(
session.db,
syncTables: syncTables,
syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval,
persistentUserId: persistentUserId,
);
@ -80,3 +97,16 @@ class BasicDatabaseSession implements DatabaseSession {
extension DatabaseSessionExtension on Database {
DatabaseSession get session => BasicDatabaseSession(this);
}
/// Convenience access to a CRDT-aware database from a wrapped session.
extension CrdtDatabaseAccess on DatabaseSession {
/// Returns the wrapped [CrdtDatabase] for this session.
CrdtDatabase get crdtDb {
final database = db;
if (database is CrdtDatabase) return database;
throw StateError(
'This database session is not wrapped with CrdtDatabaseSession. '
'Use CrdtDatabaseSession.wraps(...) before accessing crdtDb.',
);
}
}

View file

@ -1,12 +1,10 @@
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:serverpod_serialization/serverpod_serialization.dart';
import 'package:uuid/uuid.dart';
import '../protocol/protocol.dart';
/// A manager for [Hlc] instances that is used to generate unique timestamps
/// for CRDT operations. Will keep track of the HLC during the lifetime of the
/// application.
/// for CRDT operations.
class HlcManager {
HlcManager._(
this.uuidUserId,
@ -15,6 +13,16 @@ class HlcManager {
this.lastHlc,
);
/// Creates a new [HlcManager] for the current node of [user].
factory HlcManager.forUser(CrdtUser user) {
return HlcManager._(
user.uuidUserId,
user.id!,
user.currentNodeId!,
user.currentNode!.lastReceivedHlc ?? Hlc.zero(user.currentNode!.uuidNodeId),
);
}
/// The UUID of the user this manager is for.
final UuidValue uuidUserId;
@ -30,64 +38,6 @@ class HlcManager {
/// The last HLC timestamp for the current node.
Hlc lastHlc;
/// Map of user ID to [HlcManager] instance of the current node for that user.
static final Map<UuidValue, HlcManager> _instances = {};
/// Returns the [HlcManager] for the given node ID.
///
/// Will create a new [HlcManager] with [Hlc.zero] if no manager is found.
static HlcManager forUser(CrdtUser user) {
_instances[user.uuidUserId] ??= HlcManager._(
user.uuidUserId,
user.id!,
user.currentNodeId!,
user.currentNode!.lastReceivedHlc ?? Hlc.zero(user.currentNode!.uuidNodeId),
);
return _instances[user.uuidUserId]!;
}
/// Closes the [HlcManager] and updates the last received HLC for all nodes.
///
/// Should be called when the application is shutting down.
static Future<void> close(DatabaseSession session) async {
await session.db.transaction((transaction) async {
final nodes = await CrdtNode.db.find(
session,
where: (t) => t.uuidNodeId.inSet(
_instances.values.map((e) => e.uuidNodeId).toSet(),
),
transaction: transaction,
lockMode: LockMode.forUpdate,
lockBehavior: LockBehavior.wait,
);
await CrdtNode.db.update(
session,
[
for (final node in nodes)
CrdtNode(
id: node.id,
userId: node.userId,
lastReceivedHlc: _instances.values
.firstWhere((e) => e.uuidNodeId == node.uuidNodeId)
.lastHlc,
),
],
columns: (t) => [t.lastReceivedHlc],
transaction: transaction,
);
_instances.clear();
});
}
/// Clears per-user HLC state when the database file was emptied (e.g. test
/// cleanup). Call before recreating managers so state matches the store.
static void reset() {
_instances.clear();
}
/// Returns the next HLC timestamp for the current node.
Hlc increment() {
lastHlc = lastHlc.increment();
@ -98,4 +48,12 @@ class HlcManager {
void merge(Hlc other) {
lastHlc = lastHlc.merge(other);
}
/// Converts this manager state to the persisted current-node model.
CrdtNode getNode() => CrdtNode(
id: normalizedNodeId,
userId: normalizedUserId,
uuidNodeId: uuidNodeId,
lastReceivedHlc: lastHlc,
);
}

View file

@ -5,50 +5,48 @@ import '../protocol/protocol.dart';
/// A manager for [CrdtUser] instances.
class CrdtUserManager {
CrdtUserManager._();
/// Creates a [CrdtUserManager] bound to a database session.
CrdtUserManager(this._session);
/// Map of user ID to [CrdtUser] instance for that user.
static final Map<UuidValue, CrdtUser> _instances = {};
final DatabaseSession _session;
/// Returns the [CrdtUser] for the given user ID.
///
/// Will create a new [CrdtUser] if no user is found.
static CrdtUser getCached(UuidValue uuidUserId) =>
_instances[uuidUserId] ?? (throw StateError('User $uuidUserId not found.'));
final Map<UuidValue, CrdtUser> _instances = {};
/// Clears the in-memory user cache. Used when the database is reset (e.g. tests)
/// so [getOrCreate] loads fresh rows from the store.
static void clearCache() {
/// Returns the cached [CrdtUser] for the given user ID.
CrdtUser getCached(UuidValue uuidUserId) =>
_instances[uuidUserId] ??
(throw StateError(
'User $uuidUserId not found in cache. '
'Ensure CrdtUserManager.getOrCreate() is called before getCached().',
));
/// Clears the in-memory cache so state is reloaded from the store.
void clearCache() {
_instances.clear();
}
/// Returns the [CrdtUser] for the given user ID.
///
/// Will create a new [CrdtUser] if no user is found.
static Future<CrdtUser> getOrCreate(
DatabaseSession session,
UuidValue uuidUserId,
) async {
return _instances[uuidUserId] ??= await session.db.transaction(
(transaction) => _getOrCreate(session, uuidUserId, transaction),
Future<CrdtUser> getOrCreate(UuidValue uuidUserId) async {
return _instances[uuidUserId] ??= await _session.db.transaction(
(transaction) => _getOrCreate(uuidUserId, transaction),
);
}
static Future<CrdtUser> _getOrCreate(
DatabaseSession session,
Future<CrdtUser> _getOrCreate(
UuidValue uuidUserId,
Transaction transaction,
) async {
var user = await CrdtUser.db.findFirstRow(
session,
_session,
where: (t) => t.uuidUserId.equals(uuidUserId),
include: CrdtUser.include(currentNode: CrdtNode.include()),
transaction: transaction,
);
// User must be created.
user ??= await CrdtUser.db.insertRow(
session,
_session,
CrdtUser(uuidUserId: uuidUserId),
transaction: transaction,
);
@ -57,15 +55,14 @@ class CrdtUserManager {
return user;
}
// User has no current node and must be created.
final currentNode = await CrdtNode.db.insertRow(
session,
_session,
CrdtNode(userId: user.id!),
transaction: transaction,
);
await CrdtUser.db.attachRow.currentNode(
session,
_session,
user,
currentNode,
transaction: transaction,

View file

@ -0,0 +1,8 @@
analyzer:
errors:
dead_code: ignore
duplicate_ignore: ignore
unnecessary_type_check: ignore
formatter:
trailing_commas: preserve

View file

@ -10,10 +10,10 @@ resolution: workspace
dependencies:
meta: ^1.18.0
serverpod_client: 3.5.0-beta.6
serverpod_database: 3.5.0-beta.6
serverpod_client: 3.5.0-beta.9
serverpod_database: 3.5.0-beta.9
serverpod_offline_sync_shared: 0.0.1
serverpod_serialization: 3.5.0-beta.6
serverpod_serialization: 3.5.0-beta.9
uuid: ^4.5.3
dev_dependencies:

View file

@ -2,7 +2,7 @@ include: ../../analysis_options.yaml
analyzer:
exclude:
- lib/src/generated/**
# - lib/src/generated/**
- test/integration/test_tools/serverpod_test_tools.dart
formatter:

View file

@ -1,7 +1,9 @@
export 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
export 'src/business/crdt_sync.dart';
export 'src/endpoints/crdt_sync_endpoint.dart';
export 'src/generated/endpoints.dart';
// The models are exported by the client package to ensure the same class as the
// one expected by the functions.
// one expected by the functions. Never export the server-side protocol.
// export 'src/generated/protocol.dart';

View file

@ -0,0 +1,28 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
/// Extension methods for [Serverpod] to configure the CRDT sync on the server.
extension CrdtSyncInitialize on Serverpod {
/// Configures the endpoint with the given sync tables.
///
/// Must be called during server startup before any sync requests are made.
/// Will override any previous initialization.
///
/// [syncBatchSize] controls the maximum number of merge changes carried by
/// each sync stream chunk.
///
/// [continuousSyncInterval] controls how long a continuous sync session waits
/// after completing one sync round before checking for local changes again.
void initializeCrdtSync({
required List<Table> syncTables,
int syncBatchSize = CrdtSync.defaultSyncBatchSize,
Duration continuousSyncInterval = CrdtSync.defaultContinuousSyncInterval,
}) {
CrdtSync.initialize(
syncTables: syncTables,
serializationManager: serializationManager,
syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval,
);
}
}

View file

@ -0,0 +1,24 @@
import 'dart:async';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
/// Endpoint for CRDT-based offline-first synchronization.
class CrdtSyncEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Opens a bidirectional CRDT sync session with the authenticated client.
Stream<CrdtSyncStreamEvent> sync(
Session session, {
required Stream<CrdtSyncStreamEvent> changes,
bool once = false,
}) async* {
yield* CrdtSync.instance.sync(
session,
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
inbound: changes,
once: once,
);
}
}

View file

@ -1,40 +0,0 @@
// import 'package:serverpod/serverpod.dart';
// import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
// // import '../generated/protocol.dart';
// /// TODO: Create the classes.
// abstract class CrdtDataEntry {}
// /// Endpoint for the CRDT sync.
// class SyncOfflineChangesEndpoint extends Endpoint {
// @override
// bool get requireLogin => true;
// /// Syncs the offline changes between server and client.
// ///
// /// This endpoint is used to sync the offline changes between server and
// /// client. After a handshake on the [migrationVersion] to ensure that the
// /// merge can happen, the client will send all [offlineChanges] since the
// /// [lastSyncHlc] HLC and the server will send all online changes through
// /// the returning stream.
// ///
// /// The connection will be kept open until one end disconnects, forwarding
// /// the changes to the other end.
// Stream<CrdtDataEntry> syncOfflineChanges(
// Session session, {
// required String migrationVersion,
// required Hlc lastSyncHlc,
// /// TODO: Create the classes.
// /// CrdtDataEntry will be a sealed class with the changes.
// ///
// /// - CrdtDataRow: New rows inserted.
// /// - CrdtDataField: Fields updated.
// /// - CrdtDataDeleted: Rows deleted.
// required Stream<CrdtDataEntry> offlineChanges,
// }) async* {
// // TODO: Implement the business layer for the CRDT sync.
// // yield* CrdtSync.syncOfflineChanges(session);
// }
// }

View file

@ -0,0 +1,8 @@
analyzer:
errors:
dead_code: ignore
duplicate_ignore: ignore
unnecessary_type_check: ignore
formatter:
trailing_commas: preserve

Some files were not shown because too many files have changed in this diff Show more