refactor: Move the core implementation to the shared package with no manual queries (#107)
This commit is contained in:
parent
92a8e22d04
commit
9f2ce4d9e6
142 changed files with 1992 additions and 14018 deletions
|
|
@ -286,8 +286,10 @@ which Serverpod does not support either.
|
|||
## How it works
|
||||
|
||||
- **Architecture.** The engine is a transparent layer between the application
|
||||
and its database. Because identical code runs on the client's SQLite and the
|
||||
server's Postgres, there are no asymmetric merge results to reconcile.
|
||||
and its database. Its models and database implementation live in the shared
|
||||
package; the client package only binds the generated module caller to the
|
||||
shared sync transport. Because identical code runs on the client's SQLite and
|
||||
the server's Postgres, there are no asymmetric merge results to reconcile.
|
||||
Migrations are generated symmetrically for both ends. The engine respects the
|
||||
three mathematical properties a CRDT must hold to be consistent: idempotence,
|
||||
associativity, and commutativity.
|
||||
|
|
|
|||
|
|
@ -135,8 +135,11 @@ class TombstoneScopeBenchmark {
|
|||
final db = _plainSession.db;
|
||||
final hiddenVisibility = CrdtDataRowVisibility.userDelete.index;
|
||||
|
||||
final benchUserId =
|
||||
(await db.unsafeQuery('SELECT MIN("id") FROM "crdt_scopes"')).first[0] as int;
|
||||
final benchScope = await CrdtScope.db.findFirstRow(
|
||||
_plainSession,
|
||||
orderBy: (t) => t.id,
|
||||
);
|
||||
final benchUserId = benchScope!.id!;
|
||||
await db.unsafeExecute('''
|
||||
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseUsers - 1)
|
||||
INSERT INTO "crdt_scopes" ("currentNodeId") SELECT NULL FROM n
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ A report similar to the one above is generated per pull request as a comment on
|
|||
|
||||
## Technical fundamentals
|
||||
|
||||
**Architecture.** The engine is a transparent layer between the application and its database. Because identical code runs on the client's SQLite and the server's Postgres, there are no asymmetric merge results to reconcile. Migrations are also generated symmetrically for both ends, which makes the migration process result in the exact same data on both ends. The entire engine is built respecting the three mathematical properties that must hold for a CRDT to be consistent: idempotence, associativity, and commutativity.
|
||||
**Architecture.** The engine is a transparent layer between the application and its database. Its models and database implementation live in the shared package, while the client package only binds the generated module caller to the shared sync transport. Because identical code runs on the client's SQLite and the server's Postgres, there are no asymmetric merge results to reconcile. Migrations are also generated symmetrically for both ends, which makes the migration process result in the exact same data on both ends. The entire engine is built respecting the three mathematical properties that must hold for a CRDT to be consistent: idempotence, associativity, and commutativity.
|
||||
|
||||
**Metadata model.** Three metadata tables track change history *without copying row data*: `crdt_data_rows` (insert + visibility), `crdt_data_fields` (update), and `crdt_data_tombstone` (delete/restore). HLCs are used to order field-level values and break ties. A monotone causal-length tombstone governs row existence, so add/delete/restore advance generations forward and can never oscillate. All metadata is normalized, with normalized IDs cached, to keep both the storage and performance impact minimal.
|
||||
|
||||
|
|
@ -176,7 +176,6 @@ The package is functionally in a production-ready state, but some APIs need to i
|
|||
|
||||
### Technical debts
|
||||
|
||||
- **Move the core of the implementation to a shared package instead of the client package**. Since the same code runs on the client and server, and requires table models, the first implementation was done in the client package. This means that users have to import a client package on their servers, crossing an isolation that is not common on Serverpod projects. To fix this, support for tables on shared models on Serverpod must be added, mapped on Serverpod's issue #5400. Then the core of the package must be moved to a shared package and all manually written SQL queries will be replaced by the Serverpod ORM.
|
||||
- **Require that all foreign-key relations are declared deferrable**. Because the package does not store a copy of the data with the metadata, there are some scenarios in which the stored data might violate the causal order of the foreign-key constraints and a merge can fail irreversibly. To prevent this, all foreign-key constraints must be deferrable, which will be enabled by Serverpod's issue #5338.
|
||||
- **Rename the `scope` name for data isolation to `space`**. This is a small refactor to avoid confusion with the `scope` keyword used in the Serverpod Auth modules. Using `space` is also clearer for usage on an application level.
|
||||
- **Reorganize the package structure to facilitate the review**. The package is currently organized in a way that does not facilitate the review process. The `recorder.dart` file is extended through part files, sharing a too many private properties and methods. The resolver must be split into smaller, scoped files, as well as its unique and foreign-key resolver utils.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,16 @@
|
|||
export 'src/crdt/client_sync.dart';
|
||||
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
|
||||
export 'src/crdt/extensions.dart';
|
||||
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';
|
||||
export 'src/database/schema.dart';
|
||||
export 'src/database/session.dart' show CrdtDatabaseAccess, CrdtDatabaseSession;
|
||||
export 'src/database/tombstone.dart' show IncludeTombstonedRows;
|
||||
export 'src/generated/protocol.dart';
|
||||
export 'src/hlc/exceptions.dart';
|
||||
export 'src/hlc/hlc.dart';
|
||||
export 'src/managers/hlc.dart';
|
||||
export 'src/managers/scope.dart';
|
||||
|
|
|
|||
147
packages/serverpod_offline_sync/lib/src/crdt/client_sync.dart
Normal file
147
packages/serverpod_offline_sync/lib/src/crdt/client_sync.dart
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
|
||||
import '../database/session.dart';
|
||||
import '../generated/protocol.dart';
|
||||
import 'sync.dart';
|
||||
|
||||
/// Opens the remote half of a CRDT sync stream.
|
||||
typedef CrdtSyncTransport =
|
||||
Stream<CrdtSyncStreamEvent> Function({
|
||||
required Stream<CrdtSyncStreamEvent> changes,
|
||||
required bool once,
|
||||
});
|
||||
|
||||
/// 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 a CRDT stream transport.
|
||||
class CrdtSyncClient {
|
||||
/// Creates a [CrdtSyncClient] for the provided [transport].
|
||||
CrdtSyncClient(CrdtSyncTransport transport) : _transport = transport;
|
||||
|
||||
final CrdtSyncTransport _transport;
|
||||
|
||||
/// Pushes local pending changes for [session] to the remote peer once.
|
||||
///
|
||||
/// The [session] must be wrapped in a [CrdtDatabaseSession].
|
||||
Future<void> syncOnce(
|
||||
DatabaseSession session, {
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) async {
|
||||
await _startSyncSession(session, once: true, onMergeSuccess: onMergeSuccess).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 = _transport(
|
||||
changes: outboundChanges.stream,
|
||||
once: once,
|
||||
);
|
||||
|
||||
final subscription = session.crdtDb
|
||||
.sync(
|
||||
inbound: remoteStream,
|
||||
once: once,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
mode: CrdtSyncPeerMode.follower,
|
||||
)
|
||||
.listen(
|
||||
(event) {
|
||||
outboundChanges.addIfNotClosed(event);
|
||||
if (once && event is CrdtSyncClose) {
|
||||
unawaited(outboundChanges.closeOrSkip());
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on StreamController<CrdtSyncStreamEvent> {
|
||||
void addIfNotClosed(CrdtSyncStreamEvent event) {
|
||||
if (isClosed) return;
|
||||
add(event);
|
||||
}
|
||||
|
||||
Future<void> closeOrSkip() async {
|
||||
if (!isClosed) {
|
||||
unawaited(close());
|
||||
}
|
||||
// The done event is only delivered once the stream has a listener. If the
|
||||
// method stream never listened (e.g. opening it failed), awaiting [done]
|
||||
// would hang forever.
|
||||
if (hasListener) {
|
||||
await done;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Completer<void> {
|
||||
void completeOrSkip() {
|
||||
if (isCompleted) return;
|
||||
complete();
|
||||
}
|
||||
|
||||
void completeErrorOrSkip(Object error, StackTrace stackTrace) {
|
||||
if (isCompleted) return;
|
||||
completeError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
178
packages/serverpod_offline_sync/lib/src/crdt/exceptions.dart
Normal file
178
packages/serverpod_offline_sync/lib/src/crdt/exceptions.dart
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import 'package:meta/meta.dart';
|
||||
import 'package:serverpod_serialization/serverpod_serialization.dart';
|
||||
|
||||
import '../generated/protocol.dart';
|
||||
import '../hlc/hlc.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 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.';
|
||||
}
|
||||
|
||||
/// Thrown when sync observes terminal CRDT metadata/domain-row inconsistency.
|
||||
final class CrdtSyncIntegrityViolationException extends CrdtSyncException {
|
||||
/// Creates a [CrdtSyncIntegrityViolationException].
|
||||
const CrdtSyncIntegrityViolationException(this.violation);
|
||||
|
||||
/// The integrity violation that caused sync to stop.
|
||||
final CrdtSyncIntegrityViolation violation;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final persisted = violation.id == null
|
||||
? ''
|
||||
: ' Persisted violation id: ${violation.id}.';
|
||||
final row = '${violation.domainTableName}.${violation.uuidRowId}';
|
||||
return switch (violation.type) {
|
||||
CrdtSyncViolationType.ownershipCollision =>
|
||||
'CrdtSyncIntegrityViolationException: '
|
||||
'${violation.type.name}/${violation.operation.name} for $row '
|
||||
'belongs to scope ${violation.ownerScopeUuid}, but sync attempted '
|
||||
'scope ${violation.incomingScopeUuid}.$persisted',
|
||||
CrdtSyncViolationType.missingDomainRow =>
|
||||
'CrdtSyncIntegrityViolationException: '
|
||||
'${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({
|
||||
required this.crdtDataRowId,
|
||||
required this.type,
|
||||
required this.operation,
|
||||
required this.tableName,
|
||||
required this.rowId,
|
||||
required this.ownerScopeId,
|
||||
required this.incomingScopeUuid,
|
||||
required this.uuidNodeId,
|
||||
this.hlc,
|
||||
});
|
||||
|
||||
final int? crdtDataRowId;
|
||||
final CrdtSyncViolationType type;
|
||||
final CrdtSyncViolationOperation operation;
|
||||
final String tableName;
|
||||
final UuidValue rowId;
|
||||
final int? ownerScopeId;
|
||||
final UuidValue incomingScopeUuid;
|
||||
final UuidValue uuidNodeId;
|
||||
final Hlc? hlc;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
79
packages/serverpod_offline_sync/lib/src/crdt/extensions.dart
Normal file
79
packages/serverpod_offline_sync/lib/src/crdt/extensions.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import '../generated/protocol.dart';
|
||||
import '../hlc/hlc.dart';
|
||||
|
||||
/// Last serialized index that still represents a visible CRDT row.
|
||||
///
|
||||
/// The [CrdtDataRowVisibility] is ordered by visibility, so the index is used
|
||||
/// to compare the visibility states. The last visible state in the enum is
|
||||
/// [CrdtDataRowVisibility.foreignKeyRestrictRestore].
|
||||
final crdtRowLastVisibleVisibilityIndex =
|
||||
CrdtDataRowVisibility.foreignKeyRestrictRestore.index;
|
||||
|
||||
/// Extensions for the [CrdtDataRowVisibility] enum.
|
||||
extension CrdtDataRowVisibilityExtension on CrdtDataRowVisibility {
|
||||
/// Whether this visibility state hides the row from domain queries.
|
||||
///
|
||||
bool get isHidden => index > crdtRowLastVisibleVisibilityIndex;
|
||||
}
|
||||
|
||||
/// Extensions for the [CrdtDataField] class.
|
||||
extension CrdtDataFieldExtension on CrdtDataField {
|
||||
/// The HLC represented by this field.
|
||||
Hlc get hlc => node == null
|
||||
? (throw StateError(
|
||||
'Fetch the CRDT field with the node included to get the HLC.',
|
||||
))
|
||||
: toHlcForNode(node!.uuidNodeId);
|
||||
}
|
||||
|
||||
/// Extensions for the [CrdtDataRow] class.
|
||||
extension CrdtDataRowExtension on CrdtDataRow {
|
||||
/// Whether this row is currently hidden from domain queries.
|
||||
bool get isHidden => visibility.isHidden;
|
||||
|
||||
/// The HLC represented by this row.
|
||||
Hlc get hlc => node == null
|
||||
? (throw StateError(
|
||||
'Fetch the CRDT row with the node included to get the HLC.',
|
||||
))
|
||||
: toHlcForNode(node!.uuidNodeId);
|
||||
}
|
||||
|
||||
/// Extensions for the [CrdtDataDeleted] class.
|
||||
extension CrdtDataDeletedExtension on CrdtDataDeleted {
|
||||
/// Whether this visibility generation hides the row.
|
||||
bool get isDeleted => clFlag.isEven;
|
||||
|
||||
/// The HLC represented by this visibility generation.
|
||||
Hlc get hlc => node == null
|
||||
? (throw StateError(
|
||||
'Fetch the CRDT visibility generation with the node included to get the HLC.',
|
||||
))
|
||||
: toHlcForNode(node!.uuidNodeId);
|
||||
}
|
||||
|
||||
/// Extensions for the [CrdtDataForeignKey] class.
|
||||
extension CrdtDataForeignKeyExtension on CrdtDataForeignKey {
|
||||
/// Whether an active projection override exists for this FK field.
|
||||
///
|
||||
/// Derived from [CrdtDataForeignKey.overrideReason]: an override is active if
|
||||
/// and only if [overrideReason] is non-null. This is the authoritative test
|
||||
/// for whether the domain row's FK value differs from the attempted value.
|
||||
bool get hasOverride => overrideReason != null;
|
||||
}
|
||||
|
||||
/// Extensions for the [CrdtDataDeletedReason] enum.
|
||||
extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
|
||||
/// Whether this tombstone reason is synced across replicas. All reasons that
|
||||
/// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
|
||||
import '../generated/protocol.dart';
|
||||
|
||||
/// Persists or updates the durable row for an integrity violation.
|
||||
Future<CrdtSyncIntegrityViolation> recordCrdtSyncIntegrityViolation(
|
||||
DatabaseSession session, {
|
||||
required CrdtSyncIntegrityViolation violation,
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final existing = await CrdtSyncIntegrityViolation.db.findFirstRow(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.type.equals(violation.type) &
|
||||
t.operation.equals(violation.operation) &
|
||||
t.domainTableName.equals(violation.domainTableName) &
|
||||
t.uuidRowId.equals(violation.uuidRowId) &
|
||||
t.ownerScopeUuid.equals(violation.ownerScopeUuid) &
|
||||
t.incomingScopeUuid.equals(violation.incomingScopeUuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final newViolation = existing != null
|
||||
? existing.copyWith(
|
||||
crdtDataRowId: violation.crdtDataRowId ?? existing.crdtDataRowId,
|
||||
uuidNodeId: violation.uuidNodeId ?? existing.uuidNodeId,
|
||||
hlcDatetime: violation.hlcDatetime,
|
||||
hlcCounter: violation.hlcCounter,
|
||||
lastSeenAt: now,
|
||||
occurrences: existing.occurrences + 1,
|
||||
)
|
||||
: violation.copyWith(
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
occurrences: 1,
|
||||
);
|
||||
|
||||
final persisted = await CrdtSyncIntegrityViolation.db.upsertRow(
|
||||
session,
|
||||
newViolation,
|
||||
conflictColumns: (t) => [
|
||||
t.type,
|
||||
t.operation,
|
||||
t.domainTableName,
|
||||
t.uuidRowId,
|
||||
t.ownerScopeUuid,
|
||||
t.incomingScopeUuid,
|
||||
],
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
return persisted!;
|
||||
}
|
||||
191
packages/serverpod_offline_sync/lib/src/crdt/merge.dart
Normal file
191
packages/serverpod_offline_sync/lib/src/crdt/merge.dart
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../generated/protocol.dart';
|
||||
import '../hlc/hlc.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 = ({
|
||||
Map<String, Set<UuidValue>> rowIdsByTable,
|
||||
Map<String, Set<String>> columnNamesByTable,
|
||||
});
|
||||
|
||||
/// 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 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 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 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 batch = CrdtSyncCycleBatch();
|
||||
|
||||
while (await moveNext()) {
|
||||
switch (current) {
|
||||
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 (batch.isEmpty) return batch;
|
||||
case CrdtSyncEndOfBatch():
|
||||
return batch;
|
||||
case CrdtSyncClose():
|
||||
return null;
|
||||
default:
|
||||
throw CrdtSyncUnexpectedEventException(
|
||||
expected: 'a sync cycle frame',
|
||||
received: current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.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"');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 = List<CrdtMergeChange>.from(this)
|
||||
..sort((left, right) => left.hlc.compareTo(right.hlc));
|
||||
return operations;
|
||||
}
|
||||
|
||||
/// Collects the row and column metadata needed to load merge state.
|
||||
CrdtMergeMetadataLookup collectMetadataLookup({
|
||||
required Map<String, Set<String>> columnNamesByTableName,
|
||||
}) {
|
||||
final rowIdsToLoadByTable = <String, Set<UuidValue>>{};
|
||||
final columnNamesToLoadByTable = <String, Set<String>>{};
|
||||
|
||||
for (final change in this) {
|
||||
final availableColumnNames = columnNamesByTableName[change.tableName];
|
||||
if (availableColumnNames == null) continue;
|
||||
|
||||
rowIdsToLoadByTable.putIfAbsent(change.tableName, () => {}).add(change.uuidRowId);
|
||||
columnNamesToLoadByTable.putIfAbsent(change.tableName, () => {}).addAll(
|
||||
switch (change) {
|
||||
CrdtMergeInsert() => availableColumnNames.difference({'id'}),
|
||||
CrdtMergeUpdate() => [change.columnName],
|
||||
_ => [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
rowIdsByTable: rowIdsToLoadByTable,
|
||||
columnNamesByTable: columnNamesToLoadByTable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helpers shared by every merge change.
|
||||
extension CrdtMergeChangeExtension on CrdtMergeChange {
|
||||
/// The HLC represented by this change.
|
||||
Hlc get hlc => Hlc(hlcDatetime, hlcCounter, uuidNodeId);
|
||||
}
|
||||
|
||||
/// Convenience helpers for insert changes.
|
||||
extension CrdtMergeInsertExtension on CrdtMergeInsert {
|
||||
/// The database column payload represented by this change.
|
||||
Map<String, Object?> get databaseColumns {
|
||||
final payload = switch (data) {
|
||||
final TableRow row => row.toJsonForDatabase(),
|
||||
final Map<String, dynamic> map => map,
|
||||
_ => throw StateError(
|
||||
'Unsupported merge insert payload type for $tableName: '
|
||||
'${data.runtimeType}. Expected TableRow or Map<String, dynamic>.',
|
||||
),
|
||||
};
|
||||
|
||||
return Map<String, Object?>.from(payload as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import 'package:meta/meta.dart';
|
||||
import 'package:serverpod_database/serverpod_database.dart' hide Protocol;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../generated/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.
|
||||
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 memberships = await CrdtScopeMember.db.find(
|
||||
session,
|
||||
where: (t) => t.userUuid.equals(userUuid),
|
||||
transaction: transaction,
|
||||
include: CrdtScopeMember.include(scope: CrdtScope.include()),
|
||||
);
|
||||
|
||||
final grantsByUuid = <String, CrdtScopeGrant>{};
|
||||
for (final membership in memberships) {
|
||||
final scopeUuid = membership.scope!.uuidScopeId;
|
||||
grantsByUuid[scopeUuid.uuid] = CrdtScopeGrant(
|
||||
uuidScopeId: scopeUuid,
|
||||
role: membership.role,
|
||||
);
|
||||
}
|
||||
// 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 membership = await CrdtScopeMember.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.userUuid.equals(userUuid) & t.scopeId.equals(scopeId),
|
||||
transaction: transaction,
|
||||
);
|
||||
return membership?.role;
|
||||
}
|
||||
|
||||
/// Reconciles the local `crdt_scope_members` cache from an authoritative
|
||||
/// [grants] announcement — a follower's read-only projection of its own
|
||||
/// memberships.
|
||||
///
|
||||
/// Upserts the materialized shared grants (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 keptScopeIds = <int>[];
|
||||
final memberships = <CrdtScopeMember>[];
|
||||
|
||||
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);
|
||||
memberships.add(
|
||||
CrdtScopeMember(
|
||||
scopeId: scopeId,
|
||||
userUuid: userUuid,
|
||||
role: grant.role,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (memberships.isNotEmpty) {
|
||||
await CrdtScopeMember.db.upsert(
|
||||
session,
|
||||
memberships,
|
||||
conflictColumns: (t) => [t.userUuid, t.scopeId],
|
||||
updateColumns: (t) => [t.role],
|
||||
noReturn: true,
|
||||
);
|
||||
}
|
||||
|
||||
await CrdtScopeMember.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.userUuid.equals(userUuid) &
|
||||
(keptScopeIds.isEmpty
|
||||
? Constant.bool(true)
|
||||
: t.scopeId.notInSet(keptScopeIds.toSet())),
|
||||
noReturn: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _scopeIdForUuid(
|
||||
DatabaseSession session,
|
||||
UuidValue scopeUuid, {
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final scope = await CrdtScope.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidScopeId.equals(scopeUuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
return scope?.id;
|
||||
}
|
||||
273
packages/serverpod_offline_sync/lib/src/crdt/scope_sync.dart
Normal file
273
packages/serverpod_offline_sync/lib/src/crdt/scope_sync.dart
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../generated/protocol.dart';
|
||||
import '../hlc/hlc.dart';
|
||||
import '../managers/scope.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1164
packages/serverpod_offline_sync/lib/src/crdt/sync.dart
Normal file
1164
packages/serverpod_offline_sync/lib/src/crdt/sync.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,26 @@
|
|||
### CRDT visibility generation table.
|
||||
###
|
||||
### This table stores materialized monotone CLFlag metadata after a visibility
|
||||
### change event has been recorded.
|
||||
class: CrdtDataDeleted
|
||||
extends: BaseHlc
|
||||
table: crdt_data_tombstone
|
||||
database: all
|
||||
fields:
|
||||
### Row whose visibility is tracked.
|
||||
row: CrdtDataRow?, relation(name=row_deleted, field=rowId, onDelete=Cascade)
|
||||
rowId: int
|
||||
|
||||
### The node that updated the field.
|
||||
node: CrdtNode?, relation
|
||||
|
||||
### Monotone causal-length flag. Odd values are visible and even values are deleted.
|
||||
clFlag: int
|
||||
|
||||
### Why the row entered its current visibility generation.
|
||||
reason: CrdtDataDeletedReason
|
||||
|
||||
indexes:
|
||||
crdt_data_tombstone_row_idx:
|
||||
fields: rowId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
### Why a synced user tombstone changed row visibility.
|
||||
enum: CrdtDataDeletedReason
|
||||
serialized: byIndex
|
||||
values:
|
||||
- userInsert
|
||||
- userReinsert
|
||||
- userDelete
|
||||
- userCascadeDelete
|
||||
- uniqueLoser
|
||||
26
packages/serverpod_offline_sync/lib/src/data/field.spy.yaml
Normal file
26
packages/serverpod_offline_sync/lib/src/data/field.spy.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
### CRDT data table.
|
||||
###
|
||||
### This table stores only the HLC timestamp for a field. During merge, if the
|
||||
### other HLC is greater, the actual data must be fetched from the table itself.
|
||||
class: CrdtDataField
|
||||
extends: BaseHlc
|
||||
table: crdt_data_fields
|
||||
database: all
|
||||
fields:
|
||||
### Row that the field belongs to.
|
||||
row: CrdtDataRow?, relation(name=row_fields, onDelete=Cascade)
|
||||
|
||||
### Column that the field belongs to.
|
||||
column: CrdtSchemaColumn?, relation(onDelete=Cascade)
|
||||
|
||||
### The node that updated the field.
|
||||
node: CrdtNode?, relation
|
||||
|
||||
### The foreign key information for the field, if this field holds a
|
||||
### reference to another row.
|
||||
foreignKey: CrdtDataForeignKey?, relation(optional, name=field_foreign_key)
|
||||
|
||||
indexes:
|
||||
crdt_data_fields_row_column_idx:
|
||||
fields: rowId, columnId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
### CRDT field foreign key value and projection table.
|
||||
###
|
||||
### This table stores the user/attempted foreign key value for a CRDT field and
|
||||
### the materialized foreign key projection for that value. CrdtDataField stores
|
||||
### only HLC metadata, so FK columns store their durable attempted value here.
|
||||
###
|
||||
### Repairs must be recomputable from merged CRDT row, field, and tombstone
|
||||
### facts plus this attempted FK value. The projection columns record what was
|
||||
### materialized into the domain row; they do not decide business semantics or
|
||||
### create user field updates during merge.
|
||||
class: CrdtDataForeignKey
|
||||
table: crdt_data_foreign_key
|
||||
database: all
|
||||
fields:
|
||||
### The field that the foreign key belongs to.
|
||||
field: CrdtDataField?, relation(name=field_foreign_key, field=fieldId, onDelete=Cascade)
|
||||
fieldId: int
|
||||
|
||||
### The user-attempted foreign key value that the CRDT field currently carries.
|
||||
###
|
||||
### This may be null for nullable foreign keys. This is durable FK field-value
|
||||
### storage for CRDT columns whose CrdtDataField row stores only HLC metadata.
|
||||
attemptedValue: UuidValue?, required
|
||||
|
||||
### The foreign key value that is currently visible in the actual data row
|
||||
### when an override is active (overrideReason is non-null).
|
||||
visibleValue: UuidValue?
|
||||
|
||||
### The authoritative indicator and reason for an active projection override.
|
||||
###
|
||||
### This is the single source of truth for whether an override is active:
|
||||
### non-null means an override is active; null means no override. It also
|
||||
### names the cause of the override. The FK resolver recomputes it from merged
|
||||
### row, field, and tombstone facts.
|
||||
overrideReason: CrdtForeignKeyOverrideReason?
|
||||
|
||||
indexes:
|
||||
crdt_data_foreign_key_field_idx:
|
||||
fields: fieldId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
### Why a foreign key projection override is active.
|
||||
enum: CrdtForeignKeyOverrideReason
|
||||
serialized: byIndex
|
||||
values:
|
||||
### The attempted parent target is hidden or missing and the FK projection
|
||||
### set the column to null.
|
||||
- setNull
|
||||
### The attempted parent target is hidden or missing and the FK projection
|
||||
### set the column to the default value.
|
||||
- setDefault
|
||||
### The attempted parent target is hidden or missing and the FK projection
|
||||
### cannot repair the value (restrict, no action, cascade, or the column is
|
||||
### non-nullable), so the row is hidden by projection while the attempt is
|
||||
### preserved.
|
||||
- missingParent
|
||||
43
packages/serverpod_offline_sync/lib/src/data/row.spy.yaml
Normal file
43
packages/serverpod_offline_sync/lib/src/data/row.spy.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
### CRDT data rows table.
|
||||
###
|
||||
### This table stores the data for all tables that are registered for CRDT
|
||||
### synchronization. It is used to interact with the CRDT data.
|
||||
###
|
||||
### The row is also the HLC timestamp for when it was inserted. This is used to
|
||||
### avoid creating one entry per field until a field is updated.
|
||||
class: CrdtDataRow
|
||||
extends: BaseHlc
|
||||
table: crdt_data_rows
|
||||
database: all
|
||||
fields:
|
||||
### Identifier for the scope that owns the data.
|
||||
scope: CrdtScope?, relation(onDelete=Cascade)
|
||||
|
||||
### Reference to the table this row belongs to.
|
||||
tbl: CrdtSchemaTable?, relation(onDelete=Cascade)
|
||||
|
||||
### Row identifier.
|
||||
uuidRowId: UuidValue
|
||||
|
||||
### The node that inserted the row with the HLC timestamp.
|
||||
node: CrdtNode?, relation
|
||||
|
||||
### Materialized visibility state for domain queries.
|
||||
###
|
||||
### Updated by user tombstone writes and foreign key projection. Visible
|
||||
### states are listed before hidden states in CrdtDataRowVisibility.
|
||||
visibility: CrdtDataRowVisibility, default=userInsert
|
||||
|
||||
### The synced user tombstone for this row.
|
||||
###
|
||||
### Only present after the first user visibility change event has been recorded.
|
||||
### Projection may keep a visible [visibility] while a user delete tombstone exists.
|
||||
deleted: CrdtDataDeleted?, relation(name=row_deleted, optional)
|
||||
|
||||
### The fields for this row.
|
||||
fields: List<CrdtDataField>?, relation(name=row_fields)
|
||||
|
||||
indexes:
|
||||
crdt_data_rows_scope_tbl_row_idx:
|
||||
fields: scopeId, tblId, uuidRowId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
### Materialized visibility state for a CRDT row.
|
||||
###
|
||||
### Visible states are listed first, hidden states last. A row is visible when
|
||||
### its serialized index is less than or equal to [foreignKeyRestrictRestore].
|
||||
enum: CrdtDataRowVisibility
|
||||
serialized: byIndex
|
||||
values:
|
||||
- userInsert
|
||||
- userReinsert
|
||||
- foreignKeyRestrictRestore
|
||||
- userDelete
|
||||
- userCascadeDelete
|
||||
- foreignKeyCascade
|
||||
1046
packages/serverpod_offline_sync/lib/src/database/database.dart
Normal file
1046
packages/serverpod_offline_sync/lib/src/database/database.dart
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue