refactor!: Rename sync APIs and ownership spaces (#128)
This commit is contained in:
parent
4331daf661
commit
b89725f6ea
7 changed files with 43650 additions and 9417 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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".';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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".';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
crdtSync:
|
||||
offlineSync:
|
||||
- sync:
|
||||
|
|
|
|||
Loading…
Reference in a new issue