feat: Implement shared scopes with role enforcement (#90)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-03 01:25:15 -03:00 • committed by GitHub
commit 8acfa7f269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 4868 additions and 485 deletions

View file

@ -1,6 +1,7 @@
export 'src/crdt/client_sync.dart';
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
export 'src/crdt/merge.dart';
export 'src/crdt/scope_membership.dart';
export 'src/crdt/sync.dart';
export 'src/database/database.dart';
export 'src/database/recorder.dart';

View file

@ -68,7 +68,12 @@ class CrdtSyncClient {
);
final subscription = session.crdtDb
.sync(inbound: remoteStream, once: once, onMergeSuccess: onMergeSuccess)
.sync(
inbound: remoteStream,
once: once,
onMergeSuccess: onMergeSuccess,
mode: CrdtSyncPeerMode.follower,
)
.listen(
outboundChanges.addIfNotClosed,
onDone: doneCompleter.completeOrSkip,

View file

@ -43,26 +43,6 @@ final class CrdtSyncUnexpectedEventException<T extends CrdtSyncStreamEvent>
'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].
@ -109,10 +89,58 @@ final class CrdtSyncIntegrityViolationException extends CrdtSyncException {
'${violation.type.name}/${violation.operation.name} references '
'missing domain row $row for scope '
'${violation.incomingScopeUuid}.$persisted',
CrdtSyncViolationType.unauthorizedWrite =>
'CrdtSyncIntegrityViolationException: '
'${violation.type.name}/${violation.operation.name} rejected '
'write to $row in scope ${violation.incomingScopeUuid}.$persisted',
};
}
}
/// Thrown when a user attempts to act in a scope they are not a member of.
final class CrdtScopeMembershipException implements Exception {
/// Creates a [CrdtScopeMembershipException].
const CrdtScopeMembershipException({
required this.userId,
required this.scopeId,
});
/// The authenticated user id supplied to `transactionForUser`.
final UuidValue userId;
/// The scope id the transaction attempted to act in.
final UuidValue scopeId;
@override
String toString() =>
'CrdtScopeMembershipException: user "$userId" is not a member of '
'scope "$scopeId".';
}
/// Thrown when a user attempts to write in a scope with a non-writable role.
final class CrdtScopeRoleException implements Exception {
/// Creates a [CrdtScopeRoleException].
const CrdtScopeRoleException({
required this.userId,
required this.scopeId,
this.role,
});
/// The authenticated user id supplied to `transactionForUser`.
final UuidValue userId;
/// The scope id the transaction attempted to write in.
final UuidValue scopeId;
/// The role that does not allow writes.
final CrdtScopeRole? role;
@override
String toString() =>
'CrdtScopeRoleException: user "$userId" with role "$role" cannot write '
'in scope "$scopeId".';
}
@internal
final class PendingOutboundIntegrityViolation implements Exception {
const PendingOutboundIntegrityViolation({

View file

@ -69,3 +69,12 @@ extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
/// start with 'user' are synced.
bool get isSynced => name.startsWith('user');
}
/// CRDT write capability for a scope role.
///
/// The implicit personal scope resolves to [CrdtScopeRole.readWrite]. A missing
/// role means no shared membership row was found and does not grant writes.
extension CrdtScopeRoleWriteAccess on CrdtScopeRole? {
/// Whether this role may write CRDT rows in the scope.
bool get canWrite => this == CrdtScopeRole.readWrite;
}

View file

@ -20,38 +20,53 @@ typedef CrdtMergeMetadataLookup = ({
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
/// Each batch is zero or more scope, handshake, and/or merge frames 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].
/// When [allowCloseBeforeBatch] is true, returns `null` if the transport
/// stream closes before the next batch starts. [CrdtSyncClose] is an
/// intentional peer request to end the streaming session and returns `null`
/// even after frames for a partial batch have already arrived. The caller
/// must not persist checkpoint progress for such a discarded partial batch.
/// 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({
/// If the transport stream closes after a frame was already received without
/// a [CrdtSyncClose] control frame, the partial batch is still treated as an
/// error.
///
/// An idle timeout only ends an *empty* batch: once any frame has been
/// collected the timeout is ignored and collection waits for the explicit
/// terminator.
Future<CrdtSyncCycleBatch?> collectNextBatch({
bool allowCloseBeforeBatch = false,
}) async {
final mergeSet = <CrdtMergeChange>[];
final batch = CrdtSyncCycleBatch();
while (await moveNext()) {
switch (current) {
case CrdtSyncMergeChunk(changes: final changes):
mergeSet.addAll(changes);
case final CrdtSyncScopeSet event:
batch.scopeSet = event;
case final CrdtSyncSinceHlc event:
batch.sinceHlcs[event.uuidScopeId] = event;
case CrdtSyncMergeChunk(:final changes):
batch.changes.addAll(changes);
case CrdtSyncIdleTimeout():
if (mergeSet.isEmpty) return mergeSet;
if (batch.isEmpty) return batch;
case CrdtSyncEndOfBatch():
return mergeSet;
return batch;
case CrdtSyncClose():
return null;
default:
throw CrdtSyncUnexpectedEventException(
expected: '"CrdtSyncMergeChunk" or "CrdtSyncEndOfBatch"',
expected: 'a sync cycle frame',
received: current,
);
}
}
if (mergeSet.isEmpty && allowCloseBeforeBatch) return null;
if (batch.isEmpty && allowCloseBeforeBatch) return null;
throw const CrdtSyncStreamClosedException(phase: 'end-of-batch');
}
@ -70,6 +85,21 @@ extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEve
}
}
/// One sync cycle's inbound frames.
class CrdtSyncCycleBatch {
/// The peer's scope announcement for this cycle, if it sent one.
CrdtSyncScopeSet? scopeSet;
/// The peer's resume vectors, keyed by scope.
final Map<UuidValue, CrdtSyncSinceHlc> sinceHlcs = {};
/// The peer's merge changes for this cycle.
final List<CrdtMergeChange> changes = [];
/// Whether the peer sent nothing this cycle (it was idle).
bool get isEmpty => scopeSet == null && sinceHlcs.isEmpty && changes.isEmpty;
}
/// Helpers for grouping merge changes into stream payload batches.
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
/// Emits lists with at most [batchSize] changes from this stream.

View file

@ -0,0 +1,204 @@
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart' hide Protocol;
import 'package:uuid/uuid.dart';
import '../protocol/protocol.dart';
/// Resolves shared-scope membership from the `crdt_scope_members` table.
///
/// The table is `database: all`, so the same code runs on every node:
/// authoritatively on the server (the source of truth) and against the local
/// read-only cache on a follower. A user's personal scope is implicit — a user
/// always belongs to the scope whose UUID equals their own user UUID — and
/// needs no membership row. Explicit rows grant access to additional shared
/// scopes.
///
/// Queries are raw SQL rather than the generated ORM on purpose: `find<T>`
/// resolves the table through the session's serialization manager keyed by the
/// model [Type], and a server session only registers the server-package model
/// classes — never this client-package [CrdtScopeMember]. Raw SQL is
/// serialization-manager-agnostic and runs identically on both ends, matching
/// the rest of the CRDT engine's cross-node queries.
class CrdtScopeMembership {
const CrdtScopeMembership._();
/// Returns the scope UUIDs [userUuid] may sync.
///
/// Includes the implicit personal scope and every explicit shared membership,
/// de-duplicated and sorted by UUID string so both peers iterate
/// deterministically.
static Future<List<UuidValue>> memberScopes(
DatabaseSession session,
UuidValue userUuid, {
Transaction? transaction,
}) async {
final grants = await memberGrants(
session,
userUuid,
transaction: transaction,
);
return [for (final grant in grants) grant.uuidScopeId];
}
/// Returns the authoritative scope grants for [userUuid].
///
/// Like [memberScopes] but carries each scope's `role`. The implicit
/// personal scope is included with [CrdtScopeRole.readWrite]. Used to build the
/// authoritative [CrdtSyncScopeSet] announcement so a follower can project
/// roles into its local cache.
static Future<List<CrdtScopeGrant>> memberGrants(
DatabaseSession session,
UuidValue userUuid, {
Transaction? transaction,
}) async {
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final result = await session.db.unsafeQuery(
'SELECT s."${CrdtScope.t.uuidScopeId.columnName}", '
'm."${CrdtScopeMember.t.role.columnName}" '
'FROM "${CrdtScopeMember.t.tableName}" m '
'JOIN "${CrdtScope.t.tableName}" s '
'ON s."${CrdtScope.t.id.columnName}" = m."${CrdtScopeMember.t.scopeId.columnName}" '
'WHERE m."${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid',
transaction: transaction,
);
final grantsByUuid = <String, CrdtScopeGrant>{};
for (final row in result) {
final scopeUuid = Protocol().deserialize<UuidValue>(row[0]);
grantsByUuid[scopeUuid.uuid] = CrdtScopeGrant(
uuidScopeId: scopeUuid,
role: _roleFromDatabase(row[1]),
);
}
// Assigned after the shared rows so the personal grant stays readWrite
// even if a stray membership row exists for it, matching [roleOf].
grantsByUuid[userUuid.uuid] = CrdtScopeGrant(
uuidScopeId: userUuid,
role: CrdtScopeRole.readWrite,
);
return grantsByUuid.values.toList()
..sort((a, b) => a.uuidScopeId.uuid.compareTo(b.uuidScopeId.uuid));
}
/// The role [userUuid] holds in [scopeUuid], or null if none is recorded.
///
/// The implicit personal scope resolves to [CrdtScopeRole.readWrite]. On the
/// server this reads authoritative membership; on a client it reads the
/// projected membership cache.
static Future<CrdtScopeRole?> roleOf(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
Transaction? transaction,
}) async {
if (userUuid == scopeUuid) return CrdtScopeRole.readWrite;
final scopeId = await _scopeIdForUuid(
session,
scopeUuid,
transaction: transaction,
);
if (scopeId == null) return null;
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
final result = await session.db.unsafeQuery(
'SELECT "${CrdtScopeMember.t.role.columnName}" '
'FROM "${CrdtScopeMember.t.tableName}" '
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid '
'AND "${CrdtScopeMember.t.scopeId.columnName}" = $encodedScopeId '
'LIMIT 1',
transaction: transaction,
);
return result.isEmpty ? null : _roleFromDatabase(result.first[0]);
}
/// Reconciles the local `crdt_scope_members` cache from an authoritative
/// [grants] announcement — a follower's read-only projection of its own
/// memberships.
///
/// Upserts a row per shared grant (the implicit personal scope is skipped)
/// and deletes rows for [userUuid] whose scope is no longer granted, so a
/// revoked or demoted membership does not linger offline. Scopes must already
/// be materialized locally; a grant whose scope is unknown is skipped.
@internal
static Future<void> projectFollowerMembership(
DatabaseSession session, {
required UuidValue userUuid,
required List<CrdtScopeGrant> grants,
}) async {
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final keptScopeIds = <int>[];
for (final grant in grants) {
if (grant.uuidScopeId == userUuid) continue; // personal scope is implicit
final scopeId = await _scopeIdForUuid(session, grant.uuidScopeId);
if (scopeId == null) continue; // not materialized locally yet
keptScopeIds.add(scopeId);
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
final encodedRole = ValueEncoder.instance.convert(grant.role.toJson());
await session.db.unsafeExecute(
'INSERT INTO "${CrdtScopeMember.t.tableName}" '
'("${CrdtScopeMember.t.scopeId.columnName}", '
'"${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.role.columnName}") '
'VALUES ($encodedScopeId, $encodedUserUuid, $encodedRole) '
'ON CONFLICT ("${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.scopeId.columnName}") '
'DO UPDATE SET "${CrdtScopeMember.t.role.columnName}" = $encodedRole',
);
}
final notInClause = keptScopeIds.isEmpty
? ''
: 'AND "${CrdtScopeMember.t.scopeId.columnName}" NOT IN '
'(${keptScopeIds.map(ValueEncoder.instance.convert).join(', ')})';
await session.db.unsafeExecute(
'DELETE FROM "${CrdtScopeMember.t.tableName}" '
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid '
'$notInClause',
);
}
/// Returns whether [userUuid] may act in [scopeUuid].
///
/// The implicit personal scope is accepted without a membership row.
static Future<bool> isMember(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
Transaction? transaction,
}) async {
final role = await roleOf(
session,
userUuid: userUuid,
scopeUuid: scopeUuid,
transaction: transaction,
);
return role != null;
}
}
CrdtScopeRole _roleFromDatabase(Object? value) {
if (value == null) {
throw StateError('crdt_scope_members.role must not be null.');
}
return CrdtScopeRole.fromJson(value as String);
}
Future<int?> _scopeIdForUuid(
DatabaseSession session,
UuidValue scopeUuid, {
Transaction? transaction,
}) async {
final encodedScopeUuid = ValueEncoder.instance.convert(scopeUuid);
final result = await session.db.unsafeQuery(
'SELECT "${CrdtScope.t.id.columnName}" FROM "${CrdtScope.t.tableName}" '
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScopeUuid '
'LIMIT 1',
transaction: transaction,
);
return result.isEmpty ? null : result.first[0] as int;
}

View file

@ -0,0 +1,273 @@
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:uuid/uuid.dart';
import '../managers/scope.dart';
import '../protocol/protocol.dart';
import 'extensions.dart';
import 'merge.dart';
import 'scope_membership.dart';
/// How a peer decides which scopes it syncs.
enum CrdtSyncPeerMode {
/// Dictates the scope set from authoritative membership.
authoritative,
/// Adopts the scope set announced by the authoritative peer.
follower,
}
/// De-duplicates [scopeIds] and sorts them by UUID string so both peers iterate
/// scopes in the same deterministic order.
List<UuidValue> _sortedUniqueScopeIds(Iterable<UuidValue> scopeIds) {
final byUuid = {for (final scopeId in scopeIds) scopeId.uuid: scopeId};
return byUuid.values.toList()..sort((a, b) => a.uuid.compareTo(b.uuid));
}
/// De-duplicates [grants] by scope and sorts them by scope UUID string.
List<CrdtScopeGrant> _sortedUniqueGrants(Iterable<CrdtScopeGrant> grants) {
final byUuid = {for (final grant in grants) grant.uuidScopeId.uuid: grant};
return byUuid.values.toList()
..sort((a, b) => a.uuidScopeId.uuid.compareTo(b.uuidScopeId.uuid));
}
/// Whether the grant list [a] last announced equals the current list [b].
bool _grantsEqual(List<CrdtScopeGrant>? a, List<CrdtScopeGrant> b) {
if (a == null || a.length != b.length) return false;
for (var i = 0; i < b.length; i++) {
if (a[i].uuidScopeId != b[i].uuidScopeId || a[i].role != b[i].role) {
return false;
}
}
return true;
}
/// Owns the scope state of one sync session and every scope decision its driver
/// (`CrdtSync.sync`) makes: which scopes are active, their handshake and
/// checkpoint state, membership reconciliation, and per-frame authorization.
///
/// The driver owns the wire (yielding and reading frames, the once/continuous
/// control flow); this owns the scopes. It needs only a [DatabaseSession] and
/// the membership helpers, so it carries no protocol or transport concerns.
class CrdtScopeSyncSession {
/// Creates a scope session for [userId] acting in [mode] against [_session].
///
/// [peerNodeId] is the remote peer's CRDT node, learned from its connect
/// handshake before this session is constructed.
CrdtScopeSyncSession(
this._session, {
required UuidValue userId,
required CrdtSyncPeerMode mode,
required UuidValue peerNodeId,
}) : _userId = userId,
_mode = mode,
_peerNodeId = peerNodeId;
final DatabaseSession _session;
final UuidValue _userId;
final CrdtSyncPeerMode _mode;
final UuidValue _peerNodeId;
/// Session-lived scope manager. Reused across cycles so its in-memory cache
/// of already-materialized scopes survives, sparing a follower a per-cycle
/// `getOrCreate` round-trip for scopes it has already created this session.
late final CrdtScopeManager _scopeManager = CrdtScopeManager(_session);
/// The grant set this peer last announced (null until the first announcement).
List<CrdtScopeGrant>? _announcedGrants;
/// This peer's current local grants, refreshed by [reconcile].
List<CrdtScopeGrant> _localGrants = const [];
/// The grant set the peer last announced, adopted by [adoptPeerGrants].
List<CrdtScopeGrant> _peerGrants = const [];
/// The scopes cycled this round, sorted, materialized for a follower.
List<UuidValue> _activeScopeIds = const [];
/// UUID strings of [_activeScopeIds] and of [_peerGrants], for O(1) lookups.
Set<String> _activeUuids = const {};
Set<String> _peerUuids = const {};
/// Active scopes this peer may send local writes for.
Set<String> _writableUuids = const {};
/// Scopes for which this peer has already sent its [CrdtSyncSinceHlc].
final Set<String> _sinceHlcSent = {};
/// The peer's node id from its connect handshake.
UuidValue get peerNodeId => _peerNodeId;
/// What the peer has seen per scope (advances as we send), seeded from its
/// [CrdtSyncSinceHlc] — presence means the scope's handshake completed both
/// ways, so it can stream changes.
final Map<UuidValue, Map<UuidValue, Hlc>> _checkpointsByScope = {};
/// The grants this peer should announce this cycle.
List<CrdtScopeGrant> get localGrants => _localGrants;
/// The scopes cycled this round (sorted, deterministic on both peers).
List<UuidValue> get activeScopeIds => _activeScopeIds;
/// The authenticated user this sync session represents.
UuidValue get userId => _userId;
/// Whether this peer is authoritative for scope membership.
bool get isAuthoritative => _mode == CrdtSyncPeerMode.authoritative;
/// Whether the local grants changed since the last announcement.
bool get shouldAnnounce => !_grantsEqual(_announcedGrants, _localGrants);
/// Records that [localGrants] was just announced, so it is not re-sent until
/// it changes again.
void markAnnounced() => _announcedGrants = _localGrants;
/// Re-resolves local membership, then recomputes the active scope set.
///
/// Used at the top of every data-loop cycle so an authoritative peer picks up
/// its own membership changes. Establishment instead recomputes from the
/// *announced* grants (via [adoptPeerGrants]) without re-reading, so a grant
/// that lands mid-handshake cannot make this peer handshake a different scope
/// count than it announced.
Future<void> reconcile() async {
_localGrants = _sortedUniqueGrants(await _resolveLocalGrants());
await _recomputeActiveScopes();
_refreshWritableScopes();
}
/// Adopts the peer's announced [peerGrants] and recomputes the active scope
/// set from the grants already resolved (no membership re-read). A follower
/// materializes the announced scopes locally and projects their roles into the
/// members cache (projection needs the scope rows to exist); an authoritative
/// peer keeps cycling its own membership.
Future<void> adoptPeerGrants(List<CrdtScopeGrant> peerGrants) async {
_peerGrants = _sortedUniqueGrants(peerGrants);
_peerUuids = {for (final g in _peerGrants) g.uuidScopeId.uuid};
await _recomputeActiveScopes();
if (_mode == CrdtSyncPeerMode.follower) {
await CrdtScopeMembership.projectFollowerMembership(
_session,
userUuid: _userId,
grants: _peerGrants,
);
}
_refreshWritableScopes();
}
/// Recomputes the active scope set from the current local and peer grants
/// (materializing a follower's adopted scopes), then prunes in-session state
/// for scopes that left the set — e.g. a membership the peer revoked.
Future<void> _recomputeActiveScopes() async {
_activeScopeIds = _sortedUniqueScopeIds(
await _resolveOrderedScopeIds(
localScopeIds: [for (final g in _localGrants) g.uuidScopeId],
peerScopeIds: [for (final g in _peerGrants) g.uuidScopeId],
),
);
_activeUuids = {for (final s in _activeScopeIds) s.uuid};
_sinceHlcSent.removeWhere((uuid) => !_activeUuids.contains(uuid));
_checkpointsByScope.removeWhere((s, _) => !_activeUuids.contains(s.uuid));
}
void _refreshWritableScopes() {
if (_mode == CrdtSyncPeerMode.authoritative) {
_writableUuids = _activeUuids;
return;
}
// The peer's announced grants already carry the authoritative roles, so a
// follower derives writability in memory instead of re-reading the cache
// it projected from these same grants. The personal scope is always
// writable; consumers only consult scopes in the active set.
_writableUuids = {
_userId.uuid,
for (final grant in _peerGrants)
if (grant.role.canWrite) grant.uuidScopeId.uuid,
};
}
/// Marks that this peer is sending its [CrdtSyncSinceHlc] for [scopeId],
/// returning whether the scope was newly handshaked (so the driver sends one).
bool markHandshakeSent(UuidValue scopeId) => _sinceHlcSent.add(scopeId.uuid);
/// Records the peer's resume vector for [scopeId] from its [sinceHlc],
/// completing the scope's handshake from this peer's side.
void recordPeerHandshake(UuidValue scopeId, CrdtSyncSinceHlc sinceHlc) {
_checkpointsByScope[scopeId] = {
for (final checkpoint in sinceHlc.nodeCheckpoints) checkpoint.nodeId: checkpoint,
};
}
/// Checkpoints for writable scopes whose handshake completed both ways, keyed
/// by scope — the input to a pending-change collection pass.
Map<UuidValue, List<Hlc>> get sendableCheckpoints => {
for (final scopeId in _activeScopeIds)
if (_checkpointsByScope[scopeId] != null && _writableUuids.contains(scopeId.uuid))
scopeId: _checkpointsByScope[scopeId]!.values.toList(),
};
/// Whether any active scope still lacks the peer handshake state.
bool get hasIncompleteActiveHandshake => _activeScopeIds.any(
(scopeId) => _checkpointsByScope[scopeId] == null,
);
/// Advances the in-session checkpoint for [scopeId] past a just-sent [change],
/// so the next collection does not resend it.
void advanceCheckpoint(UuidValue scopeId, CrdtMergeChange change) {
final checkpoints = _checkpointsByScope[scopeId];
if (checkpoints == null) return;
checkpoints[change.uuidNodeId] = change.hlc.maxBetween(
checkpoints[change.uuidNodeId],
);
}
/// Whether this peer authorizes acting in [scopeId]: an authoritative peer
/// trusts its own membership, a follower trusts the peer's announced set. This
/// is the wire-side counterpart of the server's membership gate.
bool accepts(UuidValue scopeId) =>
(_mode == CrdtSyncPeerMode.follower ? _peerUuids : _activeUuids).contains(
scopeId.uuid,
);
/// The greatest checkpoint HLC tracked for [scopeId], or null if none.
Hlc? checkpointMaxOf(UuidValue scopeId) {
final checkpoints = _checkpointsByScope[scopeId];
if (checkpoints == null || checkpoints.isEmpty) return null;
return checkpoints.values.max;
}
/// Resolves the local grants this peer announces in its [CrdtSyncScopeSet].
///
/// Authoritative peers enumerate [CrdtScopeMembership.memberGrants].
/// Followers send an empty set because the authoritative peer never widens
/// access from follower-reported scope state.
Future<List<CrdtScopeGrant>> _resolveLocalGrants() async {
switch (_mode) {
case CrdtSyncPeerMode.authoritative:
return CrdtScopeMembership.memberGrants(_session, _userId);
case CrdtSyncPeerMode.follower:
await _scopeManager.getOrCreate(_userId);
return const [];
}
}
/// Resolves the ordered lockstep scopes cycled this round.
///
/// Authoritative peers dictate their own set; followers adopt the peer's
/// announced set, materializing each scope locally.
Future<List<UuidValue>> _resolveOrderedScopeIds({
required List<UuidValue> localScopeIds,
required List<UuidValue> peerScopeIds,
}) async {
switch (_mode) {
case CrdtSyncPeerMode.authoritative:
return localScopeIds;
case CrdtSyncPeerMode.follower:
final scopeIds = _sortedUniqueScopeIds(peerScopeIds);
for (final scopeId in scopeIds) {
await _scopeManager.getOrCreate(scopeId);
}
return scopeIds;
}
}
}

View file

@ -13,9 +13,14 @@ import 'exceptions.dart';
import 'extensions.dart';
import 'integrity_violation.dart';
import 'merge.dart';
import 'scope_membership.dart';
import 'scope_sync.dart';
export 'scope_sync.dart' show CrdtSyncPeerMode;
/// Callback function for when a merge is successful.
typedef CrdtSyncOnMergeSuccess = FutureOr<void> Function(Hlc syncedHlc);
typedef CrdtSyncOnMergeSuccess =
FutureOr<void> Function(UuidValue scopeUuid, Hlc syncedHlc);
/// A tuple representing the ownership of a domain row.
typedef DomainRowOwner = ({bool exists, int? scopeId});
@ -123,7 +128,7 @@ class CrdtSync {
'${uuid.v5(Namespace.oid.value, canonicalSignature)}';
}
/// Streams local CRDT changes that a peer node has not seen yet.
/// Streams pending changes for every scope in [checkpointsByScopeUuid].
///
/// Changes are emitted in insert, update, then delete order. Domain row and
/// column payloads are resolved incrementally as each change is yielded.
@ -133,20 +138,39 @@ class CrdtSync {
/// in the domain table. Peers need the attempted fact to converge; local FK
/// projection materializes only the safe visible value into domain tables.
///
/// All changes for nodes that are not present in the [nodeCheckpoints] list
/// are collected and emitted. Passing an empty list will collect all changes.
/// Resolves the scopes' internal ids once and keeps their checkpoint vectors
/// scoped by those internal ids. Node ids are stable per replica and may
/// appear in multiple scopes, so checkpoint filtering must compare both
/// `scopeId` and `uuidNodeId`. This still runs one query per change kind for
/// the whole pass. Per-row ownership and integrity checks resolve against
/// each row's own scope.
///
/// All changes for nodes that are not present in a scope's checkpoint list
/// are collected and emitted. Passing an empty list for a scope will collect
/// all of its changes. Each change carries its [CrdtMergeChange.uuidScopeId].
Stream<CrdtMergeChange> collectPendingChanges(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required List<Hlc> nodeCheckpoints,
required Map<UuidValue, List<Hlc>> checkpointsByScopeUuid,
}) async* {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
if (checkpointsByScopeUuid.isEmpty) return;
final scopes = await CrdtScope.db.find(
session,
where: (t) => t.uuidScopeId.inSet(checkpointsByScopeUuid.keys.toSet()),
);
final scopeUuidById = {
for (final scope in scopes) scope.id!: scope.uuidScopeId,
};
final checkpointsByScopeId = {
for (final scope in scopes)
scope.id!: checkpointsByScopeUuid[scope.uuidScopeId] ?? const <Hlc>[],
};
try {
await for (final change in _streamPendingChanges(
session,
crdtUser,
nodeCheckpoints,
scopeUuidById,
checkpointsByScopeId,
)) {
yield change;
}
@ -155,29 +179,31 @@ class CrdtSync {
}
}
/// Creates the [CrdtSyncSinceHlc] checkpoint for the post-connect handshake.
/// Creates the [CrdtSyncSinceHlc] checkpoint for a scope handshake.
///
/// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
/// has received from each known node, tagged with the source node id.
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required UuidValue scopeId,
}) async {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
final scope = await CrdtScopeManager(session).getOrCreate(scopeId);
final localNodeId = scope.currentNode!.uuidNodeId;
final nodes = await CrdtNode.db.find(
final scopeNodes = await CrdtScopeNode.db.find(
session,
where: (t) =>
t.scopeId.equals(crdtUser.id) &
t.uuidNodeId.notEquals(crdtUser.currentNode!.uuidNodeId),
t.scopeId.equals(scope.id) & t.nodeId.notEquals(scope.currentNodeId),
include: CrdtScopeNode.include(node: CrdtNode.include()),
);
return CrdtSyncSinceHlc(
uuidScopeId: scopeId,
nodeCheckpoints: [
// The local node is always included to avoid collecting its own changes.
Hlc.now(crdtUser.currentNode!.uuidNodeId),
for (final node in nodes) node.lastReceivedHlc ?? Hlc.zero(node.uuidNodeId),
Hlc.now(localNodeId),
for (final scopeNode in scopeNodes)
scopeNode.lastReceivedHlc ?? Hlc.zero(scopeNode.node!.uuidNodeId),
],
);
}
@ -192,114 +218,111 @@ class CrdtSync {
///
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
/// empty.
Future<Hlc?> mergeInboundBatch(
Future<Hlc?> _mergeInboundBatch(
DatabaseSession session, {
required UuidValue userId,
required UuidValue scopeId,
required UuidValue otherNodeId,
required CrdtMergeSet mergeSet,
}) async {
if (mergeSet.isEmpty) return null;
final maxSyncedHlc = mergeSet.maxHlc;
final crdtDb = _openCrdtDatabase(session);
await crdtDb.mergeChanges(mergeSet, scopeId: userId);
await crdtDb.mergeChanges(mergeSet, scopeId: scopeId);
if (maxSyncedHlc != null) {
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: userId);
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: scopeId);
}
return maxSyncedHlc;
}
/// Streams a framed outbound sync batch of merge changes.
Stream<CrdtSyncStreamEvent> streamOutboundBatch(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required List<Hlc> nodeCheckpoints,
}) async* {
yield* collectPendingChanges(
session,
userId: userId,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints,
)
.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 the peer's per-node checkpoints. Sent changes advance
/// the corresponding in-memory checkpoints for subsequent sends within the
/// session.
/// Both peers exchange [CrdtSyncConnect] and a lockstep [CrdtSyncScopeSet]
/// before the data loop. Each cycle sends a combined batch — scope
/// announcement when grants changed, [CrdtSyncSinceHlc] for newly active
/// scopes, and [CrdtSyncMergeChunk]s — closed by [CrdtSyncEndOfBatch] when
/// anything was sent. Inbound frames are de-multiplexed by collectNextBatch
/// until [CrdtSyncEndOfBatch] when this peer sent a batch, or an idle timeout
/// when both peers had nothing to send.
///
/// 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.
/// When [once] is true the loop may run an extra cycle after handshakes
/// complete so merge data can flow; then it closes symmetrically. Continuous
/// mode loops with [_continuousSyncInterval] between idle cycles.
Stream<CrdtSyncStreamEvent> sync(
DatabaseSession session, {
required UuidValue userId,
required Stream<CrdtSyncStreamEvent> inbound,
required CrdtSyncPeerMode mode,
bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess,
}) async* {
// Idle timeouts are a continuous-only affordance: they let an idle cycle
// settle into an empty batch without closing the stream. A `once` session
// is strictly lockstep — every batch ends with a [CrdtSyncEndOfBatch] and
// the session with a [CrdtSyncClose] — so it must wait for those end frames
// rather than truncate a slow peer's batch on a timeout.
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()),
),
once
? inbound
: inbound.timeout(
const Duration(seconds: 1),
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
),
);
var sessionCompleted = false;
try {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
final localNodeId = crdtUser.currentNode!.uuidNodeId;
final scope = await CrdtScopeManager(session).getOrCreate(userId);
final localNodeId = scope.currentNode!.uuidNodeId;
yield CrdtSyncConnect(
localNodeId: localNodeId,
syncTablesHash: currentSyncTablesHash,
);
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
final peerNodeId = peerConnect.localNodeId;
_validateSyncTablesHash(peerConnect.syncTablesHash);
yield await createSyncSinceHlc(
final scopes = CrdtScopeSyncSession(
session,
userId: userId,
peerNodeId: peerNodeId,
mode: mode,
peerNodeId: peerConnect.localNodeId,
);
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>();
final nodeCheckpoints = {
for (final checkpoint in peerSinceHlc.nodeCheckpoints)
checkpoint.nodeId: checkpoint,
};
await scopes.reconcile();
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
scopes.markAnnounced();
final peerScopeSet = await inboundIterator.moveAndThrowIfNot<CrdtSyncScopeSet>();
await scopes.adoptPeerGrants(peerScopeSet.scopes);
while (true) {
await scopes.reconcile();
final hadSendableCheckpoints = scopes.sendableCheckpoints.isNotEmpty;
var hasChanges = false;
final outboundScopes = <UuidValue>{};
if (scopes.shouldAnnounce) {
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
scopes.markAnnounced();
hasChanges = true;
}
for (final scopeId in scopes.activeScopeIds) {
if (!scopes.markHandshakeSent(scopeId)) continue;
yield await createSyncSinceHlc(session, scopeId: scopeId);
hasChanges = true;
}
final pendingLocalChanges = collectPendingChanges(
session,
userId: userId,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints.values.toList(),
checkpointsByScopeUuid: scopes.sendableCheckpoints,
);
var hasChanges = false;
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
hasChanges = true;
for (final change in changes) {
final lastCheckpoint = nodeCheckpoints[change.uuidNodeId];
nodeCheckpoints[change.uuidNodeId] = change.hlc.maxBetween(lastCheckpoint);
scopes.advanceCheckpoint(change.uuidScopeId, change);
outboundScopes.add(change.uuidScopeId);
}
yield CrdtSyncMergeChunk(changes: changes);
}
@ -307,28 +330,32 @@ class CrdtSync {
yield CrdtSyncEndOfBatch();
}
final inboundMergeSet = await inboundIterator.collectNextBatch(
final batch = await inboundIterator.collectNextBatch(
allowCloseBeforeBatch: !once,
);
if (inboundMergeSet == null) {
if (batch == null) {
if (once) {
yield CrdtSyncClose();
}
sessionCompleted = true;
return;
}
final lastReceivedFromPeer = await mergeInboundBatch(
await _applyCycleBatch(
session,
userId: userId,
otherNodeId: peerNodeId,
mergeSet: inboundMergeSet,
scopes,
batch,
outboundScopes,
onMergeSuccess,
);
if (hasChanges || inboundMergeSet.isNotEmpty) {
await onMergeSuccess?.call(
nodeCheckpoints.values.max.maxBetween(lastReceivedFromPeer),
);
}
if (once) {
if (scopes.hasIncompleteActiveHandshake) {
continue;
}
if (!hadSendableCheckpoints && scopes.sendableCheckpoints.isNotEmpty) {
continue;
}
yield CrdtSyncClose();
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
sessionCompleted = true;
@ -349,6 +376,17 @@ class CrdtSync {
// Wait for the configured interval before checking for local changes again.
await Future<void>.delayed(_continuousSyncInterval);
}
} on CrdtSyncStreamClosedException {
// A continuous session ending is normal: the peer closed its outbound
// (typically a cancel). Whether the stream closed between batches or
// mid-batch, nothing is left to do — complete gracefully so the `finally`
// skips the force-cancel that races WebSocket teardown and surfaces
// spurious "connection closed" errors on the peer. A `once` session has a
// completion contract (full handshake plus the symmetric Close), so a
// close before that is a real truncation and must propagate.
if (once) rethrow;
sessionCompleted = true;
return;
} finally {
// Cancelling inbound on normal completion races with WebSocket stream
// teardown and produces "connection closed" errors on the peer. On normal
@ -363,6 +401,112 @@ class CrdtSync {
}
}
Future<void> _applyCycleBatch(
DatabaseSession session,
CrdtScopeSyncSession scopes,
CrdtSyncCycleBatch batch,
Set<UuidValue> outboundScopes,
CrdtSyncOnMergeSuccess? onMergeSuccess,
) async {
if (batch.scopeSet != null) {
await scopes.adoptPeerGrants(batch.scopeSet!.scopes);
}
for (final entry in batch.sinceHlcs.entries) {
if (scopes.accepts(entry.key)) {
scopes.recordPeerHandshake(entry.key, entry.value);
}
}
final mergedScopes = <UuidValue>{};
final changesByScope = <UuidValue, List<CrdtMergeChange>>{};
for (final change in batch.changes) {
changesByScope.putIfAbsent(change.uuidScopeId, () => []).add(change);
}
for (final entry in changesByScope.entries) {
final scopeId = entry.key;
if (!scopes.accepts(scopeId)) continue;
mergedScopes.add(scopeId);
if (scopes.isAuthoritative) {
await _assertCanMergeInboundScope(
session,
scopeId: scopeId,
userId: scopes.userId,
changes: entry.value,
);
}
final receivedHlc = await _mergeInboundBatch(
session,
scopeId: scopeId,
otherNodeId: scopes.peerNodeId,
mergeSet: entry.value,
);
await _reportMerge(onMergeSuccess, scopes, scopeId, receivedHlc);
}
for (final scopeId in outboundScopes.difference(mergedScopes)) {
await _reportMerge(onMergeSuccess, scopes, scopeId, null);
}
}
Future<void> _assertCanMergeInboundScope(
DatabaseSession session, {
required UuidValue scopeId,
required UuidValue userId,
required List<CrdtMergeChange> changes,
}) async {
if (changes.isEmpty || userId == scopeId) return;
final role = await CrdtScopeMembership.roleOf(
session,
userUuid: userId,
scopeUuid: scopeId,
);
if (role.canWrite) return;
final firstChange = changes.first;
final now = DateTime.now().toUtc();
final violation = CrdtSyncIntegrityViolation(
type: CrdtSyncViolationType.unauthorizedWrite,
domainTableName: firstChange.tableName,
uuidRowId: firstChange.uuidRowId,
ownerScopeUuid: null,
incomingScopeUuid: scopeId,
operation: _operationForInboundChange(firstChange),
uuidNodeId: firstChange.uuidNodeId,
crdtDataRowId: null,
hlcDatetime: firstChange.hlcDatetime,
hlcCounter: firstChange.hlcCounter,
firstSeenAt: now,
lastSeenAt: now,
occurrences: 1,
);
final persisted = await recordCrdtSyncIntegrityViolation(
session,
violation: violation,
);
throw CrdtSyncIntegrityViolationException(persisted);
}
CrdtSyncViolationOperation _operationForInboundChange(CrdtMergeChange change) {
return switch (change) {
CrdtMergeInsert() => CrdtSyncViolationOperation.mergeInsert,
CrdtMergeUpdate() => CrdtSyncViolationOperation.mergeUpdate,
CrdtMergeDelete() => CrdtSyncViolationOperation.mergeDelete,
};
}
/// Reports a successful merge for [scopeId] to [onMergeSuccess], combining the
/// scope's checkpoint high-water mark with the [receivedHlc] just merged.
Future<void> _reportMerge(
CrdtSyncOnMergeSuccess? onMergeSuccess,
CrdtScopeSyncSession scopes,
UuidValue scopeId,
Hlc? receivedHlc,
) async {
final checkpointMax = scopes.checkpointMaxOf(scopeId);
if (checkpointMax == null) return;
await onMergeSuccess?.call(scopeId, checkpointMax.maxBetween(receivedHlc));
}
/// Drains [iterator] until the peer closes the stream.
///
/// Used to settle the inbound transport after the `once` close handshake so
@ -400,26 +544,26 @@ class CrdtSync {
Stream<CrdtMergeChange> _streamPendingChanges(
DatabaseSession session,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, UuidValue> scopeUuidById,
Map<int, List<Hlc>> checkpointsByScopeId,
) async* {
// Domain ownership is immutable while a collection runs, so read each
// row's owner at most once across all three streams.
final ownerCache = DomainRowOwnerCache();
yield* _streamInserts(session, crdtUser, nodeCheckpoints, ownerCache);
yield* _streamUpdates(session, crdtUser, nodeCheckpoints, ownerCache);
yield* _streamDeletes(session, crdtUser, nodeCheckpoints, ownerCache);
yield* _streamInserts(session, scopeUuidById, checkpointsByScopeId, ownerCache);
yield* _streamUpdates(session, scopeUuidById, checkpointsByScopeId, ownerCache);
yield* _streamDeletes(session, scopeUuidById, checkpointsByScopeId, ownerCache);
}
Stream<CrdtMergeInsert> _streamInserts(
DatabaseSession session,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, UuidValue> scopeUuidById,
Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache,
) async* {
final rows = await CrdtDataRow.db.find(
session,
where: (t) => _rowHlcAfterFilter(t, crdtUser, nodeCheckpoints),
where: (t) => _rowHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataRow.include(
tbl: CrdtSchemaTable.include(),
node: CrdtNode.include(),
@ -439,6 +583,9 @@ class CrdtSync {
final dartName = _classNamesByTableName[tableName];
if (dartName == null) continue;
final scopeId = row.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final domainRow = await _fetchDomainRow(
session,
tableName,
@ -446,7 +593,7 @@ class CrdtSync {
table,
dartName,
foreignKeyAttemptFieldsByRowId[row.id!],
crdtUser.id!,
scopeId,
ownerCache,
);
if (!domainRow.exists) {
@ -457,12 +604,12 @@ class CrdtSync {
tableName: tableName,
rowId: row.uuidRowId,
ownerScopeId: null,
incomingScopeUuid: crdtUser.uuidScopeId,
incomingScopeUuid: scopeUuid,
uuidNodeId: row.node!.uuidNodeId,
hlc: row.hlc,
);
}
if (domainRow.ownerScopeId != crdtUser.id) {
if (domainRow.ownerScopeId != scopeId) {
_throwPendingIntegrityViolation(
crdtDataRowId: row.id,
type: CrdtSyncViolationType.ownershipCollision,
@ -470,13 +617,14 @@ class CrdtSync {
tableName: tableName,
rowId: row.uuidRowId,
ownerScopeId: domainRow.ownerScopeId,
incomingScopeUuid: crdtUser.uuidScopeId,
incomingScopeUuid: scopeUuid,
uuidNodeId: row.node!.uuidNodeId,
hlc: row.hlc,
);
}
yield CrdtMergeInsert(
uuidScopeId: scopeUuid,
hlcDatetime: row.hlcDatetime,
hlcCounter: row.hlcCounter,
tableName: tableName,
@ -489,13 +637,13 @@ class CrdtSync {
Stream<CrdtMergeUpdate> _streamUpdates(
DatabaseSession session,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, UuidValue> scopeUuidById,
Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache,
) async* {
final fields = await CrdtDataField.db.find(
session,
where: (t) => _fieldHlcAfterFilter(t, crdtUser, nodeCheckpoints),
where: (t) => _fieldHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
@ -513,6 +661,8 @@ class CrdtSync {
continue;
}
final scopeId = field.row!.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final columnName = field.column!.name;
final columnValue = await _fetchOwnedColumnValue(
session,
@ -520,7 +670,7 @@ class CrdtSync {
field.row!.uuidRowId,
columnName,
field.foreignKey,
crdtUser.id!,
scopeId,
ownerCache,
);
if (!columnValue.exists) {
@ -531,12 +681,12 @@ class CrdtSync {
tableName: tableName,
rowId: field.row!.uuidRowId,
ownerScopeId: null,
incomingScopeUuid: crdtUser.uuidScopeId,
incomingScopeUuid: scopeUuid,
uuidNodeId: field.node!.uuidNodeId,
hlc: field.hlc,
);
}
if (columnValue.ownerScopeId != crdtUser.id) {
if (columnValue.ownerScopeId != scopeId) {
_throwPendingIntegrityViolation(
crdtDataRowId: field.row!.id,
type: CrdtSyncViolationType.ownershipCollision,
@ -544,13 +694,14 @@ class CrdtSync {
tableName: tableName,
rowId: field.row!.uuidRowId,
ownerScopeId: columnValue.ownerScopeId,
incomingScopeUuid: crdtUser.uuidScopeId,
incomingScopeUuid: scopeUuid,
uuidNodeId: field.node!.uuidNodeId,
hlc: field.hlc,
);
}
yield CrdtMergeUpdate(
uuidScopeId: scopeUuid,
hlcDatetime: field.hlcDatetime,
hlcCounter: field.hlcCounter,
tableName: tableName,
@ -564,13 +715,13 @@ class CrdtSync {
Stream<CrdtMergeDelete> _streamDeletes(
DatabaseSession session,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, UuidValue> scopeUuidById,
Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache,
) async* {
final tombstones = await CrdtDataDeleted.db.find(
session,
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, nodeCheckpoints),
where: (t) => _tombstoneHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataDeleted.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
node: CrdtNode.include(),
@ -582,13 +733,16 @@ class CrdtSync {
final tableName = tombstone.row!.tbl!.name;
if (!_syncTablesByName.containsKey(tableName)) continue;
final scopeId = tombstone.row!.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final owner = await _readDomainRowOwner(
session,
tableName,
tombstone.row!.uuidRowId,
ownerCache,
);
if (owner.exists && owner.scopeId != crdtUser.id) {
if (owner.exists && owner.scopeId != scopeId) {
_throwPendingIntegrityViolation(
crdtDataRowId: tombstone.row!.id,
type: CrdtSyncViolationType.ownershipCollision,
@ -596,13 +750,14 @@ class CrdtSync {
tableName: tableName,
rowId: tombstone.row!.uuidRowId,
ownerScopeId: owner.scopeId,
incomingScopeUuid: crdtUser.uuidScopeId,
incomingScopeUuid: scopeUuid,
uuidNodeId: tombstone.node!.uuidNodeId,
hlc: tombstone.hlc,
);
}
yield CrdtMergeDelete(
uuidScopeId: scopeUuid,
hlcDatetime: tombstone.hlcDatetime,
hlcCounter: tombstone.hlcCounter,
tableName: tableName,
@ -616,41 +771,41 @@ class CrdtSync {
Expression _rowHlcAfterFilter(
CrdtDataRowTable t,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, List<Hlc>> checkpointsByScopeId,
) =>
t.scopeId.equals(crdtUser.id) &
t.node.afterAnyCheckpointFilter(
t.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
_afterAnyScopeCheckpointFilter(
t.scopeId,
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
checkpointsByScopeId,
);
Expression _fieldHlcAfterFilter(
CrdtDataFieldTable t,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, List<Hlc>> checkpointsByScopeId,
) =>
t.row.scopeId.equals(crdtUser.id) &
t.node.afterAnyCheckpointFilter(
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
_afterAnyScopeCheckpointFilter(
t.row.scopeId,
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
checkpointsByScopeId,
);
Expression _tombstoneHlcAfterFilter(
CrdtDataDeletedTable t,
CrdtScope crdtUser,
List<Hlc> nodeCheckpoints,
Map<int, List<Hlc>> checkpointsByScopeId,
) =>
t.row.scopeId.equals(crdtUser.id) &
t.node.afterAnyCheckpointFilter(
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
_afterAnyScopeCheckpointFilter(
t.row.scopeId,
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
checkpointsByScopeId,
);
/// Loads a domain row for outbound insert sync.
@ -959,27 +1114,31 @@ class CrdtSync {
}
}
extension on CrdtNodeTable {
Expression afterAnyCheckpointFilter(
ColumnUuid uuidNodeId,
ColumnDateTime hlcDatetime,
ColumnInt hlcCounter,
List<Hlc> nodeCheckpoints,
) {
if (nodeCheckpoints.isEmpty) return Constant.bool(true);
final caseExpression = Case();
for (final checkpoint in nodeCheckpoints) {
Expression _afterAnyScopeCheckpointFilter(
ColumnInt scopeId,
ColumnUuid uuidNodeId,
ColumnDateTime hlcDatetime,
ColumnInt hlcCounter,
Map<int, List<Hlc>> checkpointsByScopeId,
) {
final caseExpression = Case();
var hasCheckpoint = false;
for (final MapEntry(key: normalizedScopeId, value: checkpoints)
in checkpointsByScopeId.entries) {
for (final checkpoint in checkpoints) {
hasCheckpoint = true;
caseExpression.when(
uuidNodeId.equals(checkpoint.nodeId),
scopeId.equals(normalizedScopeId) & uuidNodeId.equals(checkpoint.nodeId),
then:
(hlcDatetime > checkpoint.datetime) |
(hlcDatetime.equals(checkpoint.datetime) &
(hlcCounter > checkpoint.counter)),
);
}
return caseExpression.orElse(Constant.bool(true));
}
return hasCheckpoint
? caseExpression.orElse(Constant.bool(true))
: Constant.bool(true);
}
extension on Map<String, dynamic> {

View file

@ -2,13 +2,17 @@
// flag internal Serverpod APIs used by generated code.
// ignore_for_file: invalid_use_of_internal_member
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 '../crdt/exceptions.dart';
import '../crdt/extensions.dart';
import '../crdt/integrity_violation.dart';
import '../crdt/merge.dart';
import '../crdt/scope_membership.dart';
import '../crdt/sync.dart';
import '../protocol/protocol.dart';
import 'recorder.dart';
@ -20,6 +24,9 @@ part 'scope.dart';
/// Map of transaction hashes to the scope they are associated with.
final scopeForTransaction = <Transaction, CrdtScope>{};
/// Map of transaction hashes to the authenticated user associated with them.
final userForTransaction = <Transaction, UuidValue>{};
/// Database proxy that runs insert/update/delete ORM operations inside a
/// transaction to record each change in the CRDT tables.
class CrdtDatabase implements Database {
@ -111,6 +118,7 @@ class CrdtDatabase implements Database {
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
Stream<CrdtSyncStreamEvent> sync({
required Stream<CrdtSyncStreamEvent> inbound,
required CrdtSyncPeerMode mode,
UuidValue? userId,
bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess,
@ -123,6 +131,7 @@ class CrdtDatabase implements Database {
inbound: inbound,
once: once,
onMergeSuccess: onMergeSuccess,
mode: mode,
);
}
@ -184,20 +193,26 @@ class CrdtDatabase implements Database {
_delegate.serializationManager;
/// Merges [where] with the CRDT visibility predicates scoped to the queried
/// tables and the user associated with [transaction] (or the persistent
/// user). Without a user scope (e.g. server-side admin reads), a row is
/// only hidden when every user tracking it has it hidden.
Expression? _whereVisibleWithTombstone<T extends TableRow>(
/// tables and the user associated with [transaction] (or the persistent user).
///
/// Read filters are membership-wide. Write filters stay pinned to the acting
/// scope so mutations cannot cross scope boundaries.
Future<Expression?> _whereVisibleWithTombstone<T extends TableRow>(
Expression? where,
Include? include,
Transaction? transaction,
) {
Transaction? transaction, {
required bool membershipWide,
}) async {
final scopeIds = await _scopeIdsForQueries(
transaction,
membershipWide: membershipWide,
);
return mergeWhereWithTombstone<T>(
serializationManager,
where,
include,
tableIdForName: _recorder.tableIdForName,
scopeId: () => _recorder.scopeForQueries(transaction)?.id,
scopeIds: () => scopeIds,
);
}
@ -216,7 +231,12 @@ class CrdtDatabase implements Database {
}) async {
await _ensureInitialized();
final result = await _delegate.find<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction),
where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
limit: limit,
offset: offset,
orderBy: orderBy,
@ -244,7 +264,12 @@ class CrdtDatabase implements Database {
final table = serializationManager.getTableForType(T);
final where = table?.id.equals(id);
final result = await _delegate.findFirstRow<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction),
where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
transaction: transaction,
include: include,
lockMode: lockMode,
@ -268,7 +293,12 @@ class CrdtDatabase implements Database {
}) async {
await _ensureInitialized();
final result = await _delegate.findFirstRow<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction),
where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
offset: offset,
orderBy: orderBy,
orderByList: orderByList,
@ -289,6 +319,7 @@ class CrdtDatabase implements Database {
List<T> rows, {
Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
if (rows.isEmpty) return [];
await _ensureInitialized();
@ -348,6 +379,7 @@ class CrdtDatabase implements Database {
List<Column>? updateColumns,
Expression? updateWhere,
Transaction? transaction,
bool noReturn = false,
}) async {
await _ensureInitialized();
// TODO: Implement CRDT upsert.
@ -381,6 +413,7 @@ class CrdtDatabase implements Database {
List<T> rows, {
List<Column>? columns,
Transaction? transaction,
bool noReturn = false,
}) async {
if (rows.isEmpty) return [];
await _ensureInitialized();
@ -444,7 +477,12 @@ class CrdtDatabase implements Database {
final where = row.table.id.equals(row.id);
final updatedRows = await _delegate.updateWhere<T>(
columnValues: columnValues,
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
where: (await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: false,
))!,
transaction: transaction,
);
@ -489,6 +527,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList,
bool orderDescending = false,
Transaction? transaction,
bool noReturn = false,
}) async {
await _ensureInitialized();
if (_recorder.isCrdtTracked<T>()) {
@ -501,7 +540,12 @@ class CrdtDatabase implements Database {
(tx) async {
final result = await _delegate.updateWhere<T>(
columnValues: columnValues,
where: _whereVisibleWithTombstone<T>(where, null, tx)!,
where: (await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
))!,
limit: limit,
offset: offset,
orderBy: orderBy,
@ -529,6 +573,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList,
bool orderDescending = false,
Transaction? transaction,
bool noReturn = false,
}) async {
if (rows.isEmpty) return [];
await _ensureInitialized();
@ -572,6 +617,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList,
bool orderDescending = false,
Transaction? transaction,
bool noReturn = false,
}) async {
await _ensureInitialized();
if (!_recorder.isCrdtTracked<T>()) {
@ -591,7 +637,12 @@ class CrdtDatabase implements Database {
transaction,
(tx) async {
final rows = await _delegate.find<T>(
where: _whereVisibleWithTombstone<T>(where, null, tx),
where: await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
),
orderBy: orderBy,
orderByList: orderByList,
// Remove this once the deprecated member is removed.
@ -615,7 +666,12 @@ class CrdtDatabase implements Database {
}) async {
await _ensureInitialized();
return _delegate.count<T>(
where: _whereVisibleWithTombstone<T>(where, null, transaction),
where: await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: true,
),
limit: limit,
useCache: useCache,
transaction: transaction,
@ -631,7 +687,12 @@ class CrdtDatabase implements Database {
}) async {
await _ensureInitialized();
return _delegate.lockRows<T>(
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
where: (await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: false,
))!,
lockMode: lockMode,
transaction: transaction,
lockBehavior: lockBehavior,
@ -654,31 +715,100 @@ class CrdtDatabase implements Database {
/// Executes the [transactionFunction] in a transaction with the provided [userId].
///
/// The [userId] is used to record the CRDT operations in the database. This method
/// must be used instead of [transaction] when performing operations on the server,
/// where the database knows multiple users.
/// Without [scopeId], writes act in the user's personal scope. With [scopeId],
/// [userId] stays the authenticated identity and [scopeId] is the scope being
/// acted in; the pair is checked before the transaction starts.
Future<R> transactionForUser<R>(
UuidValue userId,
TransactionFunction<R> transactionFunction, {
UuidValue? scopeId,
TransactionSettings? settings,
}) async {
await _ensureInitialized();
// Ensure that the user exists with a node before starting the transaction.
final user = await _recorder.getOrCreateScope(userId);
final effectiveScopeId = scopeId ?? userId;
await _assertCanActInScope(userId, effectiveScopeId);
// Ensure that the scope exists with a node before starting the transaction.
final scope = await _recorder.getOrCreateScope(effectiveScopeId);
return transaction<R>(
(tx) async {
try {
scopeForTransaction[tx] = user;
scopeForTransaction[tx] = scope;
userForTransaction[tx] = userId;
return await transactionFunction(tx);
} finally {
scopeForTransaction.remove(tx);
userForTransaction.remove(tx);
}
},
settings: settings,
);
}
Future<void> _assertCanActInScope(UuidValue userId, UuidValue scopeId) async {
if (userId == scopeId) return;
// Authoritative membership: the source of truth on the server, the
// read-only cache on a follower (empty until populated). A null role means
// no membership row at all; a non-writable role means membership without
// write access.
final role = await CrdtScopeMembership.roleOf(
_delegate.session,
userUuid: userId,
scopeUuid: scopeId,
);
if (role == null) {
throw CrdtScopeMembershipException(userId: userId, scopeId: scopeId);
}
if (!role.canWrite) {
throw CrdtScopeRoleException(userId: userId, scopeId: scopeId, role: role);
}
}
Future<List<int>?> _scopeIdsForQueries(
Transaction? transaction, {
required bool membershipWide,
}) async {
if (!membershipWide) {
return _actingScopeIdsForQueries(transaction);
}
final userId = _userIdForQueries(transaction);
if (userId == null) return null;
// On the server this is authoritative membership; on a persistent client it
// is the server-projected membership cache.
return _scopeIdsForUuids(
await CrdtScopeMembership.memberScopes(_delegate.session, userId),
);
}
List<int>? _actingScopeIdsForQueries(Transaction? transaction) {
final scopeId = _recorder.scopeForQueries(transaction)?.id;
return scopeId == null ? null : [scopeId];
}
UuidValue? _userIdForQueries(Transaction? transaction) {
if (transaction != null) {
final userId = userForTransaction[transaction];
if (userId != null) return userId;
}
return _recorder.persistentUserId;
}
Future<List<int>> _scopeIdsForUuids(List<UuidValue> scopeUuids) async {
if (scopeUuids.isEmpty) return [];
final scopes = await CrdtScope.db.find(
_delegate.session,
where: (t) => t.uuidScopeId.inSet(scopeUuids.toSet()),
);
return [
for (final scope in scopes)
if (scope.id != null) scope.id!,
];
}
Future<UuidValue> _requireUserId(UuidValue? userId) async {
return userId ??
_recorder.persistentUserId ??

View file

@ -10,6 +10,10 @@ typedef _MergeContext = ({
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
Map<_MergeRowKey, _DomainOwner> domainOwners,
});
typedef _MergeNodes = ({
Map<UuidValue, CrdtNode> nodesByUuid,
Map<UuidValue, CrdtScopeNode> scopeNodesByUuid,
});
/// Adds merge-specific behavior to [CrdtMutationRecorder].
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
@ -47,6 +51,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
{for (final change in operations) change.uuidNodeId},
transaction,
);
final nodesByUuid = remoteNodes.nodesByUuid;
final context = await _loadMergeContext(mergeSet, transaction);
for (final operation in operations) {
@ -58,21 +63,21 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
case final CrdtMergeInsert insert:
await _applyMergeInsert(
insert,
remoteNodes,
nodesByUuid,
context,
transaction,
);
case final CrdtMergeUpdate update:
await _applyMergeUpdate(
update,
remoteNodes,
nodesByUuid,
context,
transaction,
);
case final CrdtMergeDelete delete:
await _applyMergeDelete(
delete,
remoteNodes,
nodesByUuid,
context,
transaction,
);
@ -91,14 +96,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
Future<void> _updateHlcFromIncomingOperations(
List<CrdtMergeChange> operations,
Map<UuidValue, CrdtNode> remoteNodes,
_MergeNodes remoteNodes,
Transaction transaction,
) async {
final maxIncomingHlc = operations.fold<Hlc?>(
null,
(current, change) => change.hlc.maxBetween(current),
);
final nodesToUpdate = <CrdtNode>[];
final scopeNodesToUpdate = <CrdtScopeNode>[];
if (maxIncomingHlc != null) {
final hlcManager = _getHlcManager(transaction);
@ -110,7 +115,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
hlcManager.merge(maxIncomingHlc);
}
nodesToUpdate.add(hlcManager.getNode());
await _persistCurrentNodeHlc(hlcManager, transaction);
}
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
@ -122,66 +127,59 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
for (final MapEntry(key: nodeId, value: incomingHlc)
in maxIncomingHlcByNode.entries) {
final remoteNode = remoteNodes[nodeId];
final remoteNode = remoteNodes.scopeNodesByUuid[nodeId];
if (remoteNode == null) continue;
final updatedNode = remoteNode.copyWith(
final updatedScopeNode = remoteNode.copyWith(
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
);
if (updatedNode.lastReceivedHlc == remoteNode.lastReceivedHlc) continue;
if (updatedScopeNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
continue;
}
nodesToUpdate.add(updatedNode);
remoteNodes[nodeId] = updatedNode;
scopeNodesToUpdate.add(updatedScopeNode);
remoteNodes.scopeNodesByUuid[nodeId] = updatedScopeNode;
}
if (nodesToUpdate.isNotEmpty) {
await CrdtNode.db.update(
if (scopeNodesToUpdate.isNotEmpty) {
await CrdtScopeNode.db.update(
_session,
nodesToUpdate,
scopeNodesToUpdate,
columns: (t) => [t.lastReceivedHlc],
transaction: transaction,
);
}
}
Future<Map<UuidValue, CrdtNode>> _findOrCreateNodesForMerge(
Future<_MergeNodes> _findOrCreateNodesForMerge(
int userId,
Set<UuidValue> nodeIds,
Transaction transaction,
) async {
if (nodeIds.isEmpty) return {};
var nodes = await CrdtNode.db.find(
_session,
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
transaction: transaction,
);
final existingNodeIds = nodes.map((node) => node.uuidNodeId).toSet();
final missingNodeIds = nodeIds.difference(existingNodeIds);
if (missingNodeIds.isNotEmpty) {
await CrdtNode.db.insert(
_session,
[
for (final uuidNodeId in missingNodeIds)
CrdtNode(
scopeId: userId,
uuidNodeId: uuidNodeId,
),
],
transaction: transaction,
ignoreConflicts: true,
);
nodes = await CrdtNode.db.find(
_session,
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
transaction: transaction,
if (nodeIds.isEmpty) {
return (
nodesByUuid: const <UuidValue, CrdtNode>{},
scopeNodesByUuid: const <UuidValue, CrdtScopeNode>{},
);
}
return {
for (final node in nodes) node.uuidNodeId: node,
final nodesByUuid = {
for (final uuidNodeId in nodeIds)
uuidNodeId: await _findOrCreateNode(uuidNodeId, transaction),
};
final scopeNodesByUuid = {
for (final entry in nodesByUuid.entries)
entry.key: await _findOrCreateScopeNode(
userId,
entry.value.id!,
transaction,
),
};
return (
nodesByUuid: nodesByUuid,
scopeNodesByUuid: scopeNodesByUuid,
);
}
Future<_MergeContext> _loadMergeContext(

View file

@ -357,28 +357,23 @@ class CrdtMutationRecorder {
UuidValue otherNodeId,
Hlc syncedHlc,
) async {
final user = await _scopeManager.getOrCreate(userId);
final scope = await _scopeManager.getOrCreate(userId);
await _db.transaction((transaction) async {
var node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.scopeId.equals(user.id) & t.uuidNodeId.equals(otherNodeId),
transaction: transaction,
final node = await _findOrCreateNode(otherNodeId, transaction);
final scopeNode = await _findOrCreateScopeNode(
scope.id!,
node.id!,
transaction,
);
node ??= await CrdtNode.db.insertRow(
_session,
CrdtNode(scopeId: user.id!, uuidNodeId: otherNodeId),
transaction: transaction,
);
final currentSyncHlc = node.lastReceivedHlc;
final currentSyncHlc = scopeNode.lastReceivedHlc;
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
return;
}
await CrdtNode.db.updateRow(
await CrdtScopeNode.db.updateRow(
_session,
node.copyWith(lastReceivedHlc: syncedHlc),
scopeNode.copyWith(lastReceivedHlc: syncedHlc),
columns: (t) => [t.lastReceivedHlc],
transaction: transaction,
);
@ -607,11 +602,13 @@ WHERE c."id" IN ($whereRowIds)
if (rows.isEmpty) return;
if (!isCrdtTracked<T>(rows.first.table)) return;
final hlcManager = _getHlcManager(transaction);
await record(
rows.first.table.tableName,
rows.uuidRowIds,
_getHlcManager(transaction),
hlcManager,
);
await _persistCurrentNodeHlc(hlcManager, transaction);
}
CrdtDataRow _newCrdtDataRow(
@ -1331,6 +1328,72 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
);
}
Future<void> _persistCurrentNodeHlc(
HlcManager hlcManager,
Transaction transaction,
) async {
await CrdtNode.db.updateRow(
_session,
hlcManager.getNode(),
columns: (t) => [t.lastHlc],
transaction: transaction,
);
}
Future<CrdtNode> _findOrCreateNode(
UuidValue uuidNodeId,
Transaction transaction,
) async {
var node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.uuidNodeId.equals(uuidNodeId),
transaction: transaction,
);
if (node != null) return node;
await CrdtNode.db.insert(
_session,
[CrdtNode(uuidNodeId: uuidNodeId)],
transaction: transaction,
ignoreConflicts: true,
);
node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.uuidNodeId.equals(uuidNodeId),
transaction: transaction,
);
return node ?? (throw StateError('Could not create CRDT node "$uuidNodeId".'));
}
Future<CrdtScopeNode> _findOrCreateScopeNode(
int scopeId,
int nodeId,
Transaction transaction,
) async {
var scopeNode = await CrdtScopeNode.db.findFirstRow(
_session,
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
transaction: transaction,
);
if (scopeNode != null) return scopeNode;
await CrdtScopeNode.db.insert(
_session,
[CrdtScopeNode(scopeId: scopeId, nodeId: nodeId)],
transaction: transaction,
ignoreConflicts: true,
);
scopeNode = await CrdtScopeNode.db.findFirstRow(
_session,
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
transaction: transaction,
);
return scopeNode ??
(throw StateError(
'Could not create CRDT scope-node row for scope $scopeId and node $nodeId.',
));
}
CrdtScope _getEffectiveScope(Transaction transaction) {
return scopeForQueries(transaction) ??
(throw StateError(

View file

@ -170,3 +170,48 @@ extension _CrdtDatabaseScope on CrdtDatabase {
}
}
}
/// Extension on [Table] that exposes [scopeEquals] for use in `where`
/// clauses to restrict membership-wide reads to a single scope.
extension ScopeUuidFilter on Table {
/// Expression that narrows a membership-wide read to [scopeUuid].
///
/// Reads are membership-wide and strip the database-local `scopeId`, so a
/// caller holding several scopes cannot otherwise restrict a query to one scope
/// or tell which scope a row came from. The placeholder is resolved to a
/// filter on the reserved `scopeId` column before the query runs. When the
/// scope UUID is not known locally the filter matches no rows.
///
/// Example:
/// ```dart
/// // Returns only persons in the shared scope.
/// final sharedPeople = await Person.db.find(
/// session,
/// where: (t) => t.scopeEquals(sharedScopeUuid),
/// );
///
/// // Combines with other filters.
/// final aliceInShared = await Person.db.find(
/// session,
/// where: (t) => t.name.equals('Alice') & t.scopeEquals(sharedScopeUuid),
/// );
/// ```
Expression scopeEquals(UuidValue scopeUuid) =>
_crdtScope.uuidScopeId.equals(scopeUuid);
CrdtScopeTable get _crdtScope {
return createRelationTable<CrdtScopeTable>(
relationFieldName: '${tableName}_crdt_scope',
field: _scopeIdColumn,
foreignField: CrdtScope.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
CrdtScopeTable(tableRelation: foreignTableRelation),
);
}
Column get _scopeIdColumn => columns.singleWhere(
(column) => column.columnName == 'scopeId' && column is ColumnInt,
orElse: () => throw StateError('Table "$tableName" has no scopeId column.'),
);
}

View file

@ -8,11 +8,11 @@ import '../protocol/protocol.dart';
/// null when the table is not registered for CRDT synchronization.
typedef CrdtTableIdResolver = int? Function(String tableName);
/// Resolves the [CrdtScope] id scoping the current query. Returns null when
/// no scope is available (e.g. server-side reads outside `transactionForUser`
/// Resolves the [CrdtScope] ids scoping the current query. Returns null when no
/// scope is available (e.g. server-side reads outside `transactionForUser`
/// without a persistent user), in which case the query is an admin read: rows
/// are not isolated and a row is hidden only when its owning scope hides it.
typedef CrdtScopeIdResolver = int? Function();
typedef CrdtScopeIdsResolver = List<int>? Function();
/// Private sentinel class used to signal that the CRDT visibility filter
/// should be bypassed. Detected via `is` type check instead of string
@ -27,29 +27,26 @@ final class _IncludeHiddenSentinel extends Expression<String> {
/// Extension on [Table] that exposes [includeHiddenRows] for use in `where`
/// clauses to bypass the CRDT tombstone/visibility filter.
///
/// When included in a `where` clause, all rows are returned — including those
/// that are tombstoned (soft-deleted) — instead of being hidden. The placeholder
/// is stripped before the query reaches the database, so no invalid SQL is
/// generated.
///
/// Example:
/// ```dart
/// // Returns all persons, including tombstoned ones.
/// final allPersons = await Person.db.find(
/// session,
/// where: (t) => t.includeHiddenRows,
/// );
///
/// // Returns all persons named 'Alice', including tombstoned ones.
/// final alicePersons = await Person.db.find(
/// session,
/// where: (t) => t.name.equals('Alice') & t.includeHiddenRows,
/// );
/// ```
extension IncludeTombstonedRows on Table {
/// Placeholder expression that signals the CRDT visibility filter should be
/// skipped, returning tombstoned rows alongside live rows.
/// Expression that bypasses the CRDT tombstone/visibility filter.
///
/// When included in a `where` clause, all rows are returned — including those
/// that are tombstoned (soft-deleted) — instead of being hidden.
///
/// Example:
/// ```dart
/// // Returns all persons, including tombstoned ones.
/// final allPersons = await Person.db.find(
/// session,
/// where: (t) => t.includeHiddenRows,
/// );
///
/// // Returns all persons named 'Alice', including tombstoned ones.
/// final alicePersons = await Person.db.find(
/// session,
/// where: (t) => t.name.equals('Alice') & t.includeHiddenRows,
/// );
/// ```
Expression get includeHiddenRows => const _IncludeHiddenSentinel();
}
@ -60,8 +57,8 @@ extension IncludeTombstonedRows on Table {
/// is null, only the root table and [where] are merged.
///
/// Each predicate only considers [CrdtDataRow]s recorded for the queried table
/// ([tableIdForName]) and, when a scope is available ([scopeId]), isolates the
/// query to that scope's rows. This prevents a tombstone recorded for another
/// ([tableIdForName]) and, when scopes are available ([scopeIds]), isolates the
/// query to those scopes' rows. This prevents a tombstone recorded for another
/// table or scope from masking an unrelated row that reuses the same UUID.
/// Without a scope (admin reads), rows are not isolated and a row stays
/// visible as long as its owning scope still sees it.
@ -85,13 +82,13 @@ Expression? mergeWhereWithTombstone<T extends TableRow>(
Expression? where,
Include? include, {
required CrdtTableIdResolver tableIdForName,
required CrdtScopeIdResolver scopeId,
required CrdtScopeIdsResolver scopeIds,
}) {
final includeObjectPredicates = _walkIncludeGraphForTombstone(
include,
null,
tableIdForName: tableIdForName,
scopeId: scopeId,
scopeIds: scopeIds,
);
final rootTable = serializationManager.getTableForType(T);
@ -99,7 +96,7 @@ Expression? mergeWhereWithTombstone<T extends TableRow>(
? where
: _mergeWhereOptional(
where,
rootTable?.whereVisibleOnCrdtRow(tableIdForName, scopeId),
rootTable?.whereVisibleOnCrdtRow(tableIdForName, scopeIds),
);
merged = _mergeWhereOptional(merged, includeObjectPredicates);
return merged;
@ -111,7 +108,7 @@ Expression? _walkIncludeGraphForTombstone(
Include? inc,
Expression? includeObjectPredicates, {
required CrdtTableIdResolver tableIdForName,
required CrdtScopeIdResolver scopeId,
required CrdtScopeIdsResolver scopeIds,
}) {
if (inc == null) return includeObjectPredicates;
if (inc is IncludeList) {
@ -119,14 +116,14 @@ Expression? _walkIncludeGraphForTombstone(
if (!_includesHiddenSentinel(inc.where)) {
inc.where = _mergeWhereOptional(
inc.where,
inc.table.whereVisibleOnCrdtRow(tableIdForName, scopeId),
inc.table.whereVisibleOnCrdtRow(tableIdForName, scopeIds),
);
}
return _walkIncludeGraphForTombstone(
inc.include,
includeObjectPredicates,
tableIdForName: tableIdForName,
scopeId: scopeId,
scopeIds: scopeIds,
);
}
var acc = includeObjectPredicates;
@ -140,7 +137,7 @@ Expression? _walkIncludeGraphForTombstone(
nested,
acc,
tableIdForName: tableIdForName,
scopeId: scopeId,
scopeIds: scopeIds,
);
continue;
}
@ -150,14 +147,14 @@ Expression? _walkIncludeGraphForTombstone(
if (childTable != null) {
acc = _mergeWhereOptional(
acc,
childTable.whereVisibleOnCrdtRow(tableIdForName, scopeId),
childTable.whereVisibleOnCrdtRow(tableIdForName, scopeIds),
);
}
acc = _walkIncludeGraphForTombstone(
nested,
acc,
tableIdForName: tableIdForName,
scopeId: scopeId,
scopeIds: scopeIds,
);
}
}
@ -190,15 +187,15 @@ extension on Table {
/// registered for CRDT synchronization. Keeps rows when the id is null
/// (unmatched [IncludeObject] joins) or no hidden CRDT row matches.
///
/// With a scope, rows are isolated to it (`scopeId = <scope>`) and checked
/// against the scope's tombstones with a correlated `NOT EXISTS` probe
/// covered by the (scopeId, tblId, uuidRowId) unique index. Without a
/// With scopes, rows are isolated to them (`scopeId IN (<scopes>)`) and
/// checked against those scopes' tombstones with a correlated `NOT EXISTS`
/// probe covered by the (scopeId, tblId, uuidRowId) unique index. Without a
/// scope (admin reads), there is no isolation filter and the probe is
/// keyed by the row's own `scopeId` column instead — covered by the same
/// index — so a row stays visible while its owning scope sees it.
Expression? whereVisibleOnCrdtRow(
CrdtTableIdResolver tableIdForName,
CrdtScopeIdResolver scopeId,
CrdtScopeIdsResolver scopeIds,
) {
if (id is! ColumnUuid) return null;
final tableId = tableIdForName(tableName);
@ -214,19 +211,26 @@ extension on Table {
));
final crdtRow = CrdtDataRow.t;
final effectiveScopeId = scopeId();
final effectiveScopeIds = scopeIds();
if (effectiveScopeIds != null && effectiveScopeIds.isEmpty) {
return id.equals(null) | const Expression('FALSE');
}
final scopeFilter = effectiveScopeIds == null
? '${crdtRow.scopeId} = $scopeColumn'
: '${crdtRow.scopeId} IN (${_sqlIntList(effectiveScopeIds)})';
final notExistsExpr = Expression(
'NOT EXISTS '
'(SELECT 1 FROM "${crdtRow.tableName}" '
'WHERE ${crdtRow.scopeId} = ${effectiveScopeId ?? scopeColumn} '
'WHERE $scopeFilter '
'AND ${crdtRow.tblId} = $tableId '
'AND ${crdtRow.uuidRowId} = $id '
'AND ${crdtRow.visibility} > $crdtRowLastVisibleVisibilityIndex)',
);
return id.equals(null) |
((effectiveScopeId != null)
? (scopeColumn.equals(effectiveScopeId) & notExistsExpr)
((effectiveScopeIds != null)
? (scopeColumn.inSet(effectiveScopeIds.toSet()) & notExistsExpr)
: notExistsExpr);
}
@ -239,3 +243,7 @@ extension on Table {
return null;
}
}
String _sqlIntList(Iterable<int> values) {
return values.map(ValueEncoder.instance.convert).join(', ');
}

View file

@ -19,7 +19,7 @@ class HlcManager {
scope.uuidScopeId,
scope.id!,
scope.currentNodeId!,
scope.currentNode!.lastReceivedHlc ?? Hlc.zero(scope.currentNode!.uuidNodeId),
scope.currentNode!.lastHlc ?? Hlc.zero(scope.currentNode!.uuidNodeId),
);
}
@ -52,8 +52,7 @@ class HlcManager {
/// Converts this manager state to the persisted current-node model.
CrdtNode getNode() => CrdtNode(
id: normalizedNodeId,
scopeId: normalizedScopeId,
uuidNodeId: uuidNodeId,
lastReceivedHlc: lastHlc,
lastHlc: lastHlc,
);
}

View file

@ -12,6 +12,9 @@ class CrdtScopeManager {
final Map<UuidValue, CrdtScope> _instances = {};
/// Canonical [CrdtNode] shared across all scopes on this database.
CrdtNode? _cachedCurrentNode;
/// Returns the cached [CrdtScope] for the given scope ID.
CrdtScope getCached(UuidValue uuidScopeId) =>
_instances[uuidScopeId] ??
@ -23,6 +26,7 @@ class CrdtScopeManager {
/// Clears the in-memory cache so state is reloaded from the store.
void clearCache() {
_instances.clear();
_cachedCurrentNode = null;
}
/// Returns the [CrdtScope] for the given scope ID.
@ -51,26 +55,101 @@ class CrdtScopeManager {
transaction: transaction,
);
if (scope.currentNode != null) {
return scope;
var currentNode = await _getOrCreateCurrentNode(transaction);
currentNode = await _preserveLatestCurrentNodeHlc(
currentNode,
scope.currentNode,
transaction,
);
if (scope.currentNodeId != currentNode.id) {
await CrdtScope.db.attachRow.currentNode(
_session,
scope,
currentNode,
transaction: transaction,
);
}
final currentNode = await CrdtNode.db.insertRow(
_session,
CrdtNode(scopeId: scope.id!),
transaction: transaction,
);
await _ensureScopeNode(scope.id!, currentNode.id!, transaction);
await CrdtScope.db.attachRow.currentNode(
_session,
scope,
currentNode,
transaction: transaction,
);
_cachedCurrentNode = currentNode;
return scope.copyWith(
currentNodeId: currentNode.id,
currentNode: currentNode,
);
}
Future<CrdtNode> _getOrCreateCurrentNode(Transaction transaction) async {
final cachedId = _cachedCurrentNode?.id;
if (cachedId != null) {
final node = await CrdtNode.db.findById(
_session,
cachedId,
transaction: transaction,
);
if (node != null) return node;
_cachedCurrentNode = null;
}
final existingScope = await CrdtScope.db.findFirstRow(
_session,
where: (t) => t.currentNodeId.notEquals(null),
orderBy: (t) => t.id,
include: CrdtScope.include(currentNode: CrdtNode.include()),
transaction: transaction,
);
final existingNode = existingScope?.currentNode;
if (existingNode != null) {
return _cachedCurrentNode = existingNode;
}
return _cachedCurrentNode = await CrdtNode.db.insertRow(
_session,
CrdtNode(),
transaction: transaction,
);
}
Future<CrdtNode> _preserveLatestCurrentNodeHlc(
CrdtNode currentNode,
CrdtNode? scopeCurrentNode,
Transaction transaction,
) async {
final scopeLastHlc = scopeCurrentNode?.lastHlc;
if (scopeLastHlc == null || scopeCurrentNode?.id == currentNode.id) {
return currentNode;
}
final currentLastHlc = currentNode.lastHlc;
if (currentLastHlc != null && currentLastHlc >= scopeLastHlc) {
return currentNode;
}
final updatedNode = currentNode.copyWith(
lastHlc: scopeLastHlc.copyWith(nodeId: currentNode.uuidNodeId),
);
await CrdtNode.db.updateRow(
_session,
updatedNode,
columns: (t) => [t.lastHlc],
transaction: transaction,
);
return updatedNode;
}
Future<void> _ensureScopeNode(
int scopeId,
int nodeId,
Transaction transaction,
) async {
await CrdtScopeNode.db.insert(
_session,
[CrdtScopeNode(scopeId: scopeId, nodeId: nodeId)],
transaction: transaction,
ignoreConflicts: true,
);
}
}

View file

@ -21,6 +21,7 @@ class CrdtSyncEndpoint extends Endpoint {
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
inbound: changes,
once: once,
mode: CrdtSyncPeerMode.authoritative,
);
}
}

View file

@ -3,6 +3,9 @@ class: CrdtMergeChange
sealed: true
extends: BaseHlc
fields:
### The scope this change belongs to.
uuidScopeId: UuidValue
### The table receiving the change.
tableName: String

View file

@ -1,15 +1,12 @@
### CRDT nodes table.
###
### This table stores all nodes that have been seen.
### This table stores stable CRDT replica identities that have been seen.
class: CrdtNode
table: crdt_nodes
database: all
fields:
### The scope that owns the node.
scope: CrdtScope?, relation(name=scope_nodes, onDelete=Cascade)
### Unique identifier for the node.
### Stable unique identifier for the replica/database install.
uuidNodeId: UuidValue, default=random_v7, unique
### The last HLC timestamp when the node has synchronized.
lastReceivedHlc: Hlc?
### Last HLC generated by the local replica when this row is the current node.
lastHlc: Hlc?

View file

@ -12,5 +12,5 @@ fields:
### The current node id of the scope.
currentNode: CrdtNode?, relation(optional)
### The nodes associated with the scope.
nodes: List<CrdtNode>?, relation(name=scope_nodes)
### The nodes associated with the scope and their per-scope checkpoints.
nodes: List<CrdtScopeNode>?, relation(name=scope_nodes)

View file

@ -0,0 +1,31 @@
### Authorization membership for shared CRDT scopes.
###
### `database: all`, so the table exists on every node and membership resolves
### with the same code on both ends. The server is the authoritative writer; a
### client copy (once populated) is a read-only cache for UI and offline role
### checks.
###
### Personal-scope membership is implicit: a user always belongs to the scope
### whose UUID equals their auth user UUID. Rows in this table represent shared
### scopes only.
class: CrdtScopeMember
table: crdt_scope_members
database: all
fields:
### Shared scope this membership grants access to.
scope: CrdtScope?, relation(onDelete=Cascade)
### Auth user UUID that may access the scope.
userUuid: UuidValue
### CRDT access role for this shared-scope membership.
role: CrdtScopeRole
indexes:
### Leads with userUuid so the per-cycle membership resolution
### (`WHERE userUuid = ?`) is index-covered, while still enforcing one row
### per (scope, user). `isMember` (`WHERE userUuid AND scopeId`) is covered
### too.
crdt_scope_member_unique_idx:
fields: userUuid, scopeId
unique: true

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