refactor!: Rename sync APIs and ownership spaces (#128)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-14 13:39:07 -03:00 • committed by GitHub
commit b89725f6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 43650 additions and 9417 deletions

View file

@ -1,7 +1,7 @@
export 'package:serverpod_offline_sync/serverpod_offline_sync.dart' hide Protocol;
export 'src/business/crdt_scopes.dart';
export 'src/business/crdt_sync.dart';
export 'src/endpoints/crdt_sync_endpoint.dart';
export 'src/business/offline_sync.dart';
export 'src/business/offline_sync_spaces.dart';
export 'src/endpoints/offline_sync_endpoint.dart';
export 'src/generated/endpoints.dart';
export 'src/generated/protocol.dart';

View file

@ -1,158 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
/// Server-side service for managing CRDT shared-scope membership.
///
/// This is a data-invariant service, not an authorization policy. Applications
/// call it from their own endpoints after applying their own access checks.
class CrdtScopes {
/// Creates a session-bound CRDT scope management service.
CrdtScopes(this._session);
final Session _session;
/// Creates a shared scope and returns its UUID.
///
/// [grants], if given, adds each user as a member with its role, applied in
/// one transaction with the scope insert. With no grants the scope is created
/// dormant — it exists but no one can sync it until membership is granted.
Future<UuidValue> create({
Map<UuidValue, CrdtScopeRole>? grants,
Transaction? transaction,
}) async {
final scope = const Uuid().v7obj();
await DatabaseUtil.runInTransactionOrSavepoint(
_session.db,
transaction,
(tx) async {
final insertedScope = await CrdtScope.db.insertRow(
_session,
CrdtScope(uuidScopeId: scope),
transaction: tx,
);
await _applyGrants(insertedScope.id!, grants ?? const {}, tx);
},
);
return scope;
}
/// Creates a shared scope granting [user] the given [role], in one transaction.
Future<UuidValue> createFor(
UuidValue user, {
CrdtScopeRole role = CrdtScopeRole.readWrite,
Transaction? transaction,
}) => create(grants: {user: role}, transaction: transaction);
/// Grants [user] the given [role] in [scope], upserting an existing membership.
Future<void> grant({
required UuidValue scope,
required UuidValue user,
required CrdtScopeRole role,
Transaction? transaction,
}) => grantAll(scope, {user: role}, transaction: transaction);
/// Grants several members in one transaction; the bulk twin of [grant].
Future<void> grantAll(
UuidValue scope,
Map<UuidValue, CrdtScopeRole> grants, {
Transaction? transaction,
}) {
return DatabaseUtil.runInTransactionOrSavepoint(
_session.db,
transaction,
(tx) async {
final scopeRow = await CrdtScope.db.findFirstRow(
_session,
where: (t) => t.uuidScopeId.equals(scope),
transaction: tx,
);
final scopeId = scopeRow?.id;
if (scopeId == null) {
throw CrdtScopeNotFoundException(scope);
}
await _applyGrants(scopeId, grants, tx);
},
);
}
/// Removes [user]'s membership in [scope]. A no-op if not a member.
Future<void> revoke({
required UuidValue scope,
required UuidValue user,
Transaction? transaction,
}) async {
await CrdtScopeMember.db.deleteWhere(
_session,
where: (t) => t.userUuid.equals(user) & t.scope.uuidScopeId.equals(scope),
transaction: transaction,
noReturn: true,
);
}
/// The role [user] holds in [scope], or null if not a member.
Future<CrdtScopeRole?> roleOf({
required UuidValue user,
required UuidValue scope,
Transaction? transaction,
}) {
return CrdtScopeMembership.roleOf(
_session,
userUuid: user,
scopeUuid: scope,
transaction: transaction,
);
}
/// The members of [scope] and their roles.
Future<Map<UuidValue, CrdtScopeRole>> members(
UuidValue scope, {
Transaction? transaction,
}) async {
final memberships = await CrdtScopeMember.db.find(
_session,
where: (t) => t.scope.uuidScopeId.equals(scope),
transaction: transaction,
);
return {
for (final membership in memberships) membership.userUuid: membership.role,
};
}
Future<void> _applyGrants(
int scopeId,
Map<UuidValue, CrdtScopeRole> grants,
Transaction transaction,
) async {
if (grants.isEmpty) return;
await CrdtScopeMember.db.upsert(
_session,
[
for (final grant in grants.entries)
CrdtScopeMember(
scopeId: scopeId,
userUuid: grant.key,
role: grant.value,
),
],
conflictColumns: (t) => [t.userUuid, t.scopeId],
updateColumns: (t) => [t.role],
transaction: transaction,
noReturn: true,
);
}
}
/// Thrown when a scope-management operation targets a missing CRDT scope row.
final class CrdtScopeNotFoundException implements Exception {
/// Creates a [CrdtScopeNotFoundException].
const CrdtScopeNotFoundException(this.scope);
/// The scope UUID that could not be found.
final UuidValue scope;
@override
String toString() =>
'CrdtScopeNotFoundException: no CRDT scope row exists for "$scope".';
}

View file

@ -0,0 +1,101 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'offline_sync_spaces.dart';
/// The CRDT sync configured per [Serverpod] instance.
///
/// Keyed by the [Serverpod] instance so each pod owns its own [OfflineSyncEngine] (and
/// the [OfflineSyncDatabaseContext] it carries) instead of sharing a single
/// process-wide singleton.
final _offlineSyncByServerpod = Expando<OfflineSyncEngine>('offlineSync');
/// Intercepts each Serverpod session database with a CRDT-aware database once
/// [OfflineSyncInitialize.initializeOfflineSync] has configured sync.
///
/// When sync has not been configured for the session's [Serverpod], the
/// original [inner] database is returned unchanged.
Database offlineSyncDatabaseInterceptor(Session session, Database inner) {
final offlineSync = _offlineSyncByServerpod[session.server.serverpod];
return offlineSync?.wrapDatabase(inner) ?? inner;
}
/// Extension methods for [Serverpod] to configure the CRDT sync on the server.
extension OfflineSyncInitialize on Serverpod {
/// Configures the CRDT sync with the given sync tables.
///
/// Must be called during server startup before any sync requests are made.
/// Will override any previous initialization for this [Serverpod] instance.
///
/// The [Serverpod] instance must be constructed with [offlineSyncDatabaseInterceptor]
/// as its `databaseInterceptor`. Otherwise each session's [Session.db] stays a
/// plain database and server-side ORM mutations on synced tables are not
/// CRDT-tracked.
///
/// [syncBatchSize] controls the maximum number of merge changes carried by
/// each sync stream chunk.
///
/// [continuousSyncInterval] controls how long a continuous sync session waits
/// after completing one sync round before checking for local changes again.
void initializeOfflineSync({
required List<Table> syncTables,
int syncBatchSize = OfflineSyncEngine.defaultSyncBatchSize,
Duration continuousSyncInterval = OfflineSyncEngine.defaultContinuousSyncInterval,
}) {
_offlineSyncByServerpod[this] = OfflineSyncEngine(
syncTables: syncTables,
serializationManager: serializationManager,
syncBatchSize: syncBatchSize,
continuousSyncInterval: continuousSyncInterval,
);
}
}
/// Session-bound CRDT services configured for a [Serverpod] instance.
///
/// This facade is ephemeral: each `Session.offlineSync` access creates a small wrapper
/// around the shared [OfflineSyncEngine] instance and the current [Session].
class OfflineSyncSession {
/// Creates CRDT services bound to a session.
OfflineSyncSession(this._session, this._sync);
final Session _session;
final OfflineSyncEngine _sync;
/// Returns the server-side space management service.
OfflineSyncSpaces get spaces => OfflineSyncSpaces(_session);
/// Runs a CRDT sync session with this [OfflineSyncSession]'s [Session] bound.
Stream<OfflineSyncStreamEvent> sync({
required UuidValue userId,
required Stream<OfflineSyncStreamEvent> inbound,
required OfflineSyncPeerMode mode,
bool once = false,
OfflineSyncOnMergeSuccess? onMergeSuccess,
}) {
return _sync.sync(
_session,
userId: userId,
inbound: inbound,
once: once,
mode: mode,
onMergeSuccess: onMergeSuccess,
);
}
}
/// Extension to access CRDT services for [Session] from the [Serverpod] instance.
extension OfflineSyncSessionExtension on Session {
/// Returns the CRDT services configured for this session.
OfflineSyncSession get offlineSync {
final sync = _offlineSyncByServerpod[server.serverpod];
if (sync == null) {
throw StateError(
'The OfflineSyncEngine has not been initialized for this Serverpod instance. '
'Call pod.initializeOfflineSync(...) during server startup to configure '
'the CRDT sync.',
);
}
return OfflineSyncSession(this, sync);
}
}

View file

@ -0,0 +1,158 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
/// Server-side service for managing CRDT shared-space membership.
///
/// This is a data-invariant service, not an authorization policy. Applications
/// call it from their own endpoints after applying their own access checks.
class OfflineSyncSpaces {
/// Creates a session-bound CRDT space management service.
OfflineSyncSpaces(this._session);
final Session _session;
/// Creates a shared space and returns its UUID.
///
/// [grants], if given, adds each user as a member with its role, applied in
/// one transaction with the space insert. With no grants the space is created
/// dormant — it exists but no one can sync it until membership is granted.
Future<UuidValue> create({
Map<UuidValue, OfflineSyncSpaceRole>? grants,
Transaction? transaction,
}) async {
final space = const Uuid().v7obj();
await DatabaseUtil.runInTransactionOrSavepoint(
_session.db,
transaction,
(tx) async {
final insertedSpace = await OfflineSyncSpace.db.insertRow(
_session,
OfflineSyncSpace(uuidSpaceId: space),
transaction: tx,
);
await _applyGrants(insertedSpace.id!, grants ?? const {}, tx);
},
);
return space;
}
/// Creates a shared space granting [user] the given [role], in one transaction.
Future<UuidValue> createFor(
UuidValue user, {
OfflineSyncSpaceRole role = OfflineSyncSpaceRole.readWrite,
Transaction? transaction,
}) => create(grants: {user: role}, transaction: transaction);
/// Grants [user] the given [role] in [space], upserting an existing membership.
Future<void> grant({
required UuidValue space,
required UuidValue user,
required OfflineSyncSpaceRole role,
Transaction? transaction,
}) => grantAll(space, {user: role}, transaction: transaction);
/// Grants several members in one transaction; the bulk twin of [grant].
Future<void> grantAll(
UuidValue space,
Map<UuidValue, OfflineSyncSpaceRole> grants, {
Transaction? transaction,
}) {
return DatabaseUtil.runInTransactionOrSavepoint(
_session.db,
transaction,
(tx) async {
final spaceRow = await OfflineSyncSpace.db.findFirstRow(
_session,
where: (t) => t.uuidSpaceId.equals(space),
transaction: tx,
);
final spaceId = spaceRow?.id;
if (spaceId == null) {
throw OfflineSyncSpaceNotFoundException(space);
}
await _applyGrants(spaceId, grants, tx);
},
);
}
/// Removes [user]'s membership in [space]. A no-op if not a member.
Future<void> revoke({
required UuidValue space,
required UuidValue user,
Transaction? transaction,
}) async {
await OfflineSyncSpaceMember.db.deleteWhere(
_session,
where: (t) => t.userUuid.equals(user) & t.space.uuidSpaceId.equals(space),
transaction: transaction,
noReturn: true,
);
}
/// The role [user] holds in [space], or null if not a member.
Future<OfflineSyncSpaceRole?> roleOf({
required UuidValue user,
required UuidValue space,
Transaction? transaction,
}) {
return OfflineSyncSpaceMembership.roleOf(
_session,
userUuid: user,
spaceUuid: space,
transaction: transaction,
);
}
/// The members of [space] and their roles.
Future<Map<UuidValue, OfflineSyncSpaceRole>> members(
UuidValue space, {
Transaction? transaction,
}) async {
final memberships = await OfflineSyncSpaceMember.db.find(
_session,
where: (t) => t.space.uuidSpaceId.equals(space),
transaction: transaction,
);
return {
for (final membership in memberships) membership.userUuid: membership.role,
};
}
Future<void> _applyGrants(
int spaceId,
Map<UuidValue, OfflineSyncSpaceRole> grants,
Transaction transaction,
) async {
if (grants.isEmpty) return;
await OfflineSyncSpaceMember.db.upsert(
_session,
[
for (final grant in grants.entries)
OfflineSyncSpaceMember(
spaceId: spaceId,
userUuid: grant.key,
role: grant.value,
),
],
conflictColumns: (t) => [t.userUuid, t.spaceId],
updateColumns: (t) => [t.role],
transaction: transaction,
noReturn: true,
);
}
}
/// Thrown when a space-management operation targets a missing CRDT space row.
final class OfflineSyncSpaceNotFoundException implements Exception {
/// Creates a [OfflineSyncSpaceNotFoundException].
const OfflineSyncSpaceNotFoundException(this.space);
/// The space UUID that could not be found.
final UuidValue space;
@override
String toString() =>
'OfflineSyncSpaceNotFoundException: no CRDT space row exists for "$space".';
}

View file

@ -0,0 +1,26 @@
import 'dart:async';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import '../business/offline_sync.dart';
/// Endpoint for CRDT-based offline-first synchronization.
class OfflineSyncEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Opens a bidirectional CRDT sync session with the authenticated client.
Stream<OfflineSyncStreamEvent> sync(
Session session, {
required Stream<OfflineSyncStreamEvent> changes,
bool once = false,
}) async* {
yield* session.offlineSync.sync(
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
inbound: changes,
once: once,
mode: OfflineSyncPeerMode.authoritative,
);
}
}

View file

@ -13,22 +13,22 @@
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart'
as _icw2tu00;
import '../endpoints/crdt_sync_endpoint.dart' as _i87mlr2k;
import '../endpoints/offline_sync_endpoint.dart' as _i3x2h4cf;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'crdtSync': _i87mlr2k.CrdtSyncEndpoint()
'offlineSync': _i3x2h4cf.OfflineSyncEndpoint()
..initialize(
server,
'crdtSync',
'offlineSync',
'serverpod_offline_sync',
),
};
connectors['crdtSync'] = _is.EndpointConnector(
name: 'crdtSync',
endpoint: endpoints['crdtSync']!,
connectors['offlineSync'] = _is.EndpointConnector(
name: 'offlineSync',
endpoint: endpoints['offlineSync']!,
methodConnectors: {
'sync': _is.MethodStreamConnector(
name: 'sync',
@ -41,7 +41,9 @@ class Endpoints extends _is.EndpointDispatch {
},
streamParams: {
'changes':
_is.StreamParameterDescription<_icw2tu00.CrdtSyncStreamEvent>(
_is.StreamParameterDescription<
_icw2tu00.OfflineSyncStreamEvent
>(
name: 'changes',
nullable: false,
),
@ -52,12 +54,13 @@ class Endpoints extends _is.EndpointDispatch {
_is.Session session,
Map<String, dynamic> params,
Map<String, Stream> streamParams,
) => (endpoints['crdtSync'] as _i87mlr2k.CrdtSyncEndpoint).sync(
session,
changes: streamParams['changes']!
.cast<_icw2tu00.CrdtSyncStreamEvent>(),
once: params['once'],
),
) => (endpoints['offlineSync'] as _i3x2h4cf.OfflineSyncEndpoint)
.sync(
session,
changes: streamParams['changes']!
.cast<_icw2tu00.OfflineSyncStreamEvent>(),
once: params['once'],
),
),
},
);

View file

@ -1,2 +1,2 @@
crdtSync:
offlineSync:
- sync:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,535 @@
BEGIN;
--
-- Class CrdtDataAttemptedValue as table crdt_data_attempted_value
--
CREATE TABLE "crdt_data_attempted_value" (
"id" INTEGER PRIMARY KEY,
"fieldId" INTEGER NOT NULL,
"value" BLOB NOT NULL,
"projectionReason" INTEGER NOT NULL,
CONSTRAINT "crdt_data_attempted_value_fk_0" FOREIGN KEY ("fieldId") REFERENCES "crdt_data_fields" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_data_attempted_value__fieldId__unique_idx" ON "crdt_data_attempted_value" ("fieldId");
--
-- Class CrdtDataField as table crdt_data_fields
--
CREATE TABLE "crdt_data_fields" (
"id" INTEGER PRIMARY KEY,
"hlcDatetime" INTEGER NOT NULL,
"hlcCounter" INTEGER NOT NULL,
"rowId" INTEGER NOT NULL,
"columnId" INTEGER NOT NULL,
"nodeId" INTEGER NOT NULL,
CONSTRAINT "crdt_data_fields_fk_0" FOREIGN KEY ("rowId") REFERENCES "crdt_data_rows" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_fields_fk_1" FOREIGN KEY ("columnId") REFERENCES "crdt_schema_columns" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_fields_fk_2" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_data_fields_row_column_idx" ON "crdt_data_fields" ("rowId", "columnId");
--
-- Class CrdtDataRow as table crdt_data_rows
--
CREATE TABLE "crdt_data_rows" (
"id" INTEGER PRIMARY KEY,
"hlcDatetime" INTEGER NOT NULL,
"hlcCounter" INTEGER NOT NULL,
"spaceId" INTEGER NOT NULL,
"tblId" INTEGER NOT NULL,
"uuidRowId" BLOB NOT NULL,
"nodeId" INTEGER NOT NULL,
"visibility" INTEGER NOT NULL DEFAULT (0),
CONSTRAINT "crdt_data_rows_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_rows_fk_1" FOREIGN KEY ("tblId") REFERENCES "crdt_schema_tables" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_rows_fk_2" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_data_rows_space_tbl_row_idx" ON "crdt_data_rows" ("spaceId", "tblId", "uuidRowId");
--
-- Class CrdtDataDeleted as table crdt_data_tombstone
--
CREATE TABLE "crdt_data_tombstone" (
"id" INTEGER PRIMARY KEY,
"hlcDatetime" INTEGER NOT NULL,
"hlcCounter" INTEGER NOT NULL,
"rowId" INTEGER NOT NULL,
"nodeId" INTEGER NOT NULL,
"clFlag" INTEGER NOT NULL,
"reason" INTEGER NOT NULL,
CONSTRAINT "crdt_data_tombstone_fk_0" FOREIGN KEY ("rowId") REFERENCES "crdt_data_rows" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_tombstone_fk_1" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_data_tombstone_row_idx" ON "crdt_data_tombstone" ("rowId");
--
-- Class CrdtNode as table crdt_nodes
--
CREATE TABLE "crdt_nodes" (
"id" INTEGER PRIMARY KEY,
"uuidNodeId" BLOB NOT NULL DEFAULT (unhex(printf('%012x', CAST(unixepoch('now', 'subsecond') * 1000 AS INTEGER)) || '7' || substr(hex(randomblob(2)), 2, 3) || substr('89AB', 1 + (abs(random()) % 4), 1) || substr(hex(randomblob(8)), 2, 15))),
"lastHlc" BLOB
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_nodes__uuidNodeId__unique_idx" ON "crdt_nodes" ("uuidNodeId");
--
-- Class CrdtSchemaColumn as table crdt_schema_columns
--
CREATE TABLE "crdt_schema_columns" (
"id" INTEGER PRIMARY KEY,
"tblId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"columnType" TEXT NOT NULL,
"dartType" TEXT NOT NULL,
"isNullable" INTEGER NOT NULL,
CONSTRAINT "crdt_schema_columns_fk_0" FOREIGN KEY ("tblId") REFERENCES "crdt_schema_tables" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_schema_columns_table_column_idx" ON "crdt_schema_columns" ("tblId", "name");
--
-- Class CrdtSchemaTable as table crdt_schema_tables
--
CREATE TABLE "crdt_schema_tables" (
"id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_schema_tables__name__unique_idx" ON "crdt_schema_tables" ("name");
--
-- Class OfflineSyncIntegrityViolation as table offline_sync_integrity_violations
--
CREATE TABLE "offline_sync_integrity_violations" (
"id" INTEGER PRIMARY KEY,
"type" TEXT NOT NULL,
"domainTableName" TEXT NOT NULL,
"uuidRowId" BLOB NOT NULL,
"ownerSpaceUuid" BLOB,
"incomingSpaceUuid" BLOB NOT NULL,
"operation" TEXT NOT NULL,
"uuidNodeId" BLOB,
"crdtDataRowId" INTEGER,
"hlcDatetime" INTEGER,
"hlcCounter" INTEGER,
"firstSeenAt" INTEGER NOT NULL,
"lastSeenAt" INTEGER NOT NULL,
"occurrences" INTEGER NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_integrity_violations_key_idx" ON "offline_sync_integrity_violations" ("type", "operation", "domainTableName", "uuidRowId", "ownerSpaceUuid", "incomingSpaceUuid");
--
-- Class OfflineSyncSpaceMember as table offline_sync_space_members
--
CREATE TABLE "offline_sync_space_members" (
"id" INTEGER PRIMARY KEY,
"spaceId" INTEGER NOT NULL,
"userUuid" BLOB NOT NULL,
"role" TEXT NOT NULL,
CONSTRAINT "offline_sync_space_members_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_space_member_unique_idx" ON "offline_sync_space_members" ("userUuid", "spaceId");
--
-- Class OfflineSyncSpaceNode as table offline_sync_space_nodes
--
CREATE TABLE "offline_sync_space_nodes" (
"id" INTEGER PRIMARY KEY,
"spaceId" INTEGER NOT NULL,
"nodeId" INTEGER NOT NULL,
"lastReceivedHlc" BLOB,
CONSTRAINT "offline_sync_space_nodes_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "offline_sync_space_nodes_fk_1" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_space_node_unique_idx" ON "offline_sync_space_nodes" ("spaceId", "nodeId");
--
-- Class OfflineSyncSpace as table offline_sync_spaces
--
CREATE TABLE "offline_sync_spaces" (
"id" INTEGER PRIMARY KEY,
"uuidSpaceId" BLOB NOT NULL DEFAULT (unhex(printf('%012x', CAST(unixepoch('now', 'subsecond') * 1000 AS INTEGER)) || '7' || substr(hex(randomblob(2)), 2, 3) || substr('89AB', 1 + (abs(random()) % 4), 1) || substr(hex(randomblob(8)), 2, 15))),
"currentNodeId" INTEGER,
CONSTRAINT "offline_sync_spaces_fk_0" FOREIGN KEY ("currentNodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_spaces__uuidSpaceId__unique_idx" ON "offline_sync_spaces" ("uuidSpaceId");
--
-- Class CloudStorageEntry as table serverpod_cloud_storage
--
CREATE TABLE "serverpod_cloud_storage" (
"id" INTEGER PRIMARY KEY,
"storageId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"addedTime" INTEGER NOT NULL,
"expiration" INTEGER,
"byteData" BLOB NOT NULL,
"verified" INTEGER NOT NULL,
"contentType" TEXT,
"cacheControl" TEXT,
"contentDisposition" TEXT,
"contentEncoding" TEXT,
"customMetadata" TEXT
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_cloud_storage_path_idx" ON "serverpod_cloud_storage" ("storageId", "path");
CREATE INDEX "serverpod_cloud_storage_expiration" ON "serverpod_cloud_storage" ("expiration");
--
-- Class CloudStorageDirectDownloadEntry as table serverpod_cloud_storage_direct_download
--
CREATE TABLE "serverpod_cloud_storage_direct_download" (
"id" INTEGER PRIMARY KEY,
"storageId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"expiration" INTEGER NOT NULL,
"authKey" TEXT NOT NULL,
"downloadFileName" TEXT,
"contentType" TEXT
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_download_auth_key" ON "serverpod_cloud_storage_direct_download" ("authKey");
CREATE INDEX "serverpod_cloud_storage_direct_download_expiration" ON "serverpod_cloud_storage_direct_download" ("expiration");
--
-- Class CloudStorageDirectUploadEntry as table serverpod_cloud_storage_direct_upload
--
CREATE TABLE "serverpod_cloud_storage_direct_upload" (
"id" INTEGER PRIMARY KEY,
"storageId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"expiration" INTEGER NOT NULL,
"authKey" TEXT NOT NULL,
"maxFileSize" INTEGER NOT NULL DEFAULT (10485760),
"contentLength" INTEGER,
"preventOverwrite" INTEGER NOT NULL DEFAULT (0),
"contentType" TEXT,
"cacheControl" TEXT,
"contentDisposition" TEXT,
"contentEncoding" TEXT,
"customMetadata" TEXT
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_upload_storage_path" ON "serverpod_cloud_storage_direct_upload" ("storageId", "path");
--
-- Class FutureCallEntry as table serverpod_future_call
--
CREATE TABLE "serverpod_future_call" (
"id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL,
"time" INTEGER NOT NULL,
"serializedObject" TEXT,
"serverId" TEXT NOT NULL,
"identifier" TEXT,
"scheduling" TEXT
) STRICT;
-- Indexes
CREATE INDEX "serverpod_future_call_time_idx" ON "serverpod_future_call" ("time");
CREATE INDEX "serverpod_future_call_serverId_idx" ON "serverpod_future_call" ("serverId");
CREATE INDEX "serverpod_future_call_identifier_idx" ON "serverpod_future_call" ("identifier");
--
-- Class FutureCallClaimEntry as table serverpod_future_call_claim
--
CREATE TABLE "serverpod_future_call_claim" (
"id" INTEGER PRIMARY KEY,
"futureCallId" INTEGER,
"lastHeartbeatTime" INTEGER NOT NULL,
CONSTRAINT "serverpod_future_call_claim_fk_0" FOREIGN KEY ("futureCallId") REFERENCES "serverpod_future_call" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "future_call_unique_idx" ON "serverpod_future_call_claim" ("futureCallId");
--
-- Class ServerHealthConnectionInfo as table serverpod_health_connection_info
--
CREATE TABLE "serverpod_health_connection_info" (
"id" INTEGER PRIMARY KEY,
"serverId" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"active" INTEGER NOT NULL,
"closing" INTEGER NOT NULL,
"idle" INTEGER NOT NULL,
"granularity" INTEGER NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_health_connection_info_timestamp_idx" ON "serverpod_health_connection_info" ("timestamp", "serverId", "granularity");
--
-- Class ServerHealthMetric as table serverpod_health_metric
--
CREATE TABLE "serverpod_health_metric" (
"id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"isHealthy" INTEGER NOT NULL,
"value" REAL NOT NULL,
"granularity" INTEGER NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_health_metric_timestamp_idx" ON "serverpod_health_metric" ("timestamp", "serverId", "name", "granularity");
--
-- Class LogEntry as table serverpod_log
--
CREATE TABLE "serverpod_log" (
"id" INTEGER PRIMARY KEY,
"sessionLogId" INTEGER NOT NULL,
"messageId" INTEGER,
"reference" TEXT,
"serverId" TEXT NOT NULL,
"time" INTEGER NOT NULL,
"logLevel" INTEGER NOT NULL,
"message" TEXT NOT NULL,
"error" TEXT,
"stackTrace" TEXT,
"order" INTEGER NOT NULL,
CONSTRAINT "serverpod_log_fk_0" FOREIGN KEY ("sessionLogId") REFERENCES "serverpod_session_log" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE INDEX "serverpod_log_sessionLogId_idx" ON "serverpod_log" ("sessionLogId", "order");
--
-- Class MessageLogEntry as table serverpod_message_log
--
CREATE TABLE "serverpod_message_log" (
"id" INTEGER PRIMARY KEY,
"sessionLogId" INTEGER NOT NULL,
"serverId" TEXT NOT NULL,
"messageId" INTEGER NOT NULL,
"endpoint" TEXT NOT NULL,
"messageName" TEXT NOT NULL,
"duration" REAL NOT NULL,
"error" TEXT,
"stackTrace" TEXT,
"slow" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "serverpod_message_log_fk_0" FOREIGN KEY ("sessionLogId") REFERENCES "serverpod_session_log" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE INDEX "serverpod_message_log_sessionLogId_idx" ON "serverpod_message_log" ("sessionLogId", "order");
--
-- Class MethodInfo as table serverpod_method
--
CREATE TABLE "serverpod_method" (
"id" INTEGER PRIMARY KEY,
"endpoint" TEXT NOT NULL,
"method" TEXT NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_method_endpoint_method_idx" ON "serverpod_method" ("endpoint", "method");
--
-- Class DatabaseMigrationVersion as table serverpod_migrations
--
CREATE TABLE "serverpod_migrations" (
"id" INTEGER PRIMARY KEY,
"module" TEXT NOT NULL,
"version" TEXT NOT NULL,
"timestamp" INTEGER
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "serverpod_migrations_ids" ON "serverpod_migrations" ("module");
--
-- Class QueryLogEntry as table serverpod_query_log
--
CREATE TABLE "serverpod_query_log" (
"id" INTEGER PRIMARY KEY,
"serverId" TEXT NOT NULL,
"sessionLogId" INTEGER NOT NULL,
"messageId" INTEGER,
"query" TEXT NOT NULL,
"duration" REAL NOT NULL,
"numRows" INTEGER,
"error" TEXT,
"stackTrace" TEXT,
"slow" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "serverpod_query_log_fk_0" FOREIGN KEY ("sessionLogId") REFERENCES "serverpod_session_log" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE INDEX "serverpod_query_log_sessionLogId_idx" ON "serverpod_query_log" ("sessionLogId", "order");
--
-- Class ReadWriteTestEntry as table serverpod_readwrite_test
--
CREATE TABLE "serverpod_readwrite_test" (
"id" INTEGER PRIMARY KEY,
"number" INTEGER NOT NULL
) STRICT;
--
-- Class RuntimeSettings as table serverpod_runtime_settings
--
CREATE TABLE "serverpod_runtime_settings" (
"id" INTEGER PRIMARY KEY,
"logSettings" TEXT NOT NULL,
"logSettingsOverrides" TEXT NOT NULL,
"logServiceCalls" INTEGER NOT NULL,
"logMalformedCalls" INTEGER NOT NULL
) STRICT;
--
-- Class SessionLogEntry as table serverpod_session_log
--
CREATE TABLE "serverpod_session_log" (
"id" INTEGER PRIMARY KEY,
"serverId" TEXT NOT NULL,
"time" INTEGER NOT NULL,
"module" TEXT,
"endpoint" TEXT,
"method" TEXT,
"duration" REAL,
"numQueries" INTEGER,
"slow" INTEGER,
"error" TEXT,
"stackTrace" TEXT,
"authenticatedUserId" INTEGER,
"userId" TEXT,
"isOpen" INTEGER,
"touched" INTEGER NOT NULL
) STRICT;
-- Indexes
CREATE INDEX "serverpod_session_log_serverid_idx" ON "serverpod_session_log" ("serverId");
CREATE INDEX "serverpod_session_log_time_idx" ON "serverpod_session_log" ("time");
CREATE INDEX "serverpod_session_log_touched_idx" ON "serverpod_session_log" ("touched");
CREATE INDEX "serverpod_session_log_isopen_idx" ON "serverpod_session_log" ("isOpen");
--
-- STORE COLUMN TYPES FOR MIGRATIONS
--
DROP TABLE IF EXISTS "serverpod_sqlite_schema";
CREATE TABLE "serverpod_sqlite_schema" (
"table_name" TEXT NOT NULL,
"column_name" TEXT NOT NULL,
"column_type" TEXT NOT NULL,
"column_vector_dimension" INTEGER,
PRIMARY KEY ("table_name", "column_name")
);
INSERT INTO "serverpod_sqlite_schema" VALUES
('crdt_data_attempted_value', 'value', 'jsonb', NULL),
('crdt_data_fields', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'uuidRowId', 'uuid', NULL),
('crdt_data_tombstone', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_nodes', 'uuidNodeId', 'uuid', NULL),
('crdt_nodes', 'lastHlc', 'jsonb', NULL),
('crdt_schema_columns', 'isNullable', 'boolean', NULL),
('offline_sync_integrity_violations', 'uuidRowId', 'uuid', NULL),
('offline_sync_integrity_violations', 'ownerSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'incomingSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'uuidNodeId', 'uuid', NULL),
('offline_sync_integrity_violations', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'firstSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'lastSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_space_members', 'userUuid', 'uuid', NULL),
('offline_sync_space_nodes', 'lastReceivedHlc', 'jsonb', NULL),
('offline_sync_spaces', 'uuidSpaceId', 'uuid', NULL),
('serverpod_cloud_storage', 'addedTime', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'verified', 'boolean', NULL),
('serverpod_cloud_storage_direct_download', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'preventOverwrite', 'boolean', NULL),
('serverpod_future_call', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_future_call', 'scheduling', 'json', NULL),
('serverpod_future_call_claim', 'lastHeartbeatTime', 'timestampWithoutTimeZone', NULL),
('serverpod_health_connection_info', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'isHealthy', 'boolean', NULL),
('serverpod_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_message_log', 'slow', 'boolean', NULL),
('serverpod_migrations', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_query_log', 'slow', 'boolean', NULL),
('serverpod_runtime_settings', 'logSettings', 'json', NULL),
('serverpod_runtime_settings', 'logSettingsOverrides', 'json', NULL),
('serverpod_runtime_settings', 'logServiceCalls', 'boolean', NULL),
('serverpod_runtime_settings', 'logMalformedCalls', 'boolean', NULL),
('serverpod_session_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_session_log', 'slow', 'boolean', NULL),
('serverpod_session_log', 'isOpen', 'boolean', NULL),
('serverpod_session_log', 'touched', 'timestampWithoutTimeZone', NULL);
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
COMMIT;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,670 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "deleteTable",
"deleteTable": "crdt_sync_integrity_violations"
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "deleteTable",
"deleteTable": "crdt_scopes"
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "deleteTable",
"deleteTable": "crdt_scope_nodes"
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "deleteTable",
"deleteTable": "crdt_scope_members"
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "deleteTable",
"deleteTable": "crdt_data_rows"
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "crdt_data_rows",
"dartName": "CrdtDataRow",
"module": "serverpod_offline_sync",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "hlcDatetime",
"fieldName": "hlcDatetime",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "hlcCounter",
"fieldName": "hlcCounter",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "tblId",
"fieldName": "tblId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "uuidRowId",
"fieldName": "uuidRowId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nodeId",
"fieldName": "nodeId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "visibility",
"fieldName": "visibility",
"columnType": 6,
"isNullable": false,
"columnDefault": "0",
"dartType": "serverpod_offline_sync:CrdtDataRowVisibility"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "crdt_data_rows_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "crdt_data_rows_fk_1",
"columns": [
"tblId"
],
"referenceTable": "crdt_schema_tables",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "crdt_data_rows_fk_2",
"columns": [
"nodeId"
],
"referenceTable": "crdt_nodes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 3
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "crdt_data_rows_space_tbl_row_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "tblId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "uuidRowId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "offline_sync_integrity_violations",
"dartName": "OfflineSyncIntegrityViolation",
"module": "serverpod_offline_sync",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "type",
"fieldName": "type",
"columnType": 0,
"isNullable": false,
"dartType": "serverpod_offline_sync:OfflineSyncViolationType"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "domainTableName",
"fieldName": "domainTableName",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "uuidRowId",
"fieldName": "uuidRowId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "ownerSpaceUuid",
"fieldName": "ownerSpaceUuid",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "incomingSpaceUuid",
"fieldName": "incomingSpaceUuid",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "operation",
"fieldName": "operation",
"columnType": 0,
"isNullable": false,
"dartType": "serverpod_offline_sync:OfflineSyncViolationOperation"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "uuidNodeId",
"fieldName": "uuidNodeId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "crdtDataRowId",
"fieldName": "crdtDataRowId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "hlcDatetime",
"fieldName": "hlcDatetime",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "hlcCounter",
"fieldName": "hlcCounter",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "firstSeenAt",
"fieldName": "firstSeenAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "lastSeenAt",
"fieldName": "lastSeenAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "occurrences",
"fieldName": "occurrences",
"columnType": 6,
"isNullable": false,
"dartType": "int"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "offline_sync_integrity_violations_key_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "type"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "operation"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "domainTableName"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "uuidRowId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "ownerSpaceUuid"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "incomingSpaceUuid"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "offline_sync_space_members",
"dartName": "OfflineSyncSpaceMember",
"module": "serverpod_offline_sync",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "userUuid",
"fieldName": "userUuid",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "role",
"fieldName": "role",
"columnType": 0,
"isNullable": false,
"dartType": "serverpod_offline_sync:OfflineSyncSpaceRole"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "offline_sync_space_members_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "offline_sync_space_member_unique_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "userUuid"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "offline_sync_space_nodes",
"dartName": "OfflineSyncSpaceNode",
"module": "serverpod_offline_sync",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nodeId",
"fieldName": "nodeId",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "lastReceivedHlc",
"fieldName": "lastReceivedHlc",
"columnType": 14,
"isNullable": true,
"dartType": "package:serverpod_offline_sync/serverpod_offline_sync.dart:Hlc?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "offline_sync_space_nodes_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "offline_sync_space_nodes_fk_1",
"columns": [
"nodeId"
],
"referenceTable": "crdt_nodes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "offline_sync_space_node_unique_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "nodeId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "offline_sync_spaces",
"dartName": "OfflineSyncSpace",
"module": "serverpod_offline_sync",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "uuidSpaceId",
"fieldName": "uuidSpaceId",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "currentNodeId",
"fieldName": "currentNodeId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "offline_sync_spaces_fk_0",
"columns": [
"currentNodeId"
],
"referenceTable": "crdt_nodes",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 3
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "offline_sync_spaces__uuidSpaceId__unique_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "uuidSpaceId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "tableDropped",
"message": "Table \"crdt_sync_integrity_violations\" will be dropped.",
"table": "crdt_sync_integrity_violations",
"columns": [],
"destrucive": true
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "tableDropped",
"message": "Table \"crdt_scopes\" will be dropped.",
"table": "crdt_scopes",
"columns": [],
"destrucive": true
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "tableDropped",
"message": "Table \"crdt_scope_nodes\" will be dropped.",
"table": "crdt_scope_nodes",
"columns": [],
"destrucive": true
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "tableDropped",
"message": "Table \"crdt_scope_members\" will be dropped.",
"table": "crdt_scope_members",
"columns": [],
"destrucive": true
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "columnDropped",
"message": "Column \"scopeId\" of table \"crdt_data_rows\" will be dropped.",
"table": "crdt_data_rows",
"columns": [
"scopeId"
],
"destrucive": true
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "uniqueIndexCreated",
"message": "Unique index \"crdt_data_rows_space_tbl_row_idx\" is added to table \"crdt_data_rows\". If there are existing rows with duplicate values, this migration will fail.",
"table": "crdt_data_rows",
"columns": [
"spaceId",
"tblId",
"uuidRowId"
],
"destrucive": false
},
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "tableDropped",
"message": "One or more columns are added to table \"crdt_data_rows\" which cannot be added in a table migration. The complete table will be deleted and recreated.",
"table": "crdt_data_rows",
"columns": [
"spaceId"
],
"destrucive": true
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,187 @@
BEGIN;
--
-- ACTION DROP TABLE
--
DROP TABLE "crdt_sync_integrity_violations";
--
-- ACTION DROP TABLE
--
DROP TABLE "crdt_scopes";
--
-- ACTION DROP TABLE
--
DROP TABLE "crdt_scope_nodes";
--
-- ACTION DROP TABLE
--
DROP TABLE "crdt_scope_members";
--
-- ACTION DROP TABLE
--
DROP TABLE "crdt_data_rows";
--
-- ACTION CREATE TABLE
--
CREATE TABLE "crdt_data_rows" (
"id" INTEGER PRIMARY KEY,
"hlcDatetime" INTEGER NOT NULL,
"hlcCounter" INTEGER NOT NULL,
"spaceId" INTEGER NOT NULL,
"tblId" INTEGER NOT NULL,
"uuidRowId" BLOB NOT NULL,
"nodeId" INTEGER NOT NULL,
"visibility" INTEGER NOT NULL DEFAULT (0),
CONSTRAINT "crdt_data_rows_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_rows_fk_1" FOREIGN KEY ("tblId") REFERENCES "crdt_schema_tables" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "crdt_data_rows_fk_2" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "crdt_data_rows_space_tbl_row_idx" ON "crdt_data_rows" ("spaceId", "tblId", "uuidRowId");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "offline_sync_integrity_violations" (
"id" INTEGER PRIMARY KEY,
"type" TEXT NOT NULL,
"domainTableName" TEXT NOT NULL,
"uuidRowId" BLOB NOT NULL,
"ownerSpaceUuid" BLOB,
"incomingSpaceUuid" BLOB NOT NULL,
"operation" TEXT NOT NULL,
"uuidNodeId" BLOB,
"crdtDataRowId" INTEGER,
"hlcDatetime" INTEGER,
"hlcCounter" INTEGER,
"firstSeenAt" INTEGER NOT NULL,
"lastSeenAt" INTEGER NOT NULL,
"occurrences" INTEGER NOT NULL
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_integrity_violations_key_idx" ON "offline_sync_integrity_violations" ("type", "operation", "domainTableName", "uuidRowId", "ownerSpaceUuid", "incomingSpaceUuid");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "offline_sync_space_members" (
"id" INTEGER PRIMARY KEY,
"spaceId" INTEGER NOT NULL,
"userUuid" BLOB NOT NULL,
"role" TEXT NOT NULL,
CONSTRAINT "offline_sync_space_members_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_space_member_unique_idx" ON "offline_sync_space_members" ("userUuid", "spaceId");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "offline_sync_space_nodes" (
"id" INTEGER PRIMARY KEY,
"spaceId" INTEGER NOT NULL,
"nodeId" INTEGER NOT NULL,
"lastReceivedHlc" BLOB,
CONSTRAINT "offline_sync_space_nodes_fk_0" FOREIGN KEY ("spaceId") REFERENCES "offline_sync_spaces" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "offline_sync_space_nodes_fk_1" FOREIGN KEY ("nodeId") REFERENCES "crdt_nodes" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_space_node_unique_idx" ON "offline_sync_space_nodes" ("spaceId", "nodeId");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "offline_sync_spaces" (
"id" INTEGER PRIMARY KEY,
"uuidSpaceId" BLOB NOT NULL DEFAULT (unhex(printf('%012x', CAST(unixepoch('now', 'subsecond') * 1000 AS INTEGER)) || '7' || substr(hex(randomblob(2)), 2, 3) || substr('89AB', 1 + (abs(random()) % 4), 1) || substr(hex(randomblob(8)), 2, 15))),
"currentNodeId" INTEGER,
CONSTRAINT "offline_sync_spaces_fk_0" FOREIGN KEY ("currentNodeId") REFERENCES "crdt_nodes" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
) STRICT;
-- Indexes
CREATE UNIQUE INDEX "offline_sync_spaces__uuidSpaceId__unique_idx" ON "offline_sync_spaces" ("uuidSpaceId");
--
-- STORE COLUMN TYPES FOR MIGRATIONS
--
DROP TABLE IF EXISTS "serverpod_sqlite_schema";
CREATE TABLE "serverpod_sqlite_schema" (
"table_name" TEXT NOT NULL,
"column_name" TEXT NOT NULL,
"column_type" TEXT NOT NULL,
"column_vector_dimension" INTEGER,
PRIMARY KEY ("table_name", "column_name")
);
INSERT INTO "serverpod_sqlite_schema" VALUES
('crdt_data_attempted_value', 'value', 'jsonb', NULL),
('crdt_data_fields', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'uuidRowId', 'uuid', NULL),
('crdt_data_tombstone', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_nodes', 'uuidNodeId', 'uuid', NULL),
('crdt_nodes', 'lastHlc', 'jsonb', NULL),
('crdt_schema_columns', 'isNullable', 'boolean', NULL),
('offline_sync_integrity_violations', 'uuidRowId', 'uuid', NULL),
('offline_sync_integrity_violations', 'ownerSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'incomingSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'uuidNodeId', 'uuid', NULL),
('offline_sync_integrity_violations', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'firstSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'lastSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_space_members', 'userUuid', 'uuid', NULL),
('offline_sync_space_nodes', 'lastReceivedHlc', 'jsonb', NULL),
('offline_sync_spaces', 'uuidSpaceId', 'uuid', NULL),
('serverpod_cloud_storage', 'addedTime', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'verified', 'boolean', NULL),
('serverpod_cloud_storage_direct_download', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'preventOverwrite', 'boolean', NULL),
('serverpod_future_call', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_future_call', 'scheduling', 'json', NULL),
('serverpod_future_call_claim', 'lastHeartbeatTime', 'timestampWithoutTimeZone', NULL),
('serverpod_health_connection_info', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'isHealthy', 'boolean', NULL),
('serverpod_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_message_log', 'slow', 'boolean', NULL),
('serverpod_migrations', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_query_log', 'slow', 'boolean', NULL),
('serverpod_runtime_settings', 'logSettings', 'json', NULL),
('serverpod_runtime_settings', 'logSettingsOverrides', 'json', NULL),
('serverpod_runtime_settings', 'logServiceCalls', 'boolean', NULL),
('serverpod_runtime_settings', 'logMalformedCalls', 'boolean', NULL),
('serverpod_session_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_session_log', 'slow', 'boolean', NULL),
('serverpod_session_log', 'isOpen', 'boolean', NULL),
('serverpod_session_log', 'touched', 'timestampWithoutTimeZone', NULL);
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
COMMIT;

View file

@ -8,3 +8,4 @@
20260831202217145
20260902184216091
20260902195732182
20260914143806119

View file

@ -1,358 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT scopes]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
session.serverpod.initializeCrdtSync(syncTables: []);
});
group('Given a database with no shared scopes,', () {
test(
'when createFor is called with only a user, '
'then the user is granted readWrite access to the new scope.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.createFor(user);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await CrdtScopeMembership.memberScopes(session, user),
contains(scope),
);
},
);
test(
'when createFor is called with a user and a role, '
'then the user is granted that role.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.createFor(
user,
role: CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readOnly},
);
},
);
test(
'when create is called without grants, '
'then the new shared scope is dormant and has no explicit members.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.create();
expect(await session.crdt.scopes.members(scope), isEmpty);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
isNull,
);
expect(
await CrdtScopeMembership.memberScopes(session, user),
isNot(contains(scope)),
);
},
);
test(
'when create is called with several grants, '
'then every membership is stored with its role.',
() async {
final writer = const Uuid().v7obj();
final reader = const Uuid().v7obj();
final scope = await session.crdt.scopes.create(
grants: {
writer: CrdtScopeRole.readWrite,
reader: CrdtScopeRole.readOnly,
},
);
expect(await session.crdt.scopes.members(scope), {
writer: CrdtScopeRole.readWrite,
reader: CrdtScopeRole.readOnly,
});
},
);
test(
'when roleOf is called for a personal scope with no row, '
'then readWrite is returned.',
() async {
final user = const Uuid().v7obj();
expect(
await session.crdt.scopes.roleOf(user: user, scope: user),
CrdtScopeRole.readWrite,
);
expect(await session.crdt.scopes.members(user), isEmpty);
},
);
test(
'when createFor is called inside a transaction that rolls back, '
'then no scope or membership rows leak.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.crdt.scopes.createFor(user, transaction: tx);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
},
);
test(
'when grant is called for an unknown shared scope, '
'then CrdtScopeNotFoundException is thrown.',
() async {
final missingScope = const Uuid().v7obj();
await expectLater(
session.crdt.scopes.grant(
scope: missingScope,
user: const Uuid().v7obj(),
role: CrdtScopeRole.readWrite,
),
throwsA(
isA<CrdtScopeNotFoundException>().having(
(error) => error.scope,
'scope',
missingScope,
),
),
);
},
);
});
group('Given a database with a dormant shared scope,', () {
late UuidValue scope;
setUp(() async {
scope = await session.crdt.scopes.create();
});
test(
'when grant is called, '
'then the membership is inserted.',
() async {
final user = const Uuid().v7obj();
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readOnly},
);
},
);
test(
'when grantAll is called with several users, '
'then all memberships are applied.',
() async {
final readOnlyUser = const Uuid().v7obj();
final readWriteUser = const Uuid().v7obj();
await session.crdt.scopes.grantAll(scope, {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
expect(await session.crdt.scopes.members(scope), {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
},
);
test(
'when grant is called inside a transaction that rolls back, '
'then no membership row leaks.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readWrite,
transaction: tx,
);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
isNull,
);
},
);
});
group('Given a database with a readOnly member on a shared scope,', () {
late UuidValue user;
late UuidValue scope;
setUp(() async {
user = const Uuid().v7obj();
scope = await session.crdt.scopes.create(
grants: {user: CrdtScopeRole.readOnly},
);
});
test(
'when grant is called again for the same member with readWrite, '
'then the existing membership is updated.',
() async {
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readWrite},
);
},
);
});
group('Given a database with a shared scope that has one readWrite member,', () {
late UuidValue member;
late UuidValue nonMember;
late UuidValue scope;
setUp(() async {
member = const Uuid().v7obj();
nonMember = const Uuid().v7obj();
scope = await session.crdt.scopes.createFor(member);
});
test(
'when revoke is called for the member, '
'then the membership is removed.',
() async {
await session.crdt.scopes.revoke(scope: scope, user: member);
expect(
await session.crdt.scopes.roleOf(user: member, scope: scope),
isNull,
);
expect(await session.crdt.scopes.members(scope), isEmpty);
},
);
test(
'when revoke is called for a non-member, '
'then it completes without changing the scope members.',
() async {
await expectLater(
session.crdt.scopes.revoke(scope: scope, user: nonMember),
completes,
);
expect(
await session.crdt.scopes.members(scope),
{member: CrdtScopeRole.readWrite},
);
},
);
test(
'when roleOf is called for stored and missing memberships, '
'then it returns the stored role or null.',
() async {
expect(
await session.crdt.scopes.roleOf(user: member, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.roleOf(user: nonMember, scope: scope),
isNull,
);
},
);
});
group('Given a database with populated and dormant shared scopes,', () {
late UuidValue readOnlyUser;
late UuidValue readWriteUser;
late UuidValue populatedScope;
late UuidValue dormantScope;
setUp(() async {
readOnlyUser = const Uuid().v7obj();
readWriteUser = const Uuid().v7obj();
populatedScope = await session.crdt.scopes.create(
grants: {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
},
);
dormantScope = await session.crdt.scopes.create();
});
test(
'when members is called, '
'then explicit membership maps are returned.',
() async {
expect(await session.crdt.scopes.members(populatedScope), {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
expect(await session.crdt.scopes.members(dormantScope), isEmpty);
},
);
});
});
}
Future<Map<String, int>> _membershipRowCounts(Session session) async {
return {
CrdtScope.t.tableName: await CrdtScope.db.count(session),
CrdtScopeMember.t.tableName: await CrdtScopeMember.db.count(session),
};
}

View file

@ -0,0 +1,358 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT spaces]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
session.serverpod.initializeOfflineSync(syncTables: []);
});
group('Given a database with no shared spaces,', () {
test(
'when createFor is called with only a user, '
'then the user is granted readWrite access to the new space.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.createFor(user);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await OfflineSyncSpaceMembership.memberSpaces(session, user),
contains(space),
);
},
);
test(
'when createFor is called with a user and a role, '
'then the user is granted that role.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.createFor(
user,
role: OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readOnly},
);
},
);
test(
'when create is called without grants, '
'then the new shared space is dormant and has no explicit members.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.create();
expect(await session.offlineSync.spaces.members(space), isEmpty);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
isNull,
);
expect(
await OfflineSyncSpaceMembership.memberSpaces(session, user),
isNot(contains(space)),
);
},
);
test(
'when create is called with several grants, '
'then every membership is stored with its role.',
() async {
final writer = const Uuid().v7obj();
final reader = const Uuid().v7obj();
final space = await session.offlineSync.spaces.create(
grants: {
writer: OfflineSyncSpaceRole.readWrite,
reader: OfflineSyncSpaceRole.readOnly,
},
);
expect(await session.offlineSync.spaces.members(space), {
writer: OfflineSyncSpaceRole.readWrite,
reader: OfflineSyncSpaceRole.readOnly,
});
},
);
test(
'when roleOf is called for a personal space with no row, '
'then readWrite is returned.',
() async {
final user = const Uuid().v7obj();
expect(
await session.offlineSync.spaces.roleOf(user: user, space: user),
OfflineSyncSpaceRole.readWrite,
);
expect(await session.offlineSync.spaces.members(user), isEmpty);
},
);
test(
'when createFor is called inside a transaction that rolls back, '
'then no space or membership rows leak.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.offlineSync.spaces.createFor(user, transaction: tx);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
},
);
test(
'when grant is called for an unknown shared space, '
'then OfflineSyncSpaceNotFoundException is thrown.',
() async {
final missingSpace = const Uuid().v7obj();
await expectLater(
session.offlineSync.spaces.grant(
space: missingSpace,
user: const Uuid().v7obj(),
role: OfflineSyncSpaceRole.readWrite,
),
throwsA(
isA<OfflineSyncSpaceNotFoundException>().having(
(error) => error.space,
'space',
missingSpace,
),
),
);
},
);
});
group('Given a database with a dormant shared space,', () {
late UuidValue space;
setUp(() async {
space = await session.offlineSync.spaces.create();
});
test(
'when grant is called, '
'then the membership is inserted.',
() async {
final user = const Uuid().v7obj();
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readOnly},
);
},
);
test(
'when grantAll is called with several users, '
'then all memberships are applied.',
() async {
final readOnlyUser = const Uuid().v7obj();
final readWriteUser = const Uuid().v7obj();
await session.offlineSync.spaces.grantAll(space, {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
expect(await session.offlineSync.spaces.members(space), {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
},
);
test(
'when grant is called inside a transaction that rolls back, '
'then no membership row leaks.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readWrite,
transaction: tx,
);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
isNull,
);
},
);
});
group('Given a database with a readOnly member on a shared space,', () {
late UuidValue user;
late UuidValue space;
setUp(() async {
user = const Uuid().v7obj();
space = await session.offlineSync.spaces.create(
grants: {user: OfflineSyncSpaceRole.readOnly},
);
});
test(
'when grant is called again for the same member with readWrite, '
'then the existing membership is updated.',
() async {
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readWrite},
);
},
);
});
group('Given a database with a shared space that has one readWrite member,', () {
late UuidValue member;
late UuidValue nonMember;
late UuidValue space;
setUp(() async {
member = const Uuid().v7obj();
nonMember = const Uuid().v7obj();
space = await session.offlineSync.spaces.createFor(member);
});
test(
'when revoke is called for the member, '
'then the membership is removed.',
() async {
await session.offlineSync.spaces.revoke(space: space, user: member);
expect(
await session.offlineSync.spaces.roleOf(user: member, space: space),
isNull,
);
expect(await session.offlineSync.spaces.members(space), isEmpty);
},
);
test(
'when revoke is called for a non-member, '
'then it completes without changing the space members.',
() async {
await expectLater(
session.offlineSync.spaces.revoke(space: space, user: nonMember),
completes,
);
expect(
await session.offlineSync.spaces.members(space),
{member: OfflineSyncSpaceRole.readWrite},
);
},
);
test(
'when roleOf is called for stored and missing memberships, '
'then it returns the stored role or null.',
() async {
expect(
await session.offlineSync.spaces.roleOf(user: member, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.roleOf(user: nonMember, space: space),
isNull,
);
},
);
});
group('Given a database with populated and dormant shared spaces,', () {
late UuidValue readOnlyUser;
late UuidValue readWriteUser;
late UuidValue populatedSpace;
late UuidValue dormantSpace;
setUp(() async {
readOnlyUser = const Uuid().v7obj();
readWriteUser = const Uuid().v7obj();
populatedSpace = await session.offlineSync.spaces.create(
grants: {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
},
);
dormantSpace = await session.offlineSync.spaces.create();
});
test(
'when members is called, '
'then explicit membership maps are returned.',
() async {
expect(await session.offlineSync.spaces.members(populatedSpace), {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
expect(await session.offlineSync.spaces.members(dormantSpace), isEmpty);
},
);
});
});
}
Future<Map<String, int>> _membershipRowCounts(Session session) async {
return {
OfflineSyncSpace.t.tableName: await OfflineSyncSpace.db.count(session),
OfflineSyncSpaceMember.t.tableName: await OfflineSyncSpaceMember.db.count(session),
};
}

View file

@ -1,265 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_server/src/generated/protocol.dart' as server;
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT Scope Membership]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
});
group(
'Given a user with personal scope access and readWrite membership in one shared CRDT scope,',
() {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
final sharedScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readWrite,
),
);
});
test(
'when member scopes are resolved, '
'then only the personal scope and the granted shared scope are included.',
() async {
final scopes = await CrdtScopeMembership.memberScopes(session, userUuid);
final expectedScopes = [userUuid, sharedScopeUuid]
..sort((a, b) => a.uuid.compareTo(b.uuid));
expect(scopes, expectedScopes);
},
);
test(
'when member grants are resolved, '
'then the personal and granted shared scopes are returned with their roles.',
() async {
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
expect(
{for (final grant in grants) grant.uuidScopeId: grant.role},
{
userUuid: CrdtScopeRole.readWrite,
sharedScopeUuid: CrdtScopeRole.readWrite,
},
);
},
);
test(
'when membership is checked for the personal scope, '
'then the user is a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: userUuid,
),
isTrue,
);
},
);
test(
'when membership is checked for the granted shared scope, '
'then the user is a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
isTrue,
);
},
);
test(
'when the role is resolved for the granted shared scope, '
'then the stored membership role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
CrdtScopeRole.readWrite,
);
},
);
test(
'when the role is resolved for the personal scope, '
'then the readWrite role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: userUuid,
),
CrdtScopeRole.readWrite,
);
},
);
test(
'when the role is resolved for a scope without a CRDT scope row, '
'then no role is returned.',
() async {
final missingScopeUuid = const Uuid().v7obj();
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: missingScopeUuid,
),
isNull,
);
},
);
test(
'when a stray membership row grants readOnly access to the personal scope, '
'then the personal grant remains readWrite.',
() async {
final personalScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: userUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: personalScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readOnly,
),
);
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
expect(
grants.singleWhere((grant) => grant.uuidScopeId == userUuid).role,
CrdtScopeRole.readWrite,
);
},
);
test(
'when a stored membership row has no role, '
'then resolving grants fails instead of treating it as a grant.',
() async {
final scope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: const Uuid().v7obj()),
);
final encodedScopeId = ValueEncoder.instance.convert(scope.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
await session.db.unsafeExecute(
'ALTER TABLE "crdt_scope_members" ALTER COLUMN "role" DROP NOT NULL',
);
await session.db.unsafeExecute(
'INSERT INTO "crdt_scope_members" ("scopeId", "userUuid", "role") '
'VALUES ($encodedScopeId, $encodedUserUuid, NULL)',
);
await expectLater(
CrdtScopeMembership.memberGrants(session, userUuid),
throwsA(isA<Error>()),
);
},
);
},
);
group('Given a user with readOnly membership in a shared CRDT scope,', () {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
final sharedScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readOnly,
),
);
});
test(
'when the role is resolved for that scope, '
'then the readOnly role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
CrdtScopeRole.readOnly,
);
},
);
});
group('Given a shared CRDT scope without membership for a user,', () {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
});
test(
'when membership is checked for that scope, '
'then the user is not a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
isFalse,
);
},
);
});
});
}

View file

@ -0,0 +1,274 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_server/src/generated/protocol.dart' as server;
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT Space Membership]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
});
group(
'Given a user with personal space access and readWrite membership in one shared CRDT space,',
() {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
final sharedSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: sharedSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readWrite,
),
);
});
test(
'when member spaces are resolved, '
'then only the personal space and the granted shared space are included.',
() async {
final spaces = await OfflineSyncSpaceMembership.memberSpaces(
session,
userUuid,
);
final expectedSpaces = [userUuid, sharedSpaceUuid]
..sort((a, b) => a.uuid.compareTo(b.uuid));
expect(spaces, expectedSpaces);
},
);
test(
'when member grants are resolved, '
'then the personal and granted shared spaces are returned with their roles.',
() async {
final grants = await OfflineSyncSpaceMembership.memberGrants(
session,
userUuid,
);
expect(
{for (final grant in grants) grant.uuidSpaceId: grant.role},
{
userUuid: OfflineSyncSpaceRole.readWrite,
sharedSpaceUuid: OfflineSyncSpaceRole.readWrite,
},
);
},
);
test(
'when membership is checked for the personal space, '
'then the user is a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: userUuid,
),
isTrue,
);
},
);
test(
'when membership is checked for the granted shared space, '
'then the user is a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
isTrue,
);
},
);
test(
'when the role is resolved for the granted shared space, '
'then the stored membership role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when the role is resolved for the personal space, '
'then the readWrite role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: userUuid,
),
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when the role is resolved for a space without a CRDT space row, '
'then no role is returned.',
() async {
final missingSpaceUuid = const Uuid().v7obj();
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: missingSpaceUuid,
),
isNull,
);
},
);
test(
'when a stray membership row grants readOnly access to the personal space, '
'then the personal grant remains readWrite.',
() async {
final personalSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: userUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: personalSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readOnly,
),
);
final grants = await OfflineSyncSpaceMembership.memberGrants(
session,
userUuid,
);
expect(
grants.singleWhere((grant) => grant.uuidSpaceId == userUuid).role,
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when a stored membership row has no role, '
'then resolving grants fails instead of treating it as a grant.',
() async {
final space = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: const Uuid().v7obj()),
);
final encodedSpaceId = ValueEncoder.instance.convert(space.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
await session.db.unsafeExecute(
'ALTER TABLE "offline_sync_space_members" ALTER COLUMN "role" DROP NOT NULL',
);
await session.db.unsafeExecute(
'INSERT INTO "offline_sync_space_members" ("spaceId", "userUuid", "role") '
'VALUES ($encodedSpaceId, $encodedUserUuid, NULL)',
);
await expectLater(
OfflineSyncSpaceMembership.memberGrants(session, userUuid),
throwsA(isA<Error>()),
);
},
);
},
);
group('Given a user with readOnly membership in a shared CRDT space,', () {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
final sharedSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: sharedSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readOnly,
),
);
});
test(
'when the role is resolved for that space, '
'then the readOnly role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
OfflineSyncSpaceRole.readOnly,
);
},
);
});
group('Given a shared CRDT space without membership for a user,', () {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
});
test(
'when membership is checked for that space, '
'then the user is not a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
isFalse,
);
},
);
});
});
}

View file

@ -144,7 +144,7 @@ void withServerpod(
}
class TestEndpoints {
late final _CrdtSyncEndpoint crdtSync;
late final _OfflineSyncEndpoint offlineSync;
}
class _InternalTestEndpoints extends TestEndpoints
@ -154,15 +154,15 @@ class _InternalTestEndpoints extends TestEndpoints
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {
crdtSync = _CrdtSyncEndpoint(
offlineSync = _OfflineSyncEndpoint(
endpoints,
serializationManager,
);
}
}
class _CrdtSyncEndpoint {
_CrdtSyncEndpoint(
class _OfflineSyncEndpoint {
_OfflineSyncEndpoint(
this._endpointDispatch,
this._serializationManager,
);
@ -171,23 +171,23 @@ class _CrdtSyncEndpoint {
final _is.SerializationManager _serializationManager;
_ida.Stream<_icw2tu00.CrdtSyncStreamEvent> sync(
_ida.Stream<_icw2tu00.OfflineSyncStreamEvent> sync(
_ist.TestSessionBuilder sessionBuilder, {
required _ida.Stream<_icw2tu00.CrdtSyncStreamEvent> changes,
required _ida.Stream<_icw2tu00.OfflineSyncStreamEvent> changes,
required bool once,
}) {
var _localTestStreamManager =
_ist.TestStreamManager<_icw2tu00.CrdtSyncStreamEvent>();
_ist.TestStreamManager<_icw2tu00.OfflineSyncStreamEvent>();
_ist.callStreamFunctionAndHandleExceptions(
() async {
var _localUniqueSession = (sessionBuilder as _ist.InternalTestSessionBuilder)
.internalBuild(
endpoint: 'crdtSync',
endpoint: 'offlineSync',
method: 'sync',
);
var _localCallContext = await _endpointDispatch.getMethodStreamCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'crdtSync',
endpointPath: 'offlineSync',
methodName: 'sync',
arguments: {'once': once},
requestedInputStreams: ['changes'],