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
7 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: