feat: Implement the scope management interface (#96)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-07 11:48:19 -03:00 • committed by GitHub
commit d89118a43a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 755 additions and 227 deletions

View file

@ -10,6 +10,8 @@ void main() {
withServerpod(
'Given a database-backed CRDT scope sync session,',
(sessionBuilder, _) {
sessionBuilder.build().serverpod.initializeCrdtSync(syncTables: []);
late Session session;
setUp(() async {
session = sessionBuilder.build();
@ -20,13 +22,7 @@ void main() {
'then only the authoritative session announces grants.',
() async {
final userUuid = _uuid(30);
final sharedScopeUuid = _uuid(20);
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
);
final sharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
final authoritative = CrdtScopeSyncSession(
session,
@ -73,14 +69,8 @@ void main() {
'then it keeps cycling only its locally resolved scopes.',
() async {
final userUuid = _uuid(31);
final localSharedScopeUuid = _uuid(21);
final peerOnlyScopeUuid = _uuid(11);
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: localSharedScopeUuid,
);
final localSharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
final authoritative = CrdtScopeSyncSession(
session,
@ -104,7 +94,6 @@ void main() {
'then adoption keeps the announced scope set until the next reconcile.',
() async {
final userUuid = _uuid(32);
final laterSharedScopeUuid = _uuid(22);
final authoritative = CrdtScopeSyncSession(
session,
@ -116,11 +105,7 @@ void main() {
await authoritative.reconcile();
authoritative.markAnnounced();
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: laterSharedScopeUuid,
);
final laterSharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
await authoritative.adoptPeerGrants(const []);
@ -143,10 +128,17 @@ void main() {
final announcedScopeUuid = _uuid(23);
final staleScopeUuid = _uuid(13);
await _upsertScopeMembership(
final scope = await CrdtScopeManager(session).getOrCreate(staleScopeUuid);
await CrdtScopeMember.db.upsertRow(
session,
userUuid: userUuid,
scopeUuid: staleScopeUuid,
CrdtScopeMember(
scopeId: scope.id!,
userUuid: userUuid,
role: CrdtScopeRole.readWrite,
),
conflictColumns: (t) => [t.scopeId, t.userUuid],
updateColumns: (t) => [t.role],
);
final follower = CrdtScopeSyncSession(
@ -317,7 +309,6 @@ void main() {
'then the tracked checkpoint moves to the change HLC.',
() async {
final userUuid = _uuid(37);
final scopeUuid = _uuid(27);
final localNodeUuid = _uuid(47);
final remoteNodeUuid = _uuid(107);
@ -328,11 +319,7 @@ void main() {
peerNodeId: _uuid(98),
);
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: scopeUuid,
);
final scopeUuid = await session.crdt.scopes.createFor(userUuid);
await authoritative.reconcile();
authoritative.recordPeerHandshake(
@ -376,19 +363,3 @@ CrdtSyncSinceHlc _since(UuidValue scopeUuid, Hlc checkpoint) =>
Hlc _hlc(UuidValue nodeUuid, {required int minute}) =>
Hlc(DateTime.utc(2026, 7, 2, 12, minute), 0, nodeUuid);
Future<void> _upsertScopeMembership(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
CrdtScopeRole role = CrdtScopeRole.readWrite,
}) async {
final scope = await CrdtScopeManager(session).getOrCreate(scopeUuid);
await CrdtScopeMember.db.upsertRow(
session,
CrdtScopeMember(scopeId: scope.id!, userUuid: userUuid, role: role),
conflictColumns: (t) => [t.scopeId, t.userUuid],
updateColumns: (t) => [t.role],
);
}

View file

@ -170,7 +170,6 @@ void main() {
final serverSubscription = rawServerSession.crdt
.sync(
serverSession,
userId: testCrdtUserId,
inbound: clientToServer.stream,
once: false,
@ -214,12 +213,7 @@ void main() {
late UuidValue sharedScopeId;
setUp(() async {
sharedScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
);
sharedScopeId = await rawServerSession.crdt.scopes.createFor(testCrdtUserId);
});
group('and personal and shared scope rows exist on the server,', () {
@ -313,11 +307,8 @@ void main() {
final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel);
final laterScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: laterScopeId,
final laterScopeId = await rawServerSession.crdt.scopes.createFor(
testCrdtUserId,
);
final sharedPerson = await serverSession.db.transactionForUser(
@ -363,10 +354,9 @@ void main() {
null;
});
await _revokeScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
await rawServerSession.crdt.scopes.revoke(
scope: sharedScopeId,
user: testCrdtUserId,
);
// The revoked scope's rows disappear from membership-wide reads…
@ -466,12 +456,8 @@ void main() {
late UuidValue serverPersonId;
setUp(() async {
readOnlyScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: readOnlyScopeId,
role: CrdtScopeRole.readOnly,
readOnlyScopeId = await rawServerSession.crdt.scopes.create(
grants: {testCrdtUserId: CrdtScopeRole.readOnly},
);
final serverPerson = await serverSession.db.transactionForUser(
readOnlyScopeId,
@ -570,10 +556,9 @@ void main() {
);
pendingPersonId = pendingPerson.id!;
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
await rawServerSession.crdt.scopes.grant(
scope: sharedScopeId,
user: testCrdtUserId,
role: CrdtScopeRole.readOnly,
);
final inboundPerson = await serverSession.db.transactionForUser(
@ -648,10 +633,9 @@ void main() {
);
revokedPersonId = revokedPerson.id!;
await testClient.crdt.syncOnce(clientSession);
await _revokeScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
await rawServerSession.crdt.scopes.revoke(
scope: sharedScopeId,
user: testCrdtUserId,
);
});
@ -727,10 +711,7 @@ void main() {
late UuidValue ungrantedScopeId;
setUp(() async {
ungrantedScopeId = const Uuid().v7obj();
await CrdtScopeManager(rawServerSession).getOrCreate(
ungrantedScopeId,
);
ungrantedScopeId = await rawServerSession.crdt.scopes.create();
});
test('then it is rejected before writing.', () async {
@ -1300,16 +1281,11 @@ void main() {
);
await serverSession.db.initialize();
sharedScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
);
await _upsertScopeMembership(
rawServerSession,
userUuid: secondUserId,
scopeUuid: sharedScopeId,
sharedScopeId = await rawServerSession.crdt.scopes.create(
grants: {
testCrdtUserId: CrdtScopeRole.readWrite,
secondUserId: CrdtScopeRole.readWrite,
},
);
});
@ -1595,39 +1571,6 @@ Future<void> _waitUntil(
throw TimeoutException('Condition was not met within $timeout.');
}
/// Upserts a `crdt_scope_members` row: an authoritative grant on the server,
/// or a (possibly stale) locally projected grant on a client session.
Future<void> _upsertScopeMembership(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
CrdtScopeRole role = CrdtScopeRole.readWrite,
}) async {
final scope = await CrdtScopeManager(session).getOrCreate(scopeUuid);
final encodedScopeId = ValueEncoder.instance.convert(scope.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final encodedRole = ValueEncoder.instance.convert(role.toJson());
await session.db.unsafeExecute(
'INSERT INTO "crdt_scope_members" ("scopeId", "userUuid", "role") '
'VALUES ($encodedScopeId, $encodedUserUuid, $encodedRole) '
'ON CONFLICT ("scopeId", "userUuid") DO UPDATE SET "role" = $encodedRole',
);
}
Future<void> _revokeScopeMembership(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
}) async {
final scope = await CrdtScopeManager(session).getOrCreate(scopeUuid);
final encodedScopeId = ValueEncoder.instance.convert(scope.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
await session.db.unsafeExecute(
'DELETE FROM "crdt_scope_members" '
'WHERE "scopeId" = $encodedScopeId AND "userUuid" = $encodedUserUuid',
);
}
class TestClientAuthKeyProvider implements ClientAuthKeyProvider {
TestClientAuthKeyProvider([this.userKey = 'token']);

View file

@ -80,12 +80,8 @@ void main() {
late Object? sessionError;
setUp(() async {
sharedScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
role: CrdtScopeRole.readOnly,
sharedScopeId = await rawServerSession.crdt.scopes.create(
grants: {testCrdtUserId: CrdtScopeRole.readOnly},
);
await _upsertScopeMembership(
clientSession,
@ -137,7 +133,6 @@ void main() {
sessionError = await _syncOnceWithSplicedMergeChunk(
serverSync: rawServerSession.crdt,
serverSession: serverSession,
clientSync: clientSync,
clientSession: clientSession,
userUuid: testCrdtUserId,
@ -221,7 +216,6 @@ void main() {
sessionError = await _syncOnceWithSplicedMergeChunk(
serverSync: rawServerSession.crdt,
serverSession: serverSession,
clientSync: clientSync,
clientSession: clientSession,
userUuid: testCrdtUserId,
@ -266,8 +260,7 @@ void main() {
/// Returns the error that ended the authoritative session, or null when it
/// closed cleanly.
Future<Object?> _syncOnceWithSplicedMergeChunk({
required CrdtSync serverSync,
required DatabaseSession serverSession,
required CrdtSession serverSync,
required CrdtSync clientSync,
required DatabaseSession clientSession,
required UuidValue userUuid,
@ -318,7 +311,6 @@ Future<Object?> _syncOnceWithSplicedMergeChunk({
final serverSubscription = serverSync
.sync(
serverSession,
userId: userUuid,
inbound: clientToServer.stream,
once: true,
@ -349,8 +341,8 @@ Future<Object?> _syncOnceWithSplicedMergeChunk({
}
}
/// Upserts a `crdt_scope_members` row: an authoritative grant on the server,
/// or a (possibly stale) locally projected grant on a client session.
/// Upserts a projected `crdt_scope_members` row for stale or adversarial client
/// state.
Future<void> _upsertScopeMembership(
DatabaseSession session, {
required UuidValue userUuid,