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
10 changed files with 755 additions and 227 deletions

View file

@ -1,29 +1,30 @@
# Scope management API
Status: proposal for a separate implementation issue. Builds on the shared-scope
Status: implemented. Builds on the shared-scope
membership model in `shared-scopes.md`; this document specifies the developer-
facing API for *managing* membership, which that document deliberately left as
"app domain".
facing API for *managing* membership while leaving invitations, acceptance, and
authorization policy in app domain.
## Summary
The shared-scope machinery is complete for **resolving and enforcing**
membership (`CrdtScopeMembership.memberScopes` / `memberGrants` / `roleOf` /
`isMember`, the sync-loop enforcement, membership-wide reads). It has no API for
**managing** membership — creating a shared scope, adding a member with a role,
changing a role, revoking. `shared-scopes.md` says so outright: *"Rows are
managed by application code: invitations, acceptance, and role assignment are app
domain, outside this package."*
`isMember`, the sync-loop enforcement, membership-wide reads), but those helpers
are package internals. The developer-facing API for **managing** membership is
the server-side `session.crdt.scopes` service: creating a shared scope, adding a
member with a role, changing a role, revoking, and listing explicit members.
`shared-scopes.md` records the same boundary: rows are managed through
`session.crdt.scopes`, while invitations, acceptance, and authorization policy
remain app domain.
Today a developer must replicate what `scope_membership_test.dart` does — two
non-atomic raw `insertRow` calls against the **server** package's generated
`CrdtScope` / `CrdtScopeMember`, leaking every internal invariant
(`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique index,
personal-scope-UUID == user-UUID). This proposal replaces that with a small,
honest server-side service:
The service avoids making developers replicate what `scope_membership_test.dart`
used to do — two non-atomic raw `insertRow` calls against the **server**
package's generated `CrdtScope` / `CrdtScopeMember`, leaking every internal
invariant (`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique
index, personal-scope-UUID == user-UUID):
```dart
final scopeId = await session.crdt.scopes.create(owner: creatorUuid);
final scopeId = await session.crdt.scopes.createFor(creatorUuid);
await session.crdt.scopes.grant(scope: scopeId, user: bob, role: CrdtScopeRole.readWrite);
await session.crdt.scopes.revoke(scope: scopeId, user: bob);
```
@ -97,7 +98,7 @@ own only what it enforces.*
Everything uneasy traced back to `admin`: the forgeable `by`, the bypass, and a
"last-admin" orphan guard all evaporate once it leaves. `CrdtScopeRole` stays
`{ readOnly, readWrite }` — exactly what is already implemented — so this
proposal adds **no** enum churn.
implementation adds **no** enum churn.
Apps model "admin" with what they already have: a Serverpod `Scope` on the auth
user for global staff, or their own domain data for per-scope owners. If the
@ -109,26 +110,34 @@ who is an admin, display name, joined-at). The app keeps them in step — adding
domain member also calls `scopes.grant` — the same split Serverpod has between
`AuthUser` and an app's user profile.
## Proposed API
## API
Server-side, hung off the existing `session.crdt` (`CrdtSync`) accessor as
`session.crdt.scopes`. A pure data layer: atomic, transaction-threaded, no
internal auth.
Server-side, hung off the `session.crdt` accessor (the session-bound
`CrdtSession` facade) as `session.crdt.scopes`. A pure data layer: atomic,
transaction-threaded, no internal auth.
```dart
/// Creates a shared scope and returns its UUID.
///
/// [owner], if given, is granted [CrdtScopeRole.readWrite]. [grants] adds any
/// further members. Both are applied in one transaction with the scope insert.
/// With no owner and no grants the scope is created dormant — it exists but no
/// one can sync it until membership is granted. Throws [ArgumentError] if
/// [owner] also appears in [grants].
/// [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({
UuidValue? owner,
Map<UuidValue, CrdtScopeRole>? grants,
Transaction? transaction,
});
/// Creates a shared scope granting [user] the given [role], in one
/// transaction.
///
/// Pure sugar for [create] with a single-entry grants map — the member has no
/// special bond to the scope and can be re-granted or revoked like any other.
Future<UuidValue> createFor(
UuidValue user, {
CrdtScopeRole role = CrdtScopeRole.readWrite,
Transaction? transaction,
});
/// Grants [user] the given [role] in [scope], upserting an existing membership.
Future<void> grant({
required UuidValue scope,
@ -151,14 +160,6 @@ Future<void> revoke({
Transaction? transaction,
});
/// The role [user] holds in [scope], or null if not a member. The read an app
/// wraps with its own admin policy before calling [grant] / [revoke].
Future<CrdtScopeRole?> roleOf({
required UuidValue user,
required UuidValue scope,
Transaction? transaction,
});
/// The members of [scope] and their roles.
Future<Map<UuidValue, CrdtScopeRole>> members(
UuidValue scope, {
@ -168,14 +169,20 @@ Future<Map<UuidValue, CrdtScopeRole>> members(
Notes:
- **`owner` is nullable** — a scope with no owner (and no grants) is valid but
dormant. `create` skips the membership insert when `owner` is null.
- **`owner` and `grants` compose.** `owner` is ergonomic sugar for
`{owner: readWrite}`; `grants` adds the rest. If `owner` also appears in
`grants`, throw `ArgumentError` rather than silently pick a winner — a creator
listed twice with a possibly-contradictory role is a caller bug.
- **There is no "owner".** An earlier sketch had an `owner` parameter on
`create`, but the name implied a durable bond the data model does not have —
the scope row stores no owner, and the creator ends up an ordinary
`readWrite` member. It was replaced by `createFor`, which is honest sugar:
create a scope and grant one member, atomically, with no special standing.
Removing `owner` also dissolved the owner-appears-in-`grants` `ArgumentError`
special case. A durable creator/owner concept is app domain, like `admin`.
- **`grants` may be empty or absent** — a scope with no members is valid but
dormant. `create()` skips the membership insert entirely.
- **`create` and `grantAll` share one internal helper** so the create path and
the later bulk-grant path have a single implementation.
- **Unknown-scope grants throw `CrdtScopeNotFoundException`** rather than a
generic argument error, so callers can distinguish a stale or invalid scope
UUID from other failures.
- **`create` returns a server-generated UUID** — the model already defaults
`uuidScopeId` to `random_v7`. An optional explicit `uuidScopeId` param
(app-chosen or idempotent create) is a compatible later addition; left out
@ -193,14 +200,14 @@ announcement and the follower projection already implemented in
## Test plan
- **Create.** `create(owner: u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `create()` (no owner) yields a dormant scope no user
can sync; `create(owner: u, grants: {u: ...})` throws `ArgumentError`.
- **Create.** `createFor(u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `createFor(u, role: ...)` stores the explicit role;
`create()` (no grants) yields a dormant scope no user can sync;
`create(grants: {...})` stores every entry with its role.
- **Grant/revoke.** `grant` inserts and upserts (role change); `revoke` removes
and is a no-op for a non-member; `grantAll` applies a map atomically.
- **Reads.** `roleOf` returns the stored role or null; `members` returns the
full map. Personal scope resolves to `readWrite` via the existing implicit
path.
- **Reads.** `members` returns the explicit member-role map. Internal role
resolution still covers stored, missing, and implicit personal membership.
- **Propagation.** After a server-side `grant`, a continuous follower session
adopts the new scope on the next cycle (covered by the existing access-change
suite); after `revoke`, it stops cycling it.

View file

@ -101,8 +101,8 @@ indexes:
checks — see *The client membership table*.
- The index **leads with `userUuid`** so the per-cycle `WHERE userUuid = ?`
resolution is index-covered.
- Rows are managed by application code: invitations, acceptance, and role
assignment are app domain, outside this package.
- Rows are managed through `session.crdt.scopes` (`CrdtScopes`). Invitations,
acceptance, and authorization policy remain app domain.
- **Personal-scope membership is implicit.** By convention a user's personal
scope has the user's own UUID (already true: the scope UUID keys the chain),
so no `crdt_scope_members` row is stored for it. Shared scopes are
@ -128,9 +128,10 @@ They do not map one-to-one and neither replaces the other. One member may have
many nodes (one per replica/database install). The **server is a node** in every
scope it syncs but is not a member row. A freshly invited member has **zero
scope-node rows** until their first device syncs. Nodes and scope-node rows are
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); members are
created by application code. Removing the `nodes` list would break checkpointing
and causal filtering — it is the chain topology, not the access list.
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); membership
rows are managed through `session.crdt.scopes` from application endpoints.
Removing the `nodes` list would break checkpointing and causal filtering — it is
the chain topology, not the access list.
### The client membership table
@ -143,7 +144,7 @@ is **populated as a read-only projection** of the server's authoritative grants
filters over projected membership, and a local `crdt_scopes` row by itself
does not grant access. The members table also carries **roles**, which the
client consults before local writes.
- The authoritative `CrdtSyncScopeSet` carries `CrdtScopeGrant`s — `(scopeUuid,
- The authoritative `CrdtSyncScopeSet` carries `CrdtScopeGrant`s — `(uuidScopeId,
role)` — not bare UUIDs. Every grant has an explicit role; the personal scope
is announced as `readWrite` but remains implicit in storage. When the follower
*receives* an announcement, `projectFollowerMembership` reconciles the local
@ -201,13 +202,17 @@ security boundary for stale, misconfigured, or hostile clients.
At the start of each sync cycle the server resolves the authoritative set:
```
memberScopes(userUuid) =
{ personal scope = userUuid }
∪ { m.scopeUuid : crdt_scope_members where userUuid = <auth user> }
memberGrants(userUuid) =
{ personal scope = userUuid, role = readWrite }
∪ { (s.uuidScopeId, m.role)
: crdt_scope_members m
join crdt_scopes s on s.id = m.scopeId
where m.userUuid = <auth user> }
```
This set bounds everything the cycle may sync. The client never expands it; it
only learns of additions through what the server announces.
This grant set bounds everything the cycle may sync and carries the role used by
followers for outbound filtering. The client never expands it; it only learns of
additions through what the server announces.
## Sync: one call syncs every accessible scope
@ -251,7 +256,7 @@ cadences; only *when* they are sent differs (`once`: a fixed sequence per scope;
this peer's CRDT node for the whole session. The scope list is dynamic and
exchanged per cycle.
- `CrdtSyncScopeSet { scopes: List<CrdtScopeGrant> }` (each grant a
`scopeUuid` + `role`) — the server's content is authoritative (resolved from
`uuidScopeId` + `role`) — the server's content is authoritative (resolved from
`crdt_scope_members`, roles included). Followers send an empty set because the
authoritative peer never widens access from follower-reported state. In `once`
it is exchanged exactly once. In `continuous` a peer re-announces it
@ -347,9 +352,9 @@ not a symmetric set operation:
The correct operation is neither: **the server dictates its set; the client
conforms.** Each cycle:
- **Server**: `orderedScopes = sort(memberScopes(userUuid))`, computed from
`crdt_scope_members`. It never reads the client's `ScopeSet` for authority
(constraint 4).
- **Server**: `orderedScopes = sort(memberGrants(userUuid).uuidScopeId)`,
computed from `crdt_scope_members` plus the implicit personal grant. It never
reads the client's `ScopeSet` for authority (constraint 4).
- **Client**: takes the server's `ScopeSet` as the set to cycle —
`getOrCreate` (`CrdtScopeManager`) any scope it lacks, ignore any local scope
the server omitted for this cycle — then `orderedScopes = sort(peer scopeIds)`.
@ -363,8 +368,8 @@ authoritative dictates its own set, follower adopts the peer's.
Once the data is unified, a closed enum is the right shape: there is no
per-role enumeration callback left to inject. Security does not rest on the
enum — a malicious client passing `authoritative` only fails to adopt the
peer's set; writes are still gated server-side by `CrdtScopeMembership.isMember`
against the authoritative table.
peer's set; writes are still gated server-side by
`CrdtScopeMembership.roleOf(...).canWrite` against the authoritative table.
In `once`, the client adopts straight from the cycle's `ScopeSet` *before* it
iterates, so a newly granted scope syncs in that same round. In `continuous`,
@ -374,7 +379,7 @@ order deterministic on both sides with no extra negotiation. Frames carry
`uuidScopeId` so a receiver groups each chunk into the right scope and fails loud
on an unauthorized tag rather than merging a batch into the wrong scope.
Re-resolving `memberScopes` every cycle is an indexed `crdt_scope_members`
Re-resolving `memberGrants` every cycle is an indexed `crdt_scope_members`
lookup on the server only; it may be throttled or invalidated by an
application hook if per-cycle querying becomes hot, at the cost of slower
propagation of access changes.
@ -435,12 +440,11 @@ db.transactionForUser(userId, fn, scopeId: listId); // acts in a shared scope
- Without `scopeId`, the scope resolves to the user's personal scope —
`userId` itself by convention — which is exactly today's behavior.
- With `scopeId`, the package asserts membership before acting by calling
`CrdtScopeMembership.isMember` directly against the shared table — no injected
validator. On the server that table is authoritative; on a persistent client
it is the server-projected membership cache. After membership passes,
shared-scope writes also resolve the member role; anything other than
`readWrite` throws `CrdtScopeRoleException` before the transaction starts.
- With `scopeId`, the package resolves the member role with
`CrdtScopeMembership.roleOf` — no injected validator. On the server that table
is authoritative; on a persistent client it is the server-projected membership
cache. A missing role throws `CrdtScopeMembershipException`; anything other
than `readWrite` throws `CrdtScopeRoleException` before the transaction starts.
- A transaction acts in **exactly one** scope (constraint 5). The write path —
stamp-or-assert, the scoped `WHERE`, `scopeId` immutability — is unchanged
beyond which scope is resolved.
@ -485,17 +489,19 @@ to run with `--concurrency=1`.
- **Phase 1 — membership foundation.** Implemented `crdt_scope_members`
(`database: all`, unsynced) and its migration. Added the shared
`CrdtScopeMembership` helpers (`memberScopes` / `isMember`). No behavior change
yet: a personal-scope-only set reproduces today's single-scope sync exactly.
`CrdtScopeMembership` helpers (`memberGrants`, `memberScopes`, `roleOf`,
`isMember`, and the follower projection path). No behavior change yet: a
personal-scope-only set reproduces today's single-scope sync exactly.
- **Phase 2 — sequential multi-scope sync (the requirement).** Implemented
protocol model
changes (`Connect` with session `localNodeId`, new per-cycle
`CrdtSyncScopeSet`, per-scope `SinceHlc`, `MergeChunk.uuidScopeId`) plus
generate/migrate. Outer cycle loop in `CrdtSync.sync` with per-cycle
`ScopeSet` exchange, per-scope handshake-on-first-visit, in-memory per-scope
checkpoints, and the once/continuous cadence. Reconciliation via a
`ScopeSet` exchange with role-carrying `CrdtScopeGrant`s, per-scope
handshake-on-first-visit, in-memory per-scope checkpoints, and the
once/continuous cadence. Reconciliation via a
`CrdtSyncPeerMode` enum: the server endpoint passes `authoritative` (cycles
`CrdtScopeMembership.memberScopes`); the client driver passes `follower`
`CrdtScopeMembership.memberGrants`); the client driver passes `follower`
(adopts the server's set). Scope-aware `onMergeSuccess`. A personal-scope-only
device behaves identically to today.
- **Phase 3 — transaction API.** Implemented
@ -524,6 +530,11 @@ to run with `--concurrency=1`.
`unauthorizedWrite` and fails before merge, client-side
`CrdtScopeRoleException` early rejection, and follower outbound skipping for
non-writable shared scopes while reads remain membership-wide.
- **Phase 8 — scope management service.** Implemented the server-side
`session.crdt.scopes` service (`create`, `createFor`, `grant`, `grantAll`,
`revoke`, `members`) with transaction/savepoint threading. Single grants reuse
the bulk-grant path; unknown-scope grants throw `CrdtScopeNotFoundException`.
Invitations, acceptance, and authorization policy stay in app endpoints.
## Test plan
@ -550,7 +561,7 @@ tested once, not per combination.
mutation, `readWrite` syncs normally, a follower skips outbound pending changes
for non-writable scopes while still receiving inbound changes, and a stale or
hostile client write is rejected and recorded by the server as
`unauthorizedWrite`. Personal scopes remain writable through their explicit
`unauthorizedWrite`. Personal scopes remain writable through their implicit
`readWrite` grant.
- **Access changes mid-session.** During a continuous session, a grant added to
`crdt_scope_members` appears and syncs on the next cycle; a revoke stops
@ -576,8 +587,8 @@ tested once, not per combination.
resolves through `CrdtScopeMembership`, so the only residual variation is
cycle direction — a closed enum, not a callback. The enum is not the security
boundary; server-side
`CrdtScopeMembership.isMember` gates every applied write regardless of the
client's declared mode.
`CrdtScopeMembership.roleOf(...).canWrite` gates every applied write
regardless of the client's declared mode.
- **Scope set is dynamic, not pinned at connect:** the authoritative peer
re-resolves its set every cycle and re-announces a `ScopeSet` whenever it
changed, so grants and revokes take effect mid-session without reconnecting.
@ -591,7 +602,7 @@ tested once, not per combination.
has no node until first sync.
- **Client membership table:** `database: all` puts the table on the client,
populated as a read-only projection. The `CrdtSyncScopeSet` carries
`CrdtScopeGrant`s (`scopeUuid` + `role`), and each follower cycle reconciles
`CrdtScopeGrant`s (`uuidScopeId` + `role`), and each follower cycle reconciles
the local table from the announcement (upsert granted, delete revoked; only
when the set changed). The projected members table drives shared-scope
membership gating and roles for UI and offline role-gated writes; local
@ -606,11 +617,11 @@ tested once, not per combination.
anyway.
- **Membership table is `database: all` but unsynced:** the schema exists on
every node, yet it is server-authoritative app state, never CRDT-replicated.
- **Idle chatter accepted for the initial implementation:** an idle continuous
cycle does one empty scope-turn per accessible scope rather than one per
session. It is proportional to scope count and harmless to start; skipping
scopes with no local pending changes and no expected inbound is an optional
later optimization, not a launch blocker.
- **Idle chatter removed:** continuous sync has one combined data loop per
cycle. When neither peer has grants, handshakes, or changes to send, it emits
no `ScopeSet`, `SinceHlc`, `MergeChunk`, or `EndOfBatch`; `collectNextBatch`
resolves the idle cycle from the timeout once per session cycle, not once per
scope.
- **Initial-sync ordering is not a current concern:** a device joining many
scopes cycles them one at a time on first sync. Prioritizing recently active
scopes in the iteration order is a compatible refinement if cold-start latency

View file

@ -1,5 +1,6 @@
export 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
export 'src/business/crdt_scopes.dart';
export 'src/business/crdt_sync.dart';
export 'src/endpoints/crdt_sync_endpoint.dart';
export 'src/generated/endpoints.dart';

View file

@ -0,0 +1,197 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.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 scopeId = await _insertScope(scope, tx);
await _applyGrants(scopeId, 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 scopeId = await _scopeIdForUuid(scope, transaction: tx);
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 {
final encodedUser = ValueEncoder.instance.convert(user);
final encodedScope = ValueEncoder.instance.convert(scope);
await _session.db.unsafeExecute(
'DELETE FROM "${CrdtScopeMember.t.tableName}" '
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUser '
'AND "${CrdtScopeMember.t.scopeId.columnName}" IN ( '
'SELECT "${CrdtScope.t.id.columnName}" '
'FROM "${CrdtScope.t.tableName}" '
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope '
')',
transaction: transaction,
);
}
/// 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 encodedScope = ValueEncoder.instance.convert(scope);
final result = await _session.db.unsafeQuery(
'SELECT m."${CrdtScopeMember.t.userUuid.columnName}", '
'm."${CrdtScopeMember.t.role.columnName}" '
'FROM "${CrdtScopeMember.t.tableName}" m '
'JOIN "${CrdtScope.t.tableName}" s '
'ON s."${CrdtScope.t.id.columnName}" = '
'm."${CrdtScopeMember.t.scopeId.columnName}" '
'WHERE s."${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope',
transaction: transaction,
);
return {
for (final row in result)
Protocol().deserialize<UuidValue>(row[0]): _roleFromDatabase(row[1]),
};
}
Future<int> _insertScope(UuidValue scope, Transaction transaction) async {
final encodedScope = ValueEncoder.instance.convert(scope);
final result = await _session.db.unsafeQuery(
'INSERT INTO "${CrdtScope.t.tableName}" '
'("${CrdtScope.t.uuidScopeId.columnName}") '
'VALUES ($encodedScope) '
'RETURNING "${CrdtScope.t.id.columnName}"',
transaction: transaction,
);
return result.single[0] as int;
}
Future<void> _applyGrants(
int scopeId,
Map<UuidValue, CrdtScopeRole> grants,
Transaction transaction,
) async {
if (grants.isEmpty) return;
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
for (final grant in grants.entries) {
final encodedUser = ValueEncoder.instance.convert(grant.key);
final encodedRole = ValueEncoder.instance.convert(grant.value.toJson());
await _session.db.unsafeExecute(
'INSERT INTO "${CrdtScopeMember.t.tableName}" '
'("${CrdtScopeMember.t.scopeId.columnName}", '
'"${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.role.columnName}") '
'VALUES ($encodedScopeId, $encodedUser, $encodedRole) '
'ON CONFLICT ("${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.scopeId.columnName}") '
'DO UPDATE SET "${CrdtScopeMember.t.role.columnName}" = $encodedRole',
transaction: transaction,
);
}
}
Future<int?> _scopeIdForUuid(
UuidValue scope, {
required Transaction transaction,
}) async {
final encodedScope = ValueEncoder.instance.convert(scope);
final result = await _session.db.unsafeQuery(
'SELECT "${CrdtScope.t.id.columnName}" '
'FROM "${CrdtScope.t.tableName}" '
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope '
'LIMIT 1',
transaction: transaction,
);
return result.isEmpty ? null : result.single[0] as int;
}
}
CrdtScopeRole _roleFromDatabase(Object? value) {
if (value == null) {
throw StateError('crdt_scope_members.role must not be null.');
}
return CrdtScopeRole.fromJson(value as String);
}
/// 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

@ -1,6 +1,8 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
import 'crdt_scopes.dart';
/// The CRDT sync configured per [Serverpod] instance.
///
/// Keyed by the [Serverpod] instance so each pod owns its own [CrdtSync] (and
@ -49,14 +51,51 @@ extension CrdtSyncInitialize on Serverpod {
}
}
/// Extension to access the CRDT sync for [Session] from the [Serverpod] instance.
/// Session-bound CRDT services configured for a [Serverpod] instance.
///
/// This facade is ephemeral: each `Session.crdt` access creates a small wrapper
/// around the shared [CrdtSync] instance and the current [Session].
class CrdtSession {
/// Creates CRDT services bound to a session.
CrdtSession(this._session, this._sync);
final Session _session;
final CrdtSync _sync;
/// Returns the server-side scope management service.
CrdtScopes get scopes => CrdtScopes(_session);
/// Runs a CRDT sync session with this [CrdtSession]'s [Session] bound.
Stream<CrdtSyncStreamEvent> sync({
required UuidValue userId,
required Stream<CrdtSyncStreamEvent> inbound,
required CrdtSyncPeerMode mode,
bool once = false,
CrdtSyncOnMergeSuccess? 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 CrdtSessionExtension on Session {
/// Returns the CRDT sync configured for this session.
CrdtSync get crdt =>
_crdtSyncByServerpod[server.serverpod] ??
(throw StateError(
/// Returns the CRDT services configured for this session.
CrdtSession get crdt {
final sync = _crdtSyncByServerpod[server.serverpod];
if (sync == null) {
throw StateError(
'The CrdtSync has not been initialized for this Serverpod instance. '
'Call pod.initializeCrdtSync(...) during server startup to configure '
'the CRDT sync.',
));
);
}
return CrdtSession(this, sync);
}
}

View file

@ -17,7 +17,6 @@ class CrdtSyncEndpoint extends Endpoint {
bool once = false,
}) async* {
yield* session.crdt.sync(
session,
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
inbound: changes,
once: once,

View file

@ -0,0 +1,368 @@
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 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,
),
),
);
},
);
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);
},
);
});
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 _countRows(
session,
CrdtScope.t.tableName,
),
CrdtScopeMember.t.tableName: await _countRows(
session,
CrdtScopeMember.t.tableName,
),
};
}
Future<int> _countRows(Session session, String tableName) => session.db
.unsafeQuery('SELECT COUNT(*) FROM "$tableName"')
.then((result) => (result.single[0] as num).toInt());

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,