feat: Implement the scope management interface (#96)
This commit is contained in:
parent
467df68a69
commit
d89118a43a
10 changed files with 755 additions and 227 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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".';
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
@ -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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue