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

View file

@ -101,8 +101,8 @@ indexes:
checks — see *The client membership table*. checks — see *The client membership table*.
- The index **leads with `userUuid`** so the per-cycle `WHERE userUuid = ?` - The index **leads with `userUuid`** so the per-cycle `WHERE userUuid = ?`
resolution is index-covered. resolution is index-covered.
- Rows are managed by application code: invitations, acceptance, and role - Rows are managed through `session.crdt.scopes` (`CrdtScopes`). Invitations,
assignment are app domain, outside this package. acceptance, and authorization policy remain app domain.
- **Personal-scope membership is implicit.** By convention a user's personal - **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), 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 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 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 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 scope-node rows** until their first device syncs. Nodes and scope-node rows are
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); members are created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); membership
created by application code. Removing the `nodes` list would break checkpointing rows are managed through `session.crdt.scopes` from application endpoints.
and causal filtering — it is the chain topology, not the access list. Removing the `nodes` list would break checkpointing and causal filtering — it is
the chain topology, not the access list.
### The client membership table ### 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 filters over projected membership, and a local `crdt_scopes` row by itself
does not grant access. The members table also carries **roles**, which the does not grant access. The members table also carries **roles**, which the
client consults before local writes. 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 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 is announced as `readWrite` but remains implicit in storage. When the follower
*receives* an announcement, `projectFollowerMembership` reconciles the local *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: At the start of each sync cycle the server resolves the authoritative set:
``` ```
memberScopes(userUuid) = memberGrants(userUuid) =
{ personal scope = userUuid } { personal scope = userUuid, role = readWrite }
∪ { m.scopeUuid : crdt_scope_members where userUuid = <auth user> } ∪ { (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 This grant set bounds everything the cycle may sync and carries the role used by
only learns of additions through what the server announces. 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 ## 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 this peer's CRDT node for the whole session. The scope list is dynamic and
exchanged per cycle. exchanged per cycle.
- `CrdtSyncScopeSet { scopes: List<CrdtScopeGrant> }` (each grant a - `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 `crdt_scope_members`, roles included). Followers send an empty set because the
authoritative peer never widens access from follower-reported state. In `once` authoritative peer never widens access from follower-reported state. In `once`
it is exchanged exactly once. In `continuous` a peer re-announces it 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 The correct operation is neither: **the server dictates its set; the client
conforms.** Each cycle: conforms.** Each cycle:
- **Server**: `orderedScopes = sort(memberScopes(userUuid))`, computed from - **Server**: `orderedScopes = sort(memberGrants(userUuid).uuidScopeId)`,
`crdt_scope_members`. It never reads the client's `ScopeSet` for authority computed from `crdt_scope_members` plus the implicit personal grant. It never
(constraint 4). reads the client's `ScopeSet` for authority (constraint 4).
- **Client**: takes the server's `ScopeSet` as the set to cycle — - **Client**: takes the server's `ScopeSet` as the set to cycle —
`getOrCreate` (`CrdtScopeManager`) any scope it lacks, ignore any local scope `getOrCreate` (`CrdtScopeManager`) any scope it lacks, ignore any local scope
the server omitted for this cycle — then `orderedScopes = sort(peer scopeIds)`. 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 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 per-role enumeration callback left to inject. Security does not rest on the
enum — a malicious client passing `authoritative` only fails to adopt the enum — a malicious client passing `authoritative` only fails to adopt the
peer's set; writes are still gated server-side by `CrdtScopeMembership.isMember` peer's set; writes are still gated server-side by
against the authoritative table. `CrdtScopeMembership.roleOf(...).canWrite` against the authoritative table.
In `once`, the client adopts straight from the cycle's `ScopeSet` *before* it 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`, 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 `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. 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 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 application hook if per-cycle querying becomes hot, at the cost of slower
propagation of access changes. 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 — - Without `scopeId`, the scope resolves to the user's personal scope —
`userId` itself by convention — which is exactly today's behavior. `userId` itself by convention — which is exactly today's behavior.
- With `scopeId`, the package asserts membership before acting by calling - With `scopeId`, the package resolves the member role with
`CrdtScopeMembership.isMember` directly against the shared table — no injected `CrdtScopeMembership.roleOf` — no injected validator. On the server that table
validator. On the server that table is authoritative; on a persistent client is authoritative; on a persistent client it is the server-projected membership
it is the server-projected membership cache. After membership passes, cache. A missing role throws `CrdtScopeMembershipException`; anything other
shared-scope writes also resolve the member role; anything other than than `readWrite` throws `CrdtScopeRoleException` before the transaction starts.
`readWrite` throws `CrdtScopeRoleException` before the transaction starts.
- A transaction acts in **exactly one** scope (constraint 5). The write path — - A transaction acts in **exactly one** scope (constraint 5). The write path —
stamp-or-assert, the scoped `WHERE`, `scopeId` immutability — is unchanged stamp-or-assert, the scoped `WHERE`, `scopeId` immutability — is unchanged
beyond which scope is resolved. beyond which scope is resolved.
@ -485,17 +489,19 @@ to run with `--concurrency=1`.
- **Phase 1 — membership foundation.** Implemented `crdt_scope_members` - **Phase 1 — membership foundation.** Implemented `crdt_scope_members`
(`database: all`, unsynced) and its migration. Added the shared (`database: all`, unsynced) and its migration. Added the shared
`CrdtScopeMembership` helpers (`memberScopes` / `isMember`). No behavior change `CrdtScopeMembership` helpers (`memberGrants`, `memberScopes`, `roleOf`,
yet: a personal-scope-only set reproduces today's single-scope sync exactly. `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 - **Phase 2 — sequential multi-scope sync (the requirement).** Implemented
protocol model protocol model
changes (`Connect` with session `localNodeId`, new per-cycle changes (`Connect` with session `localNodeId`, new per-cycle
`CrdtSyncScopeSet`, per-scope `SinceHlc`, `MergeChunk.uuidScopeId`) plus `CrdtSyncScopeSet`, per-scope `SinceHlc`, `MergeChunk.uuidScopeId`) plus
generate/migrate. Outer cycle loop in `CrdtSync.sync` with per-cycle generate/migrate. Outer cycle loop in `CrdtSync.sync` with per-cycle
`ScopeSet` exchange, per-scope handshake-on-first-visit, in-memory per-scope `ScopeSet` exchange with role-carrying `CrdtScopeGrant`s, per-scope
checkpoints, and the once/continuous cadence. Reconciliation via a 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 `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 (adopts the server's set). Scope-aware `onMergeSuccess`. A personal-scope-only
device behaves identically to today. device behaves identically to today.
- **Phase 3 — transaction API.** Implemented - **Phase 3 — transaction API.** Implemented
@ -524,6 +530,11 @@ to run with `--concurrency=1`.
`unauthorizedWrite` and fails before merge, client-side `unauthorizedWrite` and fails before merge, client-side
`CrdtScopeRoleException` early rejection, and follower outbound skipping for `CrdtScopeRoleException` early rejection, and follower outbound skipping for
non-writable shared scopes while reads remain membership-wide. 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 ## Test plan
@ -550,7 +561,7 @@ tested once, not per combination.
mutation, `readWrite` syncs normally, a follower skips outbound pending changes mutation, `readWrite` syncs normally, a follower skips outbound pending changes
for non-writable scopes while still receiving inbound changes, and a stale or for non-writable scopes while still receiving inbound changes, and a stale or
hostile client write is rejected and recorded by the server as 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. `readWrite` grant.
- **Access changes mid-session.** During a continuous session, a grant added to - **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 `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 resolves through `CrdtScopeMembership`, so the only residual variation is
cycle direction — a closed enum, not a callback. The enum is not the security cycle direction — a closed enum, not a callback. The enum is not the security
boundary; server-side boundary; server-side
`CrdtScopeMembership.isMember` gates every applied write regardless of the `CrdtScopeMembership.roleOf(...).canWrite` gates every applied write
client's declared mode. regardless of the client's declared mode.
- **Scope set is dynamic, not pinned at connect:** the authoritative peer - **Scope set is dynamic, not pinned at connect:** the authoritative peer
re-resolves its set every cycle and re-announces a `ScopeSet` whenever it re-resolves its set every cycle and re-announces a `ScopeSet` whenever it
changed, so grants and revokes take effect mid-session without reconnecting. 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. has no node until first sync.
- **Client membership table:** `database: all` puts the table on the client, - **Client membership table:** `database: all` puts the table on the client,
populated as a read-only projection. The `CrdtSyncScopeSet` carries 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 the local table from the announcement (upsert granted, delete revoked; only
when the set changed). The projected members table drives shared-scope when the set changed). The projected members table drives shared-scope
membership gating and roles for UI and offline role-gated writes; local membership gating and roles for UI and offline role-gated writes; local
@ -606,11 +617,11 @@ tested once, not per combination.
anyway. anyway.
- **Membership table is `database: all` but unsynced:** the schema exists on - **Membership table is `database: all` but unsynced:** the schema exists on
every node, yet it is server-authoritative app state, never CRDT-replicated. every node, yet it is server-authoritative app state, never CRDT-replicated.
- **Idle chatter accepted for the initial implementation:** an idle continuous - **Idle chatter removed:** continuous sync has one combined data loop per
cycle does one empty scope-turn per accessible scope rather than one per cycle. When neither peer has grants, handshakes, or changes to send, it emits
session. It is proportional to scope count and harmless to start; skipping no `ScopeSet`, `SinceHlc`, `MergeChunk`, or `EndOfBatch`; `collectNextBatch`
scopes with no local pending changes and no expected inbound is an optional resolves the idle cycle from the timeout once per session cycle, not once per
later optimization, not a launch blocker. scope.
- **Initial-sync ordering is not a current concern:** a device joining many - **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 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 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 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
export 'src/business/crdt_scopes.dart';
export 'src/business/crdt_sync.dart'; export 'src/business/crdt_sync.dart';
export 'src/endpoints/crdt_sync_endpoint.dart'; export 'src/endpoints/crdt_sync_endpoint.dart';
export 'src/generated/endpoints.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/serverpod.dart';
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'; import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
import 'crdt_scopes.dart';
/// The CRDT sync configured per [Serverpod] instance. /// The CRDT sync configured per [Serverpod] instance.
/// ///
/// Keyed by the [Serverpod] instance so each pod owns its own [CrdtSync] (and /// 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 { extension CrdtSessionExtension on Session {
/// Returns the CRDT sync configured for this session. /// Returns the CRDT services configured for this session.
CrdtSync get crdt => CrdtSession get crdt {
_crdtSyncByServerpod[server.serverpod] ?? final sync = _crdtSyncByServerpod[server.serverpod];
(throw StateError( if (sync == null) {
throw StateError(
'The CrdtSync has not been initialized for this Serverpod instance. ' 'The CrdtSync has not been initialized for this Serverpod instance. '
'Call pod.initializeCrdtSync(...) during server startup to configure ' 'Call pod.initializeCrdtSync(...) during server startup to configure '
'the CRDT sync.', 'the CRDT sync.',
)); );
}
return CrdtSession(this, sync);
}
} }

View file

@ -17,7 +17,6 @@ class CrdtSyncEndpoint extends Endpoint {
bool once = false, bool once = false,
}) async* { }) async* {
yield* session.crdt.sync( yield* session.crdt.sync(
session,
userId: UuidValue.withValidation(session.authenticated!.userIdentifier), userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
inbound: changes, inbound: changes,
once: once, 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( withServerpod(
'Given a database-backed CRDT scope sync session,', 'Given a database-backed CRDT scope sync session,',
(sessionBuilder, _) { (sessionBuilder, _) {
sessionBuilder.build().serverpod.initializeCrdtSync(syncTables: []);
late Session session; late Session session;
setUp(() async { setUp(() async {
session = sessionBuilder.build(); session = sessionBuilder.build();
@ -20,13 +22,7 @@ void main() {
'then only the authoritative session announces grants.', 'then only the authoritative session announces grants.',
() async { () async {
final userUuid = _uuid(30); final userUuid = _uuid(30);
final sharedScopeUuid = _uuid(20); final sharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
);
final authoritative = CrdtScopeSyncSession( final authoritative = CrdtScopeSyncSession(
session, session,
@ -73,14 +69,8 @@ void main() {
'then it keeps cycling only its locally resolved scopes.', 'then it keeps cycling only its locally resolved scopes.',
() async { () async {
final userUuid = _uuid(31); final userUuid = _uuid(31);
final localSharedScopeUuid = _uuid(21);
final peerOnlyScopeUuid = _uuid(11); final peerOnlyScopeUuid = _uuid(11);
final localSharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
await _upsertScopeMembership(
session,
userUuid: userUuid,
scopeUuid: localSharedScopeUuid,
);
final authoritative = CrdtScopeSyncSession( final authoritative = CrdtScopeSyncSession(
session, session,
@ -104,7 +94,6 @@ void main() {
'then adoption keeps the announced scope set until the next reconcile.', 'then adoption keeps the announced scope set until the next reconcile.',
() async { () async {
final userUuid = _uuid(32); final userUuid = _uuid(32);
final laterSharedScopeUuid = _uuid(22);
final authoritative = CrdtScopeSyncSession( final authoritative = CrdtScopeSyncSession(
session, session,
@ -116,11 +105,7 @@ void main() {
await authoritative.reconcile(); await authoritative.reconcile();
authoritative.markAnnounced(); authoritative.markAnnounced();
await _upsertScopeMembership( final laterSharedScopeUuid = await session.crdt.scopes.createFor(userUuid);
session,
userUuid: userUuid,
scopeUuid: laterSharedScopeUuid,
);
await authoritative.adoptPeerGrants(const []); await authoritative.adoptPeerGrants(const []);
@ -143,10 +128,17 @@ void main() {
final announcedScopeUuid = _uuid(23); final announcedScopeUuid = _uuid(23);
final staleScopeUuid = _uuid(13); final staleScopeUuid = _uuid(13);
await _upsertScopeMembership( final scope = await CrdtScopeManager(session).getOrCreate(staleScopeUuid);
await CrdtScopeMember.db.upsertRow(
session, session,
userUuid: userUuid, CrdtScopeMember(
scopeUuid: staleScopeUuid, scopeId: scope.id!,
userUuid: userUuid,
role: CrdtScopeRole.readWrite,
),
conflictColumns: (t) => [t.scopeId, t.userUuid],
updateColumns: (t) => [t.role],
); );
final follower = CrdtScopeSyncSession( final follower = CrdtScopeSyncSession(
@ -317,7 +309,6 @@ void main() {
'then the tracked checkpoint moves to the change HLC.', 'then the tracked checkpoint moves to the change HLC.',
() async { () async {
final userUuid = _uuid(37); final userUuid = _uuid(37);
final scopeUuid = _uuid(27);
final localNodeUuid = _uuid(47); final localNodeUuid = _uuid(47);
final remoteNodeUuid = _uuid(107); final remoteNodeUuid = _uuid(107);
@ -328,11 +319,7 @@ void main() {
peerNodeId: _uuid(98), peerNodeId: _uuid(98),
); );
await _upsertScopeMembership( final scopeUuid = await session.crdt.scopes.createFor(userUuid);
session,
userUuid: userUuid,
scopeUuid: scopeUuid,
);
await authoritative.reconcile(); await authoritative.reconcile();
authoritative.recordPeerHandshake( authoritative.recordPeerHandshake(
@ -376,19 +363,3 @@ CrdtSyncSinceHlc _since(UuidValue scopeUuid, Hlc checkpoint) =>
Hlc _hlc(UuidValue nodeUuid, {required int minute}) => Hlc _hlc(UuidValue nodeUuid, {required int minute}) =>
Hlc(DateTime.utc(2026, 7, 2, 12, minute), 0, nodeUuid); 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 final serverSubscription = rawServerSession.crdt
.sync( .sync(
serverSession,
userId: testCrdtUserId, userId: testCrdtUserId,
inbound: clientToServer.stream, inbound: clientToServer.stream,
once: false, once: false,
@ -214,12 +213,7 @@ void main() {
late UuidValue sharedScopeId; late UuidValue sharedScopeId;
setUp(() async { setUp(() async {
sharedScopeId = const Uuid().v7obj(); sharedScopeId = await rawServerSession.crdt.scopes.createFor(testCrdtUserId);
await _upsertScopeMembership(
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: sharedScopeId,
);
}); });
group('and personal and shared scope rows exist on the server,', () { group('and personal and shared scope rows exist on the server,', () {
@ -313,11 +307,8 @@ void main() {
final syncSession = testClient.crdt.syncContinuously(clientSession); final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel); addTearDown(syncSession.cancel);
final laterScopeId = const Uuid().v7obj(); final laterScopeId = await rawServerSession.crdt.scopes.createFor(
await _upsertScopeMembership( testCrdtUserId,
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: laterScopeId,
); );
final sharedPerson = await serverSession.db.transactionForUser( final sharedPerson = await serverSession.db.transactionForUser(
@ -363,10 +354,9 @@ void main() {
null; null;
}); });
await _revokeScopeMembership( await rawServerSession.crdt.scopes.revoke(
rawServerSession, scope: sharedScopeId,
userUuid: testCrdtUserId, user: testCrdtUserId,
scopeUuid: sharedScopeId,
); );
// The revoked scope's rows disappear from membership-wide reads… // The revoked scope's rows disappear from membership-wide reads…
@ -466,12 +456,8 @@ void main() {
late UuidValue serverPersonId; late UuidValue serverPersonId;
setUp(() async { setUp(() async {
readOnlyScopeId = const Uuid().v7obj(); readOnlyScopeId = await rawServerSession.crdt.scopes.create(
await _upsertScopeMembership( grants: {testCrdtUserId: CrdtScopeRole.readOnly},
rawServerSession,
userUuid: testCrdtUserId,
scopeUuid: readOnlyScopeId,
role: CrdtScopeRole.readOnly,
); );
final serverPerson = await serverSession.db.transactionForUser( final serverPerson = await serverSession.db.transactionForUser(
readOnlyScopeId, readOnlyScopeId,
@ -570,10 +556,9 @@ void main() {
); );
pendingPersonId = pendingPerson.id!; pendingPersonId = pendingPerson.id!;
await _upsertScopeMembership( await rawServerSession.crdt.scopes.grant(
rawServerSession, scope: sharedScopeId,
userUuid: testCrdtUserId, user: testCrdtUserId,
scopeUuid: sharedScopeId,
role: CrdtScopeRole.readOnly, role: CrdtScopeRole.readOnly,
); );
final inboundPerson = await serverSession.db.transactionForUser( final inboundPerson = await serverSession.db.transactionForUser(
@ -648,10 +633,9 @@ void main() {
); );
revokedPersonId = revokedPerson.id!; revokedPersonId = revokedPerson.id!;
await testClient.crdt.syncOnce(clientSession); await testClient.crdt.syncOnce(clientSession);
await _revokeScopeMembership( await rawServerSession.crdt.scopes.revoke(
rawServerSession, scope: sharedScopeId,
userUuid: testCrdtUserId, user: testCrdtUserId,
scopeUuid: sharedScopeId,
); );
}); });
@ -727,10 +711,7 @@ void main() {
late UuidValue ungrantedScopeId; late UuidValue ungrantedScopeId;
setUp(() async { setUp(() async {
ungrantedScopeId = const Uuid().v7obj(); ungrantedScopeId = await rawServerSession.crdt.scopes.create();
await CrdtScopeManager(rawServerSession).getOrCreate(
ungrantedScopeId,
);
}); });
test('then it is rejected before writing.', () async { test('then it is rejected before writing.', () async {
@ -1300,16 +1281,11 @@ void main() {
); );
await serverSession.db.initialize(); await serverSession.db.initialize();
sharedScopeId = const Uuid().v7obj(); sharedScopeId = await rawServerSession.crdt.scopes.create(
await _upsertScopeMembership( grants: {
rawServerSession, testCrdtUserId: CrdtScopeRole.readWrite,
userUuid: testCrdtUserId, secondUserId: CrdtScopeRole.readWrite,
scopeUuid: sharedScopeId, },
);
await _upsertScopeMembership(
rawServerSession,
userUuid: secondUserId,
scopeUuid: sharedScopeId,
); );
}); });
@ -1595,39 +1571,6 @@ Future<void> _waitUntil(
throw TimeoutException('Condition was not met within $timeout.'); 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 { class TestClientAuthKeyProvider implements ClientAuthKeyProvider {
TestClientAuthKeyProvider([this.userKey = 'token']); TestClientAuthKeyProvider([this.userKey = 'token']);

View file

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