feat: Implement shared scopes with role enforcement (#90)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-03 01:25:15 -03:00 • committed by GitHub
commit 8acfa7f269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 4868 additions and 485 deletions

View file

@ -94,6 +94,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
CrdtMergeInsert insertChangeFor(TableRow<UuidValue?> row) { CrdtMergeInsert insertChangeFor(TableRow<UuidValue?> row) {
final hlc = _nextRemoteHlc(); final hlc = _nextRemoteHlc();
return CrdtMergeInsert( return CrdtMergeInsert(
uuidScopeId: _userId,
tableName: row.table.tableName, tableName: row.table.tableName,
uuidRowId: row.id!, uuidRowId: row.id!,
uuidNodeId: hlc.nodeId, uuidNodeId: hlc.nodeId,
@ -110,6 +111,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
) { ) {
final hlc = _nextRemoteHlc(); final hlc = _nextRemoteHlc();
return CrdtMergeUpdate( return CrdtMergeUpdate(
uuidScopeId: _userId,
tableName: row.table.tableName, tableName: row.table.tableName,
uuidRowId: row.id!, uuidRowId: row.id!,
uuidNodeId: hlc.nodeId, uuidNodeId: hlc.nodeId,
@ -123,6 +125,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) { CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) {
final hlc = _nextRemoteHlc(); final hlc = _nextRemoteHlc();
return CrdtMergeDelete( return CrdtMergeDelete(
uuidScopeId: _userId,
tableName: row.table.tableName, tableName: row.table.tableName,
uuidRowId: row.id!, uuidRowId: row.id!,
uuidNodeId: hlc.nodeId, uuidNodeId: hlc.nodeId,

View file

@ -107,12 +107,14 @@ class QueryCountingDatabase implements Database {
List<T> rows, { List<T> rows, {
Transaction? transaction, Transaction? transaction,
bool ignoreConflicts = false, bool ignoreConflicts = false,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.insert<T>( return _delegate.insert<T>(
rows, rows,
transaction: transaction, transaction: transaction,
ignoreConflicts: ignoreConflicts, ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
); );
} }
@ -132,6 +134,7 @@ class QueryCountingDatabase implements Database {
List<Column>? updateColumns, List<Column>? updateColumns,
Expression? updateWhere, Expression? updateWhere,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.upsert<T>( return _delegate.upsert<T>(
@ -140,6 +143,7 @@ class QueryCountingDatabase implements Database {
updateColumns: updateColumns, updateColumns: updateColumns,
updateWhere: updateWhere, updateWhere: updateWhere,
transaction: transaction, transaction: transaction,
noReturn: noReturn,
); );
} }
@ -166,9 +170,15 @@ class QueryCountingDatabase implements Database {
List<T> rows, { List<T> rows, {
List<Column>? columns, List<Column>? columns,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.update<T>(rows, columns: columns, transaction: transaction); return _delegate.update<T>(
rows,
columns: columns,
transaction: transaction,
noReturn: noReturn,
);
} }
@override @override
@ -205,6 +215,7 @@ class QueryCountingDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.updateWhere<T>( return _delegate.updateWhere<T>(
@ -218,6 +229,7 @@ class QueryCountingDatabase implements Database {
// ignore: deprecated_member_use // ignore: deprecated_member_use
orderDescending: orderDescending, orderDescending: orderDescending,
transaction: transaction, transaction: transaction,
noReturn: noReturn,
); );
} }
@ -228,6 +240,7 @@ class QueryCountingDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.delete<T>( return _delegate.delete<T>(
@ -238,6 +251,7 @@ class QueryCountingDatabase implements Database {
// ignore: deprecated_member_use // ignore: deprecated_member_use
orderDescending: orderDescending, orderDescending: orderDescending,
transaction: transaction, transaction: transaction,
noReturn: noReturn,
); );
} }
@ -257,6 +271,7 @@ class QueryCountingDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
queryCount++; queryCount++;
return _delegate.deleteWhere<T>( return _delegate.deleteWhere<T>(
@ -267,6 +282,7 @@ class QueryCountingDatabase implements Database {
// ignore: deprecated_member_use // ignore: deprecated_member_use
orderDescending: orderDescending, orderDescending: orderDescending,
transaction: transaction, transaction: transaction,
noReturn: noReturn,
); );
} }

View file

@ -142,26 +142,37 @@ WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseU
INSERT INTO "crdt_scopes" ("currentNodeId") SELECT NULL FROM n INSERT INTO "crdt_scopes" ("currentNodeId") SELECT NULL FROM n
'''); ''');
await db.unsafeExecute(''' await db.unsafeExecute('''
INSERT INTO "crdt_nodes" ("scopeId") WITH local_node(id) AS (
SELECT u."id" FROM "crdt_scopes" u SELECT "currentNodeId" FROM "crdt_scopes" WHERE "id" = $benchUserId
WHERE NOT EXISTS (SELECT 1 FROM "crdt_nodes" n WHERE n."scopeId" = u."id") )
INSERT INTO "crdt_scope_nodes" ("scopeId", "nodeId")
SELECT u."id", local_node.id
FROM "crdt_scopes" u
CROSS JOIN local_node
WHERE local_node.id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "crdt_scope_nodes" sn
WHERE sn."scopeId" = u."id" AND sn."nodeId" = local_node.id
)
'''); ''');
// Noise rows for every noise user, spread across all synced tables, with // Noise rows for every noise user, spread across all synced tables, with
// 10% tombstoned. Random UUIDs keep them unrelated to the domain rows. // 10% tombstoned. Random UUIDs keep them unrelated to the domain rows.
await db.unsafeExecute(''' await db.unsafeExecute('''
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1), WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1),
users(rowidx, uid) AS ( users(rowidx, uid, nodeId) AS (
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" SELECT ROW_NUMBER() OVER (ORDER BY s."id") - 1, s."id", sn."nodeId"
FROM "crdt_scopes" WHERE "id" <> $benchUserId FROM "crdt_scopes" s
JOIN "crdt_scope_nodes" sn ON sn."scopeId" = s."id"
WHERE s."id" <> $benchUserId
), ),
tbls(tblidx, tid) AS ( tbls(tblidx, tid) AS (
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables" SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
) )
INSERT INTO "crdt_data_rows" INSERT INTO "crdt_data_rows"
("hlcDatetime", "hlcCounter", "scopeId", "tblId", "uuidRowId", "nodeId", "visibility") ("hlcDatetime", "hlcCounter", "scopeId", "tblId", "uuidRowId", "nodeId", "visibility")
SELECT 0, 0, u.uid, t.tid, randomblob(16), SELECT 0, 0, u.uid, t.tid, randomblob(16), u.nodeId,
(SELECT nd."id" FROM "crdt_nodes" nd WHERE nd."scopeId" = u.uid LIMIT 1),
CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END
FROM n FROM n
JOIN users u ON u.rowidx = n.i % $noiseUsers JOIN users u ON u.rowidx = n.i % $noiseUsers

View file

@ -0,0 +1,104 @@
# Outbound collection consistency (follow-up)
## Summary
Outbound change collection reads CRDT metadata and the domain data it describes
in **separate, non-atomic queries**. Under concurrent domain writes — real on
the server, where co-members of a shared scope write while a sync runs — this
race can make an otherwise-healthy stream sync **fail and record a durable
integrity violation** for a condition that the very next round would resolve on
its own. Failing the session for a transient, self-correcting race is the design
weakness to fix; no data is ever applied incorrectly, so this is a
robustness/liveness problem, not a corruption one.
This is **not yet implemented** — it is tracked design debt. The single-pass
multi-scope collection (`collectPendingChanges`) did not introduce the race
but widened its window, which is what surfaced it.
## The mechanism
Collection is *snapshot-then-fetch*, and lock-free by design (the per-scope
`transactionForUser` lock guards the inbound merge, not outbound collection):
1. `_streamInserts` / `_streamUpdates` / `_streamDeletes` read the CRDT metadata
(`CrdtDataRow` / `CrdtDataField` / tombstones) as a query result — a snapshot
at some point `t0`.
2. The domain row/column values are then read in **later, separate** queries
(`_fetchDomainRow`, `_fetchOwnedColumnValue`, `_readDomainRowOwner`).
Between (1) and (2), and across the three independently-snapshotted queries, the
domain can change underneath the collection.
## Failure modes
1. **Concurrent delete → false-positive `missingDomainRow`.** A row whose CRDT
insert metadata was snapshotted, but whose domain row is deleted before the
domain read, makes `_fetchDomainRow` miss. Today that records a durable
`missingDomainRow` violation and **fails the session** — even though the next
round would simply collect the tombstone and converge.
2. **FK target inserted after the insert snapshot → dangling reference.** The
insert snapshot is taken at `t0`, but FK *column values* are read at fetch
time (later). If a referencing row's FK points to row `B` that was inserted
**after** `t0`, then `B` is not in the collected insert batch, yet the
reference to it is shipped. The same applies to an FK **update** collected in
the updates pass whose target was inserted after the inserts pass: the edge is
sent without the node. The peer then either churns through FK projection
(`SET NULL` / `SET DEFAULT` / `RESTRICT` with the durable `attemptedValue`)
until a later round delivers `B`, or trips an integrity violation and fails.
Either way, a benign ordering skew degrades the stream.
3. **Value ahead of its HLC (self-correcting, listed for completeness).** If a
column is bumped from HLC `h` to `h2` between the metadata read and the domain
read, the batch ships `(value@h2, HLC=h)`. This converges: our own
`CrdtDataField` is now at `h2`, our send checkpoint only advanced to `h`, so
the next round re-ships `(value@h2, HLC=h2)` and every replica ratifies it.
No permanent divergence — but it shares the same root cause as (1) and (2).
## Why "fail the session" is the wrong response
All three are **transient and self-correcting**: the next collection round picks
up the tombstone, the late insert, or the higher HLC, and replicas converge.
Tearing down the stream — and persisting an integrity-violation record that reads
like corruption — for a race that resolves itself next round converts an
ordering skew into an outage plus a misleading audit trail. A durable
`crdt_sync_integrity_violations` entry should mean a *genuine* invariant breach
(an ownership collision, real corruption), not "two reads happened to straddle a
concurrent commit."
## Directions
- **Read a consistent snapshot (primary).** Run the whole collection — the three
metadata queries *and* the domain reads — inside one read-only,
snapshot-consistent transaction (repeatable-read). On Postgres this is MVCC:
**a read snapshot, not a write lock**, so it adds no cross-scope write
contention (and is therefore the right answer to "do we have to lock all
scopes?" — no, we take a snapshot, not a lock). A single snapshot closes the
race outright:
- The delete in (1) is invisible if it commits after the snapshot, and fully
visible (metadata + domain) if before — never half-seen.
- It makes each batch **referentially closed by construction**: the domain's
own FK constraint guarantees that wherever `A.fk = B` is visible in the
snapshot, `B` is too, so `B` is collected alongside `A`. (2) cannot happen.
- **Tolerate, don't fail (fallback / defense in depth).** Where a consistent
snapshot is impractical, distinguish a *transient* missing row / missing
referent (present in CRDT metadata, not yet consistently readable, or arriving
next round) from a *true* violation, and **defer the racy change to the next
round** instead of failing the session and recording a durable violation. The
peer's FK projection already tolerates a transiently-missing referent — the
collector should match that posture rather than failing on it.
- **Shrink the window.** Independent of the above, replace row-by-row domain
reads with set-based queries (`WHERE id IN (…)`) per table, narrowing the gap
between the metadata snapshot and the domain reads. Cheap, and reduces exposure
even before a full snapshot lands.
## Priority
Correctness is preserved today (convergence + fail-safe: no wrong data is ever
applied), so this does not block current use. But because it can fail a healthy
long-lived continuous sync under concurrent shared-scope writes — the exact
workload shared scopes enable — it should be addressed before such deployments
are relied upon. The consistent-snapshot approach is the smallest change that
removes all three failure modes at once.

View file

@ -1,7 +1,6 @@
# Row ownership and scope isolation # Row ownership and scope isolation
Status: implemented. Status: implemented.
The *Future: shared scopes* section is direction, not commitment.
## Summary ## Summary
@ -20,9 +19,8 @@ This document makes ownership explicit. The core decisions:
> never materialize or sync the same `(table, uuidRowId)`. Cross-scope UUID > never materialize or sync the same `(table, uuidRowId)`. Cross-scope UUID
> reuse is a terminal ownership collision, not valid multi-tenant state. Sync > reuse is a terminal ownership collision, not valid multi-tenant state. Sync
> fails on that collision, records a durable violation, and does not advance > fails on that collision, records a durable violation, and does not advance
> the sender past the rejected change. Sharing, when it arrives, will be > the sender past the rejected change. Sharing is modeled as shared scopes that
> modeled as shared scopes that multiple identities subscribe to — never as > multiple identities subscribe to — never as two scopes aliasing one row.
> two scopes aliasing one row.
> **Every synced table declares `scopeId`, identically on server and > **Every synced table declares `scopeId`, identically on server and
> client.** The column is a hard requirement, never `scope=serverOnly`, so > client.** The column is a hard requirement, never `scope=serverOnly`, so
@ -129,9 +127,9 @@ enforces that the answer is unique, and the domain storage assumes it is.
"Leave it up to users" is rejected explicitly: symptom 2 means the storage "Leave it up to users" is rejected explicitly: symptom 2 means the storage
model cannot represent what multi-scope tracking promises, so allowing it is model cannot represent what multi-scope tracking promises, so allowing it is
not granting flexibility — it is permitting silent cross-tenant data not granting flexibility — it is permitting silent cross-tenant data
corruption. Applications that need shared data need shared *scopes* (see corruption. Applications that need shared data use shared *scopes* (see
*Future: shared scopes*), which preserve the one-chain-per-row property that `shared-scopes.md`), which preserve the one-chain-per-row property that makes
makes LWW merging coherent. LWW merging coherent.
## The `scopeId` column ## The `scopeId` column
@ -475,9 +473,7 @@ actually key — scopes:
- **Internals and storage** adopt scope naming in one sweep: `crdt_users` → - **Internals and storage** adopt scope naming in one sweep: `crdt_users` →
`crdt_scopes` (`CrdtUser` → `CrdtScope`, `uuidUserId` → `uuidScopeId`), `crdt_scopes` (`CrdtUser` → `CrdtScope`, `uuidUserId` → `uuidScopeId`),
`crdt_data_rows.userId` → `scopeId` (matching the domain column), `crdt_data_rows.userId` → `scopeId` (matching the domain column),
`userForTransaction`, the effective-user helpers, and the HLC manager `userForTransaction`, the effective-user helpers, and the HLC manager keys.
keys. Doing this now is what keeps the *Future: shared scopes* section a
pure addition instead of a rename project.
- **Unscoped reads** remain supported as admin reads. - **Unscoped reads** remain supported as admin reads.
Consequences for application code, stated as contract: Consequences for application code, stated as contract:
@ -560,7 +556,7 @@ tested **once**, not per combination:
`withServerpod` sync roundtrip proving the wiring end to end. These exist `withServerpod` sync roundtrip proving the wiring end to end. These exist
to prove deployment shapes compose, not to re-verify mechanisms. to prove deployment shapes compose, not to re-verify mechanisms.
## Future: shared scopes ## Shared scopes
`crdt_scopes` is the entity everything is keyed by: sync, locking, HLC `crdt_scopes` is the entity everything is keyed by: sync, locking, HLC
chains, and visibility. Sharing becomes an indirection in front of it, not a chains, and visibility. Sharing becomes an indirection in front of it, not a
@ -575,9 +571,9 @@ change to row ownership:
changes. changes.
This is the standard shape of sharing in local-first systems, and it is the This is the standard shape of sharing in local-first systems, and it is the
reason the column and concept are named `scope` rather than `user`. The rest reason the column and concept are named `scope` rather than `user`.
of this section sketches the direction in enough detail to keep today's Implementation details and current limits live in `shared-scopes.md`; the rest
decisions compatible with it; it is not committed design. of this section summarizes why the ownership rules remain compatible.
### Users authenticate, scopes act ### Users authenticate, scopes act
@ -641,15 +637,18 @@ filter and RLS become two enforcements of one predicate.
### Sync and clients ### Sync and clients
- A device syncs one chain per scope it holds: the sync session (or one - A device syncs one chain per scope it holds: each sync call enumerates the
session per scope) enumerates the authenticated user's member scopes, authenticated user's member scopes, verified server-side against
verified server-side against `crdt_scope_members` at the handshake; the `crdt_scope_members` at the start of every sync cycle; the membership table
membership table itself is not synced. itself is not synced. `CrdtScopeRole` is enforced as described in
`shared-scopes.md`: reads stay membership-wide, writes are role-gated, and the
server records `unauthorizedWrite` for rejected inbound changes.
- Every database is already sharing-capable schema-wise — the column is - Every database is already sharing-capable schema-wise — the column is
universal — so a sharing-enabled client simply holds several scopes' rows. universal — so a sharing-enabled client simply holds several scopes' rows.
One database file per scope remains a valid alternative layout. One database file per scope remains a valid alternative layout.
- Checkpoints stay per `(scope, node)`; a device is a node in each scope's - Checkpoints stay per `(scope, node)`: `crdt_nodes` stores the stable replica
chain it participates in. identity, while `crdt_scope_nodes` stores each scope's checkpoint for that
node.
### What sharing does not change ### What sharing does not change
@ -729,12 +728,15 @@ not relitigated by accident:
application with a pre-existing, semantically different `scopeId` column application with a pre-existing, semantically different `scopeId` column
on a synced table has no escape hatch other than renaming its column. on a synced table has no escape hatch other than renaming its column.
Acceptable for now; revisit if it bites real users. Acceptable for now; revisit if it bites real users.
3. **Membership revocation semantics (sharing).** When a user loses 3. **Membership revocation semantics (sharing).** When a user loses membership
membership of a scope, the server stops streaming it at the next of a scope, the server stops streaming it at the next sync cycle. The device
handshake, but the device still holds the scope's data and may hold may still store the scope's data and unsynced local changes, though
unsynced local changes for it. Purge-vs-keep policy, and whether revoked membership-wide reads no longer expose revoked rows. Purge-vs-keep policy,
unsynced changes are surfaced or dropped, need their own design. and whether revoked unsynced changes are surfaced or dropped, need their own
4. **Roles within a scope (sharing).** `crdt_scope_members.role` is sketched design.
but undefined: read-only members would need write rejection at merge time 4. **Roles within a scope (sharing).** Resolved in `shared-scopes.md`.
(another fail-and-record case), and the interaction with offline-written `crdt_scope_members.role` is enforced for writes through the closed
changes by a demoted member overlaps with question 3. `CrdtScopeRole` enum: the server rejects and records unauthorized inbound
writes, the client rejects local writes from projected `readOnly` roles, and
reads remain membership-wide. The remaining cleanup question for demoted or
revoked unsynced local changes is part of question 3.

View file

@ -0,0 +1,208 @@
# Scope management API
Status: proposal for a separate implementation issue. Builds on the shared-scope
membership model in `shared-scopes.md`; this document specifies the developer-
facing API for *managing* membership, which that document deliberately left as
"app domain".
## Summary
The shared-scope machinery is complete for **resolving and enforcing**
membership (`CrdtScopeMembership.memberScopes` / `memberGrants` / `roleOf` /
`isMember`, the sync-loop enforcement, membership-wide reads). It has no API for
**managing** membership — creating a shared scope, adding a member with a role,
changing a role, revoking. `shared-scopes.md` says so outright: *"Rows are
managed by application code: invitations, acceptance, and role assignment are app
domain, outside this package."*
Today a developer must replicate what `scope_membership_test.dart` does — two
non-atomic raw `insertRow` calls against the **server** package's generated
`CrdtScope` / `CrdtScopeMember`, leaking every internal invariant
(`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique index,
personal-scope-UUID == user-UUID). This proposal replaces that with a small,
honest server-side service:
```dart
final scopeId = await session.crdt.scopes.create(owner: creatorUuid);
await session.crdt.scopes.grant(scope: scopeId, user: bob, role: CrdtScopeRole.readWrite);
await session.crdt.scopes.revoke(scope: scopeId, user: bob);
```
## Design decisions
These were settled in discussion; the reasoning is recorded so the
implementation issue does not relitigate them.
### A service, not an endpoint (the `AuthUsers` model)
The load-bearing constraint is `shared-scopes.md` #4: *membership cannot be
forged; the server never widens access from client-reported state*. If the
package auto-generated a management **endpoint**, any logged-in client could
self-grant into any scope and break that.
So the package ships a **server-side business service** the developer calls from
their **own** endpoint, after applying their **own** authorization policy. This
is exactly Serverpod's `AuthUsers`: `AuthUsers.create(session, ...)` is a
capability, not a policy — it does not check "may the caller create a user".
The sync path enforces roles internally only because it is *directly reachable
by untrusted clients*; the management service is server-only (no endpoint), so
its only caller is the developer's own server code.
The package owns the **data invariants**; the app owns the **authorization
policy**. That is the same division that makes invitations/acceptance app domain.
### No authorization check inside the service
An earlier sketch gated writes with a `by:` (acting-user) parameter checked
against an admin role. This was dropped:
- **`by` is forgeable, so it is not enforcement.** It is a `UuidValue` the
caller supplies; the service cannot tell a real identity from a typed-in admin
UUID. It prevents a bug, not an attacker — and only if someone upstream binds
it to the true identity, which is the endpoint's job.
- The unforgeable identity is `session.authenticated` — the same source the sync
endpoint already trusts (`crdt_sync_endpoint.dart`). It lives at the endpoint,
which is where the app's policy lives anyway.
- The internal check is what forced a system/backend **bypass**; removing the
check dissolves the bypass.
The service therefore performs **no** internal authorization. Authorization is
100% the endpoint's job, where the authenticated caller is unforgeable:
```dart
class TeamEndpoint extends Endpoint {
Future<void> invite(Session s, UuidValue scope, UuidValue user, CrdtScopeRole role) async {
final caller = UuidValue.withValidation(s.authenticated!.userIdentifier); // unforgeable
if (!myApp.mayManage(caller, scope)) throw ...; // app policy
await s.crdt.scopes.grant(scope: scope, user: user, role: role);
}
}
```
### No `admin` role in the package
The discussion considered an `admin` tier in `CrdtScopeRole` so "any admin can
grant". It was rejected on the **enforce-vs-define** line: *the package should
own only what it enforces.*
- `readOnly` / `readWrite` are **enforced by the sync engine against untrusted
clients** (the server rejects an unauthorized stream write and records
`unauthorizedWrite`). This check cannot live in the app; it must be in the
package.
- `admin` is **never consulted by the engine** — nothing in the sync path,
merge, or read filter asks "is this user an admin". It would be a permission
the package *defines but never acts on*, and it overlaps with Serverpod's own
`Scope` mechanism (a second permission system the app must reconcile).
Everything uneasy traced back to `admin`: the forgeable `by`, the bypass, and a
"last-admin" orphan guard all evaporate once it leaves. `CrdtScopeRole` stays
`{ readOnly, readWrite }` — exactly what is already implemented — so this
proposal adds **no** enum churn.
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
app's `TeamMember { teamId, userId, isAdmin }` is itself a synced CRDT table, an
"am I an admin" UI works offline for free. The two membership layers stay
separate: **access-membership** (`crdt_scope_members`, server-authoritative,
package-enforced: who may sync/read/write) vs **domain-membership** (app data:
who is an admin, display name, joined-at). The app keeps them in step — adding a
domain member also calls `scopes.grant` — the same split Serverpod has between
`AuthUser` and an app's user profile.
## Proposed API
Server-side, hung off the existing `session.crdt` (`CrdtSync`) accessor as
`session.crdt.scopes`. A pure data layer: atomic, transaction-threaded, no
internal auth.
```dart
/// Creates a shared scope and returns its UUID.
///
/// [owner], if given, is granted [CrdtScopeRole.readWrite]. [grants] adds any
/// further members. Both are applied in one transaction with the scope insert.
/// With no owner and no grants the scope is created dormant — it exists but no
/// one can sync it until membership is granted. Throws [ArgumentError] if
/// [owner] also appears in [grants].
Future<UuidValue> create({
UuidValue? owner,
Map<UuidValue, CrdtScopeRole>? grants,
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,
});
/// Grants several members in one transaction; the bulk twin of [grant].
Future<void> grantAll(
UuidValue scope,
Map<UuidValue, CrdtScopeRole> grants, {
Transaction? transaction,
});
/// Removes [user]'s membership in [scope]. A no-op if not a member.
Future<void> revoke({
required UuidValue scope,
required UuidValue user,
Transaction? transaction,
});
/// The role [user] holds in [scope], or null if not a member. The read an app
/// wraps with its own admin policy before calling [grant] / [revoke].
Future<CrdtScopeRole?> roleOf({
required UuidValue user,
required UuidValue scope,
Transaction? transaction,
});
/// The members of [scope] and their roles.
Future<Map<UuidValue, CrdtScopeRole>> members(
UuidValue scope, {
Transaction? transaction,
});
```
Notes:
- **`owner` is nullable** — a scope with no owner (and no grants) is valid but
dormant. `create` skips the membership insert when `owner` is null.
- **`owner` and `grants` compose.** `owner` is ergonomic sugar for
`{owner: readWrite}`; `grants` adds the rest. If `owner` also appears in
`grants`, throw `ArgumentError` rather than silently pick a winner — a creator
listed twice with a possibly-contradictory role is a caller bug.
- **`create` and `grantAll` share one internal helper** so the create path and
the later bulk-grant path have a single implementation.
- **`create` returns a server-generated UUID** — the model already defaults
`uuidScopeId` to `random_v7`. An optional explicit `uuidScopeId` param
(app-chosen or idempotent create) is a compatible later addition; left out
until there is a real need.
- Membership writes are plain `crdt_scope_members` / `crdt_scopes` rows — **not**
CRDT-tracked, not part of the sync loop. Changes propagate to clients on the
next sync cycle through the existing `ScopeSet` re-announcement path.
## Client side
Unchanged. Management is server-only; the client never writes membership. A
client learns of new scopes and role changes purely through the sync `ScopeSet`
announcement and the follower projection already implemented in
`shared-scopes.md`. There is no client `create`/`grant`/`revoke`.
## Test plan
- **Create.** `create(owner: u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `create()` (no owner) yields a dormant scope no user
can sync; `create(owner: u, grants: {u: ...})` throws `ArgumentError`.
- **Grant/revoke.** `grant` inserts and upserts (role change); `revoke` removes
and is a no-op for a non-member; `grantAll` applies a map atomically.
- **Reads.** `roleOf` returns the stored role or null; `members` returns the
full map. Personal scope resolves to `readWrite` via the existing implicit
path.
- **Propagation.** After a server-side `grant`, a continuous follower session
adopts the new scope on the next cycle (covered by the existing access-change
suite); after `revoke`, it stops cycling it.
- **Transaction threading.** A `create` folded into an app domain transaction
rolls back atomically with the app write on failure.

638
docs/shared-scopes.md Normal file
View file

@ -0,0 +1,638 @@
# Shared scopes
Status: implemented. This records the shared-scope behavior built on top of
the row-ownership work in `row-ownership.md`. Revocation cleanup is still a
deferred follow-up; where the ownership summary and this document differ, this
one wins.
## Summary
A user is an authentication identity; a scope is the unit of replication and
ownership. Each user has a personal scope and may also belong to any number of
shared scopes. The load-bearing
requirement is:
> **A single `sync` call syncs every scope the authenticated user has access
> to.** One `syncOnce` cycles through all accessible scopes exactly once; a
> continuous session keeps cycling forever. There is no separate stream or
> separate call per scope.
The row-ownership work keys storage, locking, HLC chains, visibility, and
ownership by **scope**, not user, so sharing is an indirection in front of
`crdt_scopes`, not a re-architecture. The implemented pieces are a server-side
membership relation and a sync protocol that iterates scopes.
> **Merges serialize one scope at a time.** A merge runs in exactly one scope's
> transaction and lock, so scopes always serialize on the merge step regardless
> of framing. A cycle's outbound changes are instead collected in a single pass
> over the active scopes and carried in one combined batch, each change tagged
> with its `uuidScopeId`; the receiver regroups by scope and merges each group
> in its own transaction. The wire stays a near-verbatim repetition of today's
> frames, now scope-tagged.
> **The server is the authority on membership; the client follows.** The set of
> scopes a session syncs is computed server-side from `crdt_scope_members` and
> re-resolved at the start of every sync cycle, so access changes take effect
> mid-session. The server dictates the set; the client adopts scopes it
> announces and stops cycling the ones it omits. The reconciliation is
> directional — not a set operation toggled by a flag — and membership itself is
> never synced. Local data cleanup for omitted scopes is a separate revocation
> policy, not part of the sync loop.
> **Row ownership is unchanged.** Every row still has exactly one owning
> `scopeId`, `(table, uuidRowId)` stays globally identified, and every
> enforcement rule in `row-ownership.md` survives untouched. Sharing adds a
> membership layer in front of scopes; it does not touch row ownership.
## Goals and constraints
1. **One call, every scope.** The public client API (`syncOnce`,
`syncContinuously`) does not change shape: callers pass a session, never a
scope. A device with only a personal scope behaves exactly as today.
2. **Smallest protocol delta.** The exchanged frames are today's, plus a
per-cycle scope-set exchange and a scope tag on each frame. `collectNextBatch`
demultiplexes a cycle's combined batch, `collectPendingChanges` collects every
active scope in one pass, and the per-scope merge plus `recordSyncCheckpoint`
run once per inbound scope group.
3. **Chunked streaming.** Outbound changes are collected in a single pass over
the active scopes and streamed in `syncBatchSize` chunks, so the wire payload
stays bounded regardless of scope count. The collection step itself
materializes a cycle's pending rows across the active scopes (one
`scopeId IN (…)` query per change kind), so peak memory tracks a cycle's
pending rows rather than being held strictly one scope at a time. See
`outbound-collection-consistency.md`.
4. **Membership cannot be forged.** The server computes its own scope set from
`crdt_scope_members` (plus the implicit personal scope) and never widens it
from anything the client sends. A scope the user is not a member of is never
synced, even if the client names it.
5. **One scope per transaction.** Reads are membership-wide; writes stay pinned
to exactly one scope. This is deliberate honesty about CRDT semantics: two
scopes' chains replicate independently and a remote replica can never
observe a cross-scope write atomically.
6. **Roles are closed CRDT access roles.** The package stores and projects
non-null `CrdtScopeRole` values in scope grants and shared memberships:
`readWrite` allows CRDT writes and `readOnly` blocks them. The implicit
personal scope is announced as a `readWrite` grant, so single-tenant servers
and multi-account devices remain the same code path with different scope
counts.
## Membership
### The `crdt_scope_members` table
```yaml
class: CrdtScopeMember
table: crdt_scope_members
database: all
fields:
scope: CrdtScope?, relation(onDelete=Cascade)
userUuid: UuidValue
role: CrdtScopeRole
indexes:
crdt_scope_member_unique_idx:
fields: userUuid, scopeId
unique: true
```
- The table is **`database: all`** but **not** in `syncTables`. It exists on
every node so membership resolves with the same code on both ends, but it is
never CRDT-replicated by this package. The server is the authoritative writer;
a client copy (once populated) is a read-only cache for UI and offline role
checks — see *The client membership table*.
- The index **leads with `userUuid`** so the per-cycle `WHERE userUuid = ?`
resolution is index-covered.
- Rows are managed by application code: invitations, acceptance, and role
assignment are app domain, outside this package.
- **Personal-scope membership is implicit.** By convention a user's personal
scope has the user's own UUID (already true: the scope UUID keys the chain),
so no `crdt_scope_members` row is stored for it. Shared scopes are
`crdt_scopes` rows whose UUID is no user's id, with explicit membership rows.
- `role` is a required CRDT access enum. Shared memberships always store an
explicit role: `readWrite` allows writes, while `readOnly` denies
shared-scope writes.
### Members vs. nodes
`crdt_scope_members` and the `nodes` list on a scope are orthogonal layers and
both stay:
- A **member** is an authorization fact: a user identity allowed to access a
scope. It answers *who may sync, read, and write*.
- A **node** (`crdt_nodes`) is a stable CRDT replica identity. A scope's
`nodes` relation points at `crdt_scope_nodes`, which records that replica's
participation and checkpoint state in that scope. Together they answer
*whose changes, up to which HLC* and are what per-`(scope, node)` checkpoints
key on.
They do not map one-to-one and neither replaces the other. One member may have
many nodes (one per replica/database install). The **server is a node** in every
scope it syncs but is not a member row. A freshly invited member has **zero
scope-node rows** until their first device syncs. Nodes and scope-node rows are
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); members are
created by application code. Removing the `nodes` list would break checkpointing
and causal filtering — it is the chain topology, not the access list.
### The client membership table
`crdt_scope_members` is `database: all`, so the table exists on the client and
is **populated as a read-only projection** of the server's authoritative grants
— without being bidirectionally CRDT-synced:
- The client's projected `crdt_scope_members` rows plus the implicit personal
scope are its membership view for *membership gating*: a membership-wide read
filters over projected membership, and a local `crdt_scopes` row by itself
does not grant access. The members table also carries **roles**, which the
client consults before local writes.
- The authoritative `CrdtSyncScopeSet` carries `CrdtScopeGrant`s — `(scopeUuid,
role)` — not bare UUIDs. Every grant has an explicit role; the personal scope
is announced as `readWrite` but remains implicit in storage. When the follower
*receives* an announcement, `projectFollowerMembership` reconciles the local
table from it: upsert a row per shared grant (the personal scope is skipped),
delete rows whose scope is no longer granted. Since the authoritative peer
announces only when its grants change, projection runs only on change and an
idle session writes nothing.
- This is a **server-authored, client-read-only projection**, never a synced
members table: the client never writes membership the server reads, so it
cannot self-grant. The server stays the security boundary and re-verifies on
sync.
- A revoke takes effect for sync as soon as the server omits the scope from a
cycle's `ScopeSet`: the client stops cycling it and discards in-memory
checkpoint state for it. The scope row and domain rows may remain stored
locally until the deferred revocation cleanup policy decides whether to purge
or keep them, but membership-wide reads stop returning those rows.
- Writes are bounded by projected membership and by the projected role. They are
re-verified by the server on sync, which remains the security boundary.
Client-side checks are early-failure UX, not the enforcement point.
### Role write access
The package exposes a closed CRDT access role enum rather than free-form role
strings:
```dart
enum CrdtScopeRole {
readOnly,
readWrite,
}
```
Only `readWrite` allows writes. `readOnly` denies writes. The implicit personal
scope has no stored membership row, but its authoritative grant and role lookup
resolve to `readWrite`.
Roles gate **writes only**. Reads stay membership-wide and role-independent.
The authoritative server enforces role writes before applying inbound changes:
for each accepted inbound scope group, it resolves the authenticated user's
role from `crdt_scope_members`; if the role is not `readWrite`, the server
records `CrdtSyncIntegrityViolation(type: unauthorizedWrite)` and fails the
sync session before opening the merge transaction for that scope. The rejected
change is not applied and checkpoints do not advance past it.
The client uses the same enum as early UX: `transactionForUser(userId, fn,
scopeId: sharedScope)` resolves the projected role and throws
`CrdtScopeRoleException` before any local write when the role cannot write. A
follower also omits non-writable shared scopes from outbound pending-change
collection while still receiving inbound changes for those scopes. This avoids
repeatedly streaming local read-only changes; the server enforcement remains the
security boundary for stale, misconfigured, or hostile clients.
### Scope enumeration (server is the authority)
At the start of each sync cycle the server resolves the authoritative set:
```
memberScopes(userUuid) =
{ personal scope = userUuid }
∪ { m.scopeUuid : crdt_scope_members where userUuid = <auth user> }
```
This set bounds everything the cycle may sync. The client never expands it; it
only learns of additions through what the server announces.
## Sync: one call syncs every accessible scope
### One `sync`, two cadences: bounded `once`, idle-silent `continuous`
Merge is inherently one scope at a time: a merge runs in exactly one scope's
transaction and lock, so the scopes always serialize regardless of framing. A
single `sync` method serves both `syncOnce` and `syncContinuously`, branching on
`once` only where they genuinely differ — read discipline and termination — so
there is no duplicated send/merge logic:
1. **Establishment (shared, lockstep).** After `Connect`, both peers exchange the
`ScopeSet`. Doing this *before* the loop is what removes the announce/adopt
offset that would otherwise make a follower act before it knows the agreed
set.
2. **Data loop (shared, idle-silent).** Each cycle is **one combined batch**: the
scope announcement (only when this peer's grants changed), a `SinceHlc` for
each newly active scope, and merge chunks whose changes carry their scope,
all closed by a single `EndOfBatch`. Continuous mode omits the terminator when
nothing was sent. The receive phase demultiplexes the peer's batch by type
until that terminator or — when the peer was idle — an idle timeout. An idle
continuous cycle therefore sends nothing and idles out **once**, regardless of
scope count.
`once` stays in the data loop until every active scope has exchanged `SinceHlc`
and had one sendable data cycle, then performs the symmetric `Close` handshake.
`continuous` loops instead, with no per-cycle chatter; it handshakes its initial
scopes in the first cycle and absorbs membership changes as they are announced
(a newly granted scope is announced, adopted, and established over the following
cycles — its data deferred until its `SinceHlc` round-trips).
### Protocol frames
`Connect` opens the session once. After that the frames are the same in both
cadences; only *when* they are sent differs (`once`: a fixed sequence per scope;
`continuous`: only on change, inside one combined batch). Frame changes
(regenerated models + a migration on both client and server schemas):
- `CrdtSyncConnect { syncTablesHash, localNodeId }` — sent once by each peer. The
schema hash is validated once, before any scope work. `localNodeId` identifies
this peer's CRDT node for the whole session. The scope list is dynamic and
exchanged per cycle.
- `CrdtSyncScopeSet { scopes: List<CrdtScopeGrant> }` (each grant a
`scopeUuid` + `role`) — the server's content is authoritative (resolved from
`crdt_scope_members`, roles included). Followers send an empty set because the
authoritative peer never widens access from follower-reported state. In `once`
it is exchanged exactly once. In `continuous` a peer re-announces it
**only when its own grant set changed** since the last announcement — this is
the frame that carries access changes mid-session, and gating it on change is
what keeps an idle session silent while still letting a grant or revoke take
effect.
- `CrdtSyncSinceHlc { uuidScopeId, nodeCheckpoints }` — per scope. Carries that
scope's per-node checkpoints. Exchanged when a scope first becomes active
(and again if a dropped scope is later re-adopted);
checkpoints then live in memory for the rest of the session.
- `CrdtSyncMergeChunk { changes }` — each change carries `uuidScopeId`, letting
a combined batch carry every scope's changes and regroup them on receive.
- `CrdtSyncEndOfBatch`, `CrdtSyncClose`, `CrdtSyncIdleTimeout` — unchanged. A
single `EndOfBatch` terminates a cycle's combined batch (and is itself omitted
when nothing was sent, so an idle peer resolves to an empty batch instead).
`Close` ends the streaming session. Because it is an intentional peer control
frame, it may arrive even after frames for a partial cycle; the receiver
discards that partial cycle and records no checkpoint progress for it.
The data cycle's receive uses `collectNextBatch`, which demultiplexes one
cycle's frames by type into `{ scopeSet?, sinceHlcs, changes }`,
returning on the single `EndOfBatch`, on `Close` with a closed result, or —
when the peer was idle — on the idle timeout. A raw transport close without a
`Close` frame is stricter: it is graceful only between continuous-session
batches and is treated as truncation once a partial batch has arrived.
### The session state machine
```
handshake (both cadences):
send Connect(syncTablesHash, localNodeId); read peer Connect; validate
syncTablesHash; store peer localNodeId
establishment (both cadences, lockstep):
send ScopeSet(myGrants); read peer ScopeSet → adopt → activeScopes
follower: materialize activeScopes, project membership
data loop (both cadences, one combined batch per cycle):
loop:
resolve activeScopes (authoritative: my set; follower: adopted peerGrants)
discard state for scopes no longer active
# send phase — emit only what changed; track whether any frame was sent
if myGrants != announcedGrants: send ScopeSet(myGrants)
for scope in activeScopes not yet sinceHlcSent: send SinceHlc(scope)
if peer checkpoints exist: send MergeChunk(changes)* # follower skips non-writable scopes
if anything was sent (or once): send EndOfBatch
# receive phase
batch = collectNextBatch(allowCloseBeforeBatch: !once) # {scopeSet?, sinceHlcs, changes} | idle | closed
if closed: return
adopt batch.scopeSet (follower: materialize + project); store sinceHlcs;
group changes by uuidScopeId; mergeInboundBatch each group
if once: send Close; read Close; drain; return
delay continuousSyncInterval
```
The shared establishment exchanges the scope set so a follower never acts before
it knows the agreed set. Both cadences handshake initial and newly added scopes
through the loop's own `SinceHlc` exchange.
`collectPendingChanges`, `mergeInboundBatch`, `recordSyncCheckpoint`, and the
ownership-violation internals (`_streamInserts`/`Updates`/`Deletes`,
`_fetchDomainRow`, the durable-violation path) are scope-parameterized and merge
each inbound scope group in its own `transactionForUser`/lock; any scope's merge
failure (for example an ownership collision or unauthorized role write) records
the durable violation and fails the session, preserving today's fail-fast
semantics.
Because `continuous` defers a scope's data until both peers have exchanged its
`SinceHlc`, a newly active scope is established over the **following** cycles
rather than in lockstep — the deliberate trade for keeping the steady state
silent. A peer only honors inbound `SinceHlc`/`MergeChunk` frames for scopes it
authorizes (authoritative: its own membership; follower: the announced set), so
the combined batch tolerates a peer's informational extras without widening
access.
`onMergeSuccess` becomes scope-aware: `Function(UuidValue scopeUuid, Hlc hlc)`.
This touches three test call sites and the example app's `demo_controller`.
### Scope-set reconciliation and lockstep
Both peers must cycle the **same ordered scope set** or send/receive desyncs.
Membership is asymmetric — the client trusts the server's membership claims, the
server trusts none of the client's — so the reconciliation is **directional**,
not a symmetric set operation:
- **Union is wrong** (insecure): a client could name a scope the user is not a
member of and force the server to sync it.
- **Intersection is wrong** (cannot adopt): a brand-new share is not in the
client's set yet, so it would never enter the intersection and never sync —
the client cannot hold what it has not synced, and cannot sync what it does
not hold.
The correct operation is neither: **the server dictates its set; the client
conforms.** Each cycle:
- **Server**: `orderedScopes = sort(memberScopes(userUuid))`, computed from
`crdt_scope_members`. It never reads the client's `ScopeSet` for authority
(constraint 4).
- **Client**: takes the server's `ScopeSet` as the set to cycle —
`getOrCreate` (`CrdtScopeManager`) any scope it lacks, ignore any local scope
the server omitted for this cycle — then `orderedScopes = sort(peer scopeIds)`.
This asymmetry is expressed by a **`CrdtSyncPeerMode` enum**
(`authoritative` / `follower`) passed at the call site: the server endpoint
passes `authoritative`, the client driver passes `follower`. Membership is
resolved from the same shared `crdt_scope_members` table (via
`CrdtScopeMembership`); the mode varies only the cycle *direction* —
authoritative dictates its own set, follower adopts the peer's.
Once the data is unified, a closed enum is the right shape: there is no
per-role enumeration callback left to inject. Security does not rest on the
enum — a malicious client passing `authoritative` only fails to adopt the
peer's set; writes are still gated server-side by `CrdtScopeMembership.isMember`
against the authoritative table.
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`,
adoption happens when the announcement arrives and the scope's data follows over
the next cycles once its `SinceHlc` round-trips. Sorting by scope UUID makes the
order deterministic on both sides with no extra negotiation. Frames carry
`uuidScopeId` so a receiver groups each chunk into the right scope and fails loud
on an unauthorized tag rather than merging a batch into the wrong scope.
Re-resolving `memberScopes` every cycle is an indexed `crdt_scope_members`
lookup on the server only; it may be throttled or invalidated by an
application hook if per-cycle querying becomes hot, at the cost of slower
propagation of access changes.
### Checkpoints, once vs. continuous
Checkpoint rows stay per `(scope, node)` in `crdt_scope_nodes`, while
`crdt_nodes` stores the stable replica identity. The same local replica node can
participate in multiple scopes, with separate `lastReceivedHlc` values per
scope. `SinceHlc` is exchanged once per scope on first visit; later cycles reuse
the in-memory
`nodeCheckpoints[scope]`, advanced as chunks are sent and as inbound batches
merge — the multi-scope analogue of today's single-scope continuous loop, which
already keeps checkpoints in memory across rounds.
The continuous interval delay moves to **after each full cycle**, not after each
scope, to keep per-scope latency low while still preventing a busy loop when
idle.
### Access changes during a session
Because the authoritative scope set is re-resolved every cycle and re-announced
whenever it changes (not pinned at `Connect`), a continuous session absorbs
membership changes without reconnecting:
- **Grant.** The server re-announces a `ScopeSet` that includes the new scope;
the client adopts and materializes it, then handshakes and syncs it over the
next cycles. (The server detects the grant on its next cycle, so propagation
is bounded by one idle poll plus the handshake round-trip.)
- **Revoke.** The server re-announces a `ScopeSet` that omits the scope; both
sides stop cycling it and discard its in-memory checkpoint state, and the
follower's `projectFollowerMembership` deletes its members-table row. The
device can still store the revoked scope's rows locally, but membership-wide
reads no longer return them. Purging stored rows (and the fate of any unsynced
local changes) is the deferred revocation question, not a sync-loop concern.
- **Demote.** The server re-announces the same scope with a different role; the
follower projection updates the cached role. The next local write consults
the new role and fails early if it is read-only. Pending local changes already
authored before demotion are not silently applied by the server: if streamed,
they are rejected and recorded as `unauthorizedWrite`. Whether the client
purges, surfaces, or keeps skipped pending changes is part of the deferred
revocation-cleanup design.
The authoritative peer originates every set change, so both sides converge on
the same active set from the same announcement and stay in step across it. A
one-shot `syncOnce` captures the set once; the next `syncOnce` call picks up any
change.
## Transaction API
`transactionForUser` keeps its name and user-first semantics and gains an
optional scope:
```dart
db.transactionForUser(userId, fn); // acts in the personal scope
db.transactionForUser(userId, fn, scopeId: listId); // acts in a shared scope
```
- Without `scopeId`, the scope resolves to the user's personal scope —
`userId` itself by convention — which is exactly today's behavior.
- With `scopeId`, the package asserts membership before acting by calling
`CrdtScopeMembership.isMember` directly against the shared table — no injected
validator. On the server that table is authoritative; on a persistent client
it is the server-projected membership cache. After membership passes,
shared-scope writes also resolve the member role; anything other than
`readWrite` throws `CrdtScopeRoleException` before the transaction starts.
- A transaction acts in **exactly one** scope (constraint 5). The write path —
stamp-or-assert, the scoped `WHERE`, `scopeId` immutability — is unchanged
beyond which scope is resolved.
## Read path: membership-wide
A user-scoped read in a sharing world means "rows in any scope I am a member
of". The single-scope resolver becomes a set:
- `mergeWhereWithTombstone` isolates scoped reads with `scopeId IN (<member
scope ids>)` through `CrdtScopeIdsResolver`. For writes, the resolver supplies
the single acting scope. For reads, both server sessions and persistent
clients resolve the set from `CrdtScopeMembership.memberScopes`
(authoritative on the server, projected on the client); unscoped admin reads
still pass no scope filter.
- The visibility probe is the row-keyed form for the multi-scope case, so
sharing simplifies the predicate matrix rather than growing it: reads differ
only in their membership filter (one scope, a user's scopes, or none for
admin).
- This is the shape of a Postgres row-level-security policy
(`scopeId IN (SELECT scopeId FROM crdt_scope_members WHERE userUuid = …)`); the
package's application-level filter and RLS become two enforcements of one
predicate.
Writes stay scope-pinned (above); only reads widen.
## What sharing does not change
Every enforcement rule in `row-ownership.md` survives unchanged:
`(table, uuidRowId)` stays globally identified; rows have exactly one owning
scope; merge-insert ownership checks compare scopes; unique conflict groups form
within a scope; ordinary global unique indexes stay rejected; stamp-or-assert
applies with the transaction's resolved scope; the durable
`crdt_sync_integrity_violations` contract now also records unauthorized role
writes. The merge, FK, and unique engines already key on the scope the merge
runs in and need no ownership changes for sharing.
## Implementation status
The implementation landed in independently green phases. Tests should continue
to run with `--concurrency=1`.
- **Phase 1 — membership foundation.** Implemented `crdt_scope_members`
(`database: all`, unsynced) and its migration. Added the shared
`CrdtScopeMembership` helpers (`memberScopes` / `isMember`). No behavior change
yet: a personal-scope-only set reproduces today's single-scope sync exactly.
- **Phase 2 — sequential multi-scope sync (the requirement).** Implemented
protocol model
changes (`Connect` with session `localNodeId`, new per-cycle
`CrdtSyncScopeSet`, per-scope `SinceHlc`, `MergeChunk.uuidScopeId`) plus
generate/migrate. Outer cycle loop in `CrdtSync.sync` with per-cycle
`ScopeSet` exchange, per-scope handshake-on-first-visit, in-memory per-scope
checkpoints, and the once/continuous cadence. Reconciliation via a
`CrdtSyncPeerMode` enum: the server endpoint passes `authoritative` (cycles
`CrdtScopeMembership.memberScopes`); the client driver passes `follower`
(adopts the server's set). Scope-aware `onMergeSuccess`. A personal-scope-only
device behaves identically to today.
- **Phase 3 — transaction API.** Implemented
`transactionForUser(userId, fn, {scopeId})` with the membership assertion;
one scope per transaction.
- **Phase 4 — membership-wide reads.** Implemented the scoped read filter as
`scopeId IN (…)` over the user's member scopes.
- **Phase 5 — lifecycle and docs.** Documented client adoption of newly
announced shares; reconcile with the scope-purge semantics in
`sync-non-sync-relations.md`.
Revocation cleanup is deferred (see *Follow-ups*).
- **Phase 6 — idle-silent sync, one method.** Reworked the single `sync` method
(no separate once/continuous bodies) into a shared lockstep establishment plus
a shared combined data loop: each cycle coalesces into one scoped-change batch
terminated by a single `EndOfBatch`, with `ScopeSet`/`SinceHlc`/`MergeChunk`
sent only on change and read via `collectNextBatch`. An idle continuous
session now sends zero frames and idles out once per cycle regardless of scope
count (Phase 2's loop re-announced the set and an `EndOfBatch` per scope every
tick). `once` runs one data cycle then closes; mid-session continuous scopes are
deferred until their `SinceHlc` round-trips, and inbound frames are honored
only for authorized scopes.
- **Phase 7 — roles within a scope.** Implemented the closed
`CrdtScopeRole` enum (`readOnly`, `readWrite`) on server and client.
`readWrite` is the only shared-scope write grant. Added authoritative inbound
enforcement that records
`unauthorizedWrite` and fails before merge, client-side
`CrdtScopeRoleException` early rejection, and follower outbound skipping for
non-writable shared scopes while reads remain membership-wide.
## Test plan
Because no code branches on scope count or deployment role, mechanisms are
tested once, not per combination.
- **Regression.** Every existing single-scope suite passes unchanged on the
personal-scope-only path.
- **Protocol.** `Connect`/`SinceHlc` round-trip a multi-scope list; merge chunks
route to the correct scope by `uuidScopeId`; per-scope checkpoints advance
independently across cycles.
- **One call, every scope.** A single `syncOnce` converges N scopes in one pass;
a continuous session keeps cycling. Outbound changes for the active scopes are
collected in one pass and streamed in `syncBatchSize` chunks; the receive
groups a cycle's inbound chunks by scope before merging each in its own
transaction.
- **Idle silence.** A continuous session with no pending changes settles and then
emits no further frames — neither peer re-announces the scope set nor sends an
`EndOfBatch` while idle.
- **Membership.** The server syncs personal + shared scopes; a client-announced
non-member scope is refused and never streamed; a newly granted scope is
adopted and syncs within the same `syncOnce` call.
- **Roles.** `readOnly` can still read shared rows, local writes fail before
mutation, `readWrite` syncs normally, a follower skips outbound pending changes
for non-writable scopes while still receiving inbound changes, and a stale or
hostile client write is rejected and recorded by the server as
`unauthorizedWrite`. Personal scopes remain writable through their explicit
`readWrite` grant.
- **Access changes mid-session.** During a continuous session, a grant added to
`crdt_scope_members` appears and syncs on the next cycle; a revoke stops
cycling that scope without dropping the session.
- **Isolation under sharing.** Two users sharing scope X both converge on X
while their personal scopes stay isolated; an ownership collision in one scope
records a durable violation and fails the session without corrupting another
scope's merge.
- **Smoke.** One `withServerpod` round-trip with one personal plus one shared
scope, proving the end-to-end wiring (mirrors `sync_flow_test.dart`).
## Resolved during review
- **Combined sync batch:** merges serialize regardless, so combining scopes is
not a throughput optimization. Both cadences use the same combined batch shape:
each change carries its `uuidScopeId`, and receive groups changes by scope
before merging each scope group. Continuous mode omits idle frames so idle
latency is constant in scope count.
- **Membership authority:** the server, from `crdt_scope_members`. The
reconciliation is directional — server dictates, client conforms — and is
neither union (insecure) nor intersection (cannot adopt). It is a
**`CrdtSyncPeerMode` enum** (`authoritative` / `follower`): membership still
resolves through `CrdtScopeMembership`, so the only residual variation is
cycle direction — a closed enum, not a callback. The enum is not the security
boundary; server-side
`CrdtScopeMembership.isMember` gates every applied write regardless of the
client's declared mode.
- **Scope set is dynamic, not pinned at connect:** the authoritative peer
re-resolves its set every cycle and re-announces a `ScopeSet` whenever it
changed, so grants and revokes take effect mid-session without reconnecting.
In `syncOnce` the client adopts from the initial `ScopeSet`, exchanges
`SinceHlc`, then runs the sendable data cycle; in `syncContinuously` a newly
announced scope is adopted then established over the next cycles as its
`SinceHlc` round-trips.
- **Members vs. nodes:** orthogonal layers, both kept. Members are authorization
(`who`); nodes are CRDT causality (`whose changes, up to which HLC`). One
member maps to many nodes; the server is a node but not a member; a new member
has no node until first sync.
- **Client membership table:** `database: all` puts the table on the client,
populated as a read-only projection. The `CrdtSyncScopeSet` carries
`CrdtScopeGrant`s (`scopeUuid` + `role`), and each follower cycle reconciles
the local table from the announcement (upsert granted, delete revoked; only
when the set changed). The projected members table drives shared-scope
membership gating and roles for UI and offline role-gated writes; local
`crdt_scopes` rows alone do not grant access. It is a server-authored,
client-read-only projection — never a
bidirectionally-synced members table, so a client cannot self-grant.
- **Iteration order:** sorted scope UUIDs, so both peers stay in lockstep
without negotiation. Each merge change carries its scope UUID, so receive can
regroup a combined batch deterministically.
- **One scope per transaction:** kept. Cross-scope writes need separate
transactions; remote replicas cannot observe a cross-scope write atomically
anyway.
- **Membership table is `database: all` but unsynced:** the schema exists on
every node, yet it is server-authoritative app state, never CRDT-replicated.
- **Idle chatter accepted for the initial implementation:** an idle continuous
cycle does one empty scope-turn per accessible scope rather than one per
session. It is proportional to scope count and harmless to start; skipping
scopes with no local pending changes and no expected inbound is an optional
later optimization, not a launch blocker.
- **Initial-sync ordering is not a current concern:** a device joining many
scopes cycles them one at a time on first sync. Prioritizing recently active
scopes in the iteration order is a compatible refinement if cold-start latency
over large scope counts ever matters, but it is not a present worry.
## Follow-ups
Deferred work needing its own design pass. It does not block the implemented
role enforcement above.
1. **Membership revocation.** When a user loses membership, the server omits the
scope from the next cycle's `ScopeSet` and both sides stop syncing it
promptly. The device may still store the scope's data and unsynced local
changes, though membership-wide reads no longer expose revoked rows.
Purge-vs-keep policy, and whether revoked or demoted unsynced changes are
surfaced, dropped, or kept pending locally, need their own design. (Carried
from `row-ownership.md` open question 3.)
2. **Outbound collection consistency.** Collection reads CRDT metadata and the
domain data separately and lock-free, so a concurrent delete — or an FK
reference to a row inserted after the insert snapshot — can fail a healthy
stream sync and record a durable violation for a race that the next round
would resolve. The single-pass multi-scope collection widened this window.
Failing the session for a transient, self-correcting race is the weakness to
fix; the leading direction is a read-only snapshot (MVCC, not a lock). See
`outbound-collection-consistency.md`.

View file

@ -286,7 +286,8 @@ class DemoController extends ChangeNotifier {
try { try {
await session.syncOnce( await session.syncOnce(
client, client,
onMergeSuccess: (hlc) => _handleReplicaMerge(slot, hlc), onMergeSuccess: (scopeUuid, hlc) =>
_handleReplicaMerge(slot, scopeUuid, hlc),
); );
state.phase = SyncPhase.idle; state.phase = SyncPhase.idle;
state.lastSyncedLabel = 'synced ${_timeLabel()}'; state.lastSyncedLabel = 'synced ${_timeLabel()}';
@ -312,7 +313,8 @@ class DemoController extends ChangeNotifier {
() async { () async {
final stream = session.syncContinuously( final stream = session.syncContinuously(
client, client,
onMergeSuccess: (hlc) => unawaited(_handleReplicaMerge(slot, hlc)), onMergeSuccess: (scopeUuid, hlc) =>
unawaited(_handleReplicaMerge(slot, scopeUuid, hlc)),
); );
state.stream = stream; state.stream = stream;
state.phase = SyncPhase.streaming; state.phase = SyncPhase.streaming;
@ -418,8 +420,12 @@ class DemoController extends ChangeNotifier {
} }
} }
Future<void> _handleReplicaMerge(ReplicaSlot slot, Object hlc) async { Future<void> _handleReplicaMerge(
replicas[slot]!.lastSyncedLabel = 'merged $hlc'; ReplicaSlot slot,
offline.UuidValue scopeUuid,
Object hlc,
) async {
replicas[slot]!.lastSyncedLabel = 'merged $scopeUuid at $hlc';
await _refreshReplica(slot, notify: false); await _refreshReplica(slot, notify: false);
await _fetchServer(notify: false); await _fetchServer(notify: false);
replicas[slot]!.changed(); replicas[slot]!.changed();

View file

@ -1,6 +1,7 @@
export 'src/crdt/client_sync.dart'; export 'src/crdt/client_sync.dart';
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation; export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
export 'src/crdt/merge.dart'; export 'src/crdt/merge.dart';
export 'src/crdt/scope_membership.dart';
export 'src/crdt/sync.dart'; export 'src/crdt/sync.dart';
export 'src/database/database.dart'; export 'src/database/database.dart';
export 'src/database/recorder.dart'; export 'src/database/recorder.dart';

View file

@ -68,7 +68,12 @@ class CrdtSyncClient {
); );
final subscription = session.crdtDb final subscription = session.crdtDb
.sync(inbound: remoteStream, once: once, onMergeSuccess: onMergeSuccess) .sync(
inbound: remoteStream,
once: once,
onMergeSuccess: onMergeSuccess,
mode: CrdtSyncPeerMode.follower,
)
.listen( .listen(
outboundChanges.addIfNotClosed, outboundChanges.addIfNotClosed,
onDone: doneCompleter.completeOrSkip, onDone: doneCompleter.completeOrSkip,

View file

@ -43,26 +43,6 @@ final class CrdtSyncUnexpectedEventException<T extends CrdtSyncStreamEvent>
'received "${received.runtimeType.className}" instead.'; 'received "${received.runtimeType.className}" instead.';
} }
/// Thrown when a peer's since-HLC frame does not reference the local node id.
final class CrdtSyncInvalidSinceHlcException extends CrdtSyncException {
/// Creates a [CrdtSyncInvalidSinceHlcException].
const CrdtSyncInvalidSinceHlcException({
required this.receivedNodeId,
required this.expectedNodeId,
});
/// The node id carried by the peer's since-HLC frame.
final UuidValue receivedNodeId;
/// The local node id that was expected.
final UuidValue expectedNodeId;
@override
String toString() =>
'CrdtSyncInvalidSinceHlcException: peer since HLC node id '
'"$receivedNodeId" does not match local node id "$expectedNodeId".';
}
/// Thrown when the sync tables hash sent by a peer does not match locally. /// Thrown when the sync tables hash sent by a peer does not match locally.
final class SyncTablesHashMismatchException extends CrdtSyncException { final class SyncTablesHashMismatchException extends CrdtSyncException {
/// Creates a [SyncTablesHashMismatchException]. /// Creates a [SyncTablesHashMismatchException].
@ -109,10 +89,58 @@ final class CrdtSyncIntegrityViolationException extends CrdtSyncException {
'${violation.type.name}/${violation.operation.name} references ' '${violation.type.name}/${violation.operation.name} references '
'missing domain row $row for scope ' 'missing domain row $row for scope '
'${violation.incomingScopeUuid}.$persisted', '${violation.incomingScopeUuid}.$persisted',
CrdtSyncViolationType.unauthorizedWrite =>
'CrdtSyncIntegrityViolationException: '
'${violation.type.name}/${violation.operation.name} rejected '
'write to $row in scope ${violation.incomingScopeUuid}.$persisted',
}; };
} }
} }
/// Thrown when a user attempts to act in a scope they are not a member of.
final class CrdtScopeMembershipException implements Exception {
/// Creates a [CrdtScopeMembershipException].
const CrdtScopeMembershipException({
required this.userId,
required this.scopeId,
});
/// The authenticated user id supplied to `transactionForUser`.
final UuidValue userId;
/// The scope id the transaction attempted to act in.
final UuidValue scopeId;
@override
String toString() =>
'CrdtScopeMembershipException: user "$userId" is not a member of '
'scope "$scopeId".';
}
/// Thrown when a user attempts to write in a scope with a non-writable role.
final class CrdtScopeRoleException implements Exception {
/// Creates a [CrdtScopeRoleException].
const CrdtScopeRoleException({
required this.userId,
required this.scopeId,
this.role,
});
/// The authenticated user id supplied to `transactionForUser`.
final UuidValue userId;
/// The scope id the transaction attempted to write in.
final UuidValue scopeId;
/// The role that does not allow writes.
final CrdtScopeRole? role;
@override
String toString() =>
'CrdtScopeRoleException: user "$userId" with role "$role" cannot write '
'in scope "$scopeId".';
}
@internal @internal
final class PendingOutboundIntegrityViolation implements Exception { final class PendingOutboundIntegrityViolation implements Exception {
const PendingOutboundIntegrityViolation({ const PendingOutboundIntegrityViolation({

View file

@ -69,3 +69,12 @@ extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
/// start with 'user' are synced. /// start with 'user' are synced.
bool get isSynced => name.startsWith('user'); bool get isSynced => name.startsWith('user');
} }
/// CRDT write capability for a scope role.
///
/// The implicit personal scope resolves to [CrdtScopeRole.readWrite]. A missing
/// role means no shared membership row was found and does not grant writes.
extension CrdtScopeRoleWriteAccess on CrdtScopeRole? {
/// Whether this role may write CRDT rows in the scope.
bool get canWrite => this == CrdtScopeRole.readWrite;
}

View file

@ -20,38 +20,53 @@ typedef CrdtMergeMetadataLookup = ({
extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> { extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> {
/// Collects the next framed sync batch from this iterator. /// Collects the next framed sync batch from this iterator.
/// ///
/// Each batch is zero or more [CrdtSyncMergeChunk] events followed by /// Each batch is zero or more scope, handshake, and/or merge frames followed
/// [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an /// by [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
/// empty batch is returned. /// empty batch is returned.
/// ///
/// When [allowCloseBeforeBatch] is true, returns `null` if the stream closes /// When [allowCloseBeforeBatch] is true, returns `null` if the transport
/// before the next batch starts. Otherwise, closing before a batch starts is /// stream closes before the next batch starts. [CrdtSyncClose] is an
/// treated as a [CrdtSyncStreamClosedException]. /// intentional peer request to end the streaming session and returns `null`
/// even after frames for a partial batch have already arrived. The caller
/// must not persist checkpoint progress for such a discarded partial batch.
/// Otherwise, closing before a batch starts is treated as a
/// [CrdtSyncStreamClosedException].
/// ///
/// If the stream closes after a merge change was already received, the /// If the transport stream closes after a frame was already received without
/// partial batch is still treated as an error. /// a [CrdtSyncClose] control frame, the partial batch is still treated as an
Future<CrdtMergeSet?> collectNextBatch({ /// error.
///
/// An idle timeout only ends an *empty* batch: once any frame has been
/// collected the timeout is ignored and collection waits for the explicit
/// terminator.
Future<CrdtSyncCycleBatch?> collectNextBatch({
bool allowCloseBeforeBatch = false, bool allowCloseBeforeBatch = false,
}) async { }) async {
final mergeSet = <CrdtMergeChange>[]; final batch = CrdtSyncCycleBatch();
while (await moveNext()) { while (await moveNext()) {
switch (current) { switch (current) {
case CrdtSyncMergeChunk(changes: final changes): case final CrdtSyncScopeSet event:
mergeSet.addAll(changes); batch.scopeSet = event;
case final CrdtSyncSinceHlc event:
batch.sinceHlcs[event.uuidScopeId] = event;
case CrdtSyncMergeChunk(:final changes):
batch.changes.addAll(changes);
case CrdtSyncIdleTimeout(): case CrdtSyncIdleTimeout():
if (mergeSet.isEmpty) return mergeSet; if (batch.isEmpty) return batch;
case CrdtSyncEndOfBatch(): case CrdtSyncEndOfBatch():
return mergeSet; return batch;
case CrdtSyncClose():
return null;
default: default:
throw CrdtSyncUnexpectedEventException( throw CrdtSyncUnexpectedEventException(
expected: '"CrdtSyncMergeChunk" or "CrdtSyncEndOfBatch"', expected: 'a sync cycle frame',
received: current, received: current,
); );
} }
} }
if (mergeSet.isEmpty && allowCloseBeforeBatch) return null; if (batch.isEmpty && allowCloseBeforeBatch) return null;
throw const CrdtSyncStreamClosedException(phase: 'end-of-batch'); throw const CrdtSyncStreamClosedException(phase: 'end-of-batch');
} }
@ -70,6 +85,21 @@ extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEve
} }
} }
/// One sync cycle's inbound frames.
class CrdtSyncCycleBatch {
/// The peer's scope announcement for this cycle, if it sent one.
CrdtSyncScopeSet? scopeSet;
/// The peer's resume vectors, keyed by scope.
final Map<UuidValue, CrdtSyncSinceHlc> sinceHlcs = {};
/// The peer's merge changes for this cycle.
final List<CrdtMergeChange> changes = [];
/// Whether the peer sent nothing this cycle (it was idle).
bool get isEmpty => scopeSet == null && sinceHlcs.isEmpty && changes.isEmpty;
}
/// Helpers for grouping merge changes into stream payload batches. /// Helpers for grouping merge changes into stream payload batches.
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> { extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
/// Emits lists with at most [batchSize] changes from this stream. /// Emits lists with at most [batchSize] changes from this stream.

View file

@ -0,0 +1,204 @@
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart' hide Protocol;
import 'package:uuid/uuid.dart';
import '../protocol/protocol.dart';
/// Resolves shared-scope membership from the `crdt_scope_members` table.
///
/// The table is `database: all`, so the same code runs on every node:
/// authoritatively on the server (the source of truth) and against the local
/// read-only cache on a follower. A user's personal scope is implicit — a user
/// always belongs to the scope whose UUID equals their own user UUID — and
/// needs no membership row. Explicit rows grant access to additional shared
/// scopes.
///
/// Queries are raw SQL rather than the generated ORM on purpose: `find<T>`
/// resolves the table through the session's serialization manager keyed by the
/// model [Type], and a server session only registers the server-package model
/// classes — never this client-package [CrdtScopeMember]. Raw SQL is
/// serialization-manager-agnostic and runs identically on both ends, matching
/// the rest of the CRDT engine's cross-node queries.
class CrdtScopeMembership {
const CrdtScopeMembership._();
/// Returns the scope UUIDs [userUuid] may sync.
///
/// Includes the implicit personal scope and every explicit shared membership,
/// de-duplicated and sorted by UUID string so both peers iterate
/// deterministically.
static Future<List<UuidValue>> memberScopes(
DatabaseSession session,
UuidValue userUuid, {
Transaction? transaction,
}) async {
final grants = await memberGrants(
session,
userUuid,
transaction: transaction,
);
return [for (final grant in grants) grant.uuidScopeId];
}
/// Returns the authoritative scope grants for [userUuid].
///
/// Like [memberScopes] but carries each scope's `role`. The implicit
/// personal scope is included with [CrdtScopeRole.readWrite]. Used to build the
/// authoritative [CrdtSyncScopeSet] announcement so a follower can project
/// roles into its local cache.
static Future<List<CrdtScopeGrant>> memberGrants(
DatabaseSession session,
UuidValue userUuid, {
Transaction? transaction,
}) async {
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final result = await session.db.unsafeQuery(
'SELECT s."${CrdtScope.t.uuidScopeId.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 m."${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid',
transaction: transaction,
);
final grantsByUuid = <String, CrdtScopeGrant>{};
for (final row in result) {
final scopeUuid = Protocol().deserialize<UuidValue>(row[0]);
grantsByUuid[scopeUuid.uuid] = CrdtScopeGrant(
uuidScopeId: scopeUuid,
role: _roleFromDatabase(row[1]),
);
}
// Assigned after the shared rows so the personal grant stays readWrite
// even if a stray membership row exists for it, matching [roleOf].
grantsByUuid[userUuid.uuid] = CrdtScopeGrant(
uuidScopeId: userUuid,
role: CrdtScopeRole.readWrite,
);
return grantsByUuid.values.toList()
..sort((a, b) => a.uuidScopeId.uuid.compareTo(b.uuidScopeId.uuid));
}
/// The role [userUuid] holds in [scopeUuid], or null if none is recorded.
///
/// The implicit personal scope resolves to [CrdtScopeRole.readWrite]. On the
/// server this reads authoritative membership; on a client it reads the
/// projected membership cache.
static Future<CrdtScopeRole?> roleOf(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
Transaction? transaction,
}) async {
if (userUuid == scopeUuid) return CrdtScopeRole.readWrite;
final scopeId = await _scopeIdForUuid(
session,
scopeUuid,
transaction: transaction,
);
if (scopeId == null) return null;
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
final result = await session.db.unsafeQuery(
'SELECT "${CrdtScopeMember.t.role.columnName}" '
'FROM "${CrdtScopeMember.t.tableName}" '
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid '
'AND "${CrdtScopeMember.t.scopeId.columnName}" = $encodedScopeId '
'LIMIT 1',
transaction: transaction,
);
return result.isEmpty ? null : _roleFromDatabase(result.first[0]);
}
/// Reconciles the local `crdt_scope_members` cache from an authoritative
/// [grants] announcement — a follower's read-only projection of its own
/// memberships.
///
/// Upserts a row per shared grant (the implicit personal scope is skipped)
/// and deletes rows for [userUuid] whose scope is no longer granted, so a
/// revoked or demoted membership does not linger offline. Scopes must already
/// be materialized locally; a grant whose scope is unknown is skipped.
@internal
static Future<void> projectFollowerMembership(
DatabaseSession session, {
required UuidValue userUuid,
required List<CrdtScopeGrant> grants,
}) async {
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
final keptScopeIds = <int>[];
for (final grant in grants) {
if (grant.uuidScopeId == userUuid) continue; // personal scope is implicit
final scopeId = await _scopeIdForUuid(session, grant.uuidScopeId);
if (scopeId == null) continue; // not materialized locally yet
keptScopeIds.add(scopeId);
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
final encodedRole = ValueEncoder.instance.convert(grant.role.toJson());
await session.db.unsafeExecute(
'INSERT INTO "${CrdtScopeMember.t.tableName}" '
'("${CrdtScopeMember.t.scopeId.columnName}", '
'"${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.role.columnName}") '
'VALUES ($encodedScopeId, $encodedUserUuid, $encodedRole) '
'ON CONFLICT ("${CrdtScopeMember.t.userUuid.columnName}", '
'"${CrdtScopeMember.t.scopeId.columnName}") '
'DO UPDATE SET "${CrdtScopeMember.t.role.columnName}" = $encodedRole',
);
}
final notInClause = keptScopeIds.isEmpty
? ''
: 'AND "${CrdtScopeMember.t.scopeId.columnName}" NOT IN '
'(${keptScopeIds.map(ValueEncoder.instance.convert).join(', ')})';
await session.db.unsafeExecute(
'DELETE FROM "${CrdtScopeMember.t.tableName}" '
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUserUuid '
'$notInClause',
);
}
/// Returns whether [userUuid] may act in [scopeUuid].
///
/// The implicit personal scope is accepted without a membership row.
static Future<bool> isMember(
DatabaseSession session, {
required UuidValue userUuid,
required UuidValue scopeUuid,
Transaction? transaction,
}) async {
final role = await roleOf(
session,
userUuid: userUuid,
scopeUuid: scopeUuid,
transaction: transaction,
);
return role != null;
}
}
CrdtScopeRole _roleFromDatabase(Object? value) {
if (value == null) {
throw StateError('crdt_scope_members.role must not be null.');
}
return CrdtScopeRole.fromJson(value as String);
}
Future<int?> _scopeIdForUuid(
DatabaseSession session,
UuidValue scopeUuid, {
Transaction? transaction,
}) async {
final encodedScopeUuid = ValueEncoder.instance.convert(scopeUuid);
final result = await session.db.unsafeQuery(
'SELECT "${CrdtScope.t.id.columnName}" FROM "${CrdtScope.t.tableName}" '
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScopeUuid '
'LIMIT 1',
transaction: transaction,
);
return result.isEmpty ? null : result.first[0] as int;
}

View file

@ -0,0 +1,273 @@
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:uuid/uuid.dart';
import '../managers/scope.dart';
import '../protocol/protocol.dart';
import 'extensions.dart';
import 'merge.dart';
import 'scope_membership.dart';
/// How a peer decides which scopes it syncs.
enum CrdtSyncPeerMode {
/// Dictates the scope set from authoritative membership.
authoritative,
/// Adopts the scope set announced by the authoritative peer.
follower,
}
/// De-duplicates [scopeIds] and sorts them by UUID string so both peers iterate
/// scopes in the same deterministic order.
List<UuidValue> _sortedUniqueScopeIds(Iterable<UuidValue> scopeIds) {
final byUuid = {for (final scopeId in scopeIds) scopeId.uuid: scopeId};
return byUuid.values.toList()..sort((a, b) => a.uuid.compareTo(b.uuid));
}
/// De-duplicates [grants] by scope and sorts them by scope UUID string.
List<CrdtScopeGrant> _sortedUniqueGrants(Iterable<CrdtScopeGrant> grants) {
final byUuid = {for (final grant in grants) grant.uuidScopeId.uuid: grant};
return byUuid.values.toList()
..sort((a, b) => a.uuidScopeId.uuid.compareTo(b.uuidScopeId.uuid));
}
/// Whether the grant list [a] last announced equals the current list [b].
bool _grantsEqual(List<CrdtScopeGrant>? a, List<CrdtScopeGrant> b) {
if (a == null || a.length != b.length) return false;
for (var i = 0; i < b.length; i++) {
if (a[i].uuidScopeId != b[i].uuidScopeId || a[i].role != b[i].role) {
return false;
}
}
return true;
}
/// Owns the scope state of one sync session and every scope decision its driver
/// (`CrdtSync.sync`) makes: which scopes are active, their handshake and
/// checkpoint state, membership reconciliation, and per-frame authorization.
///
/// The driver owns the wire (yielding and reading frames, the once/continuous
/// control flow); this owns the scopes. It needs only a [DatabaseSession] and
/// the membership helpers, so it carries no protocol or transport concerns.
class CrdtScopeSyncSession {
/// Creates a scope session for [userId] acting in [mode] against [_session].
///
/// [peerNodeId] is the remote peer's CRDT node, learned from its connect
/// handshake before this session is constructed.
CrdtScopeSyncSession(
this._session, {
required UuidValue userId,
required CrdtSyncPeerMode mode,
required UuidValue peerNodeId,
}) : _userId = userId,
_mode = mode,
_peerNodeId = peerNodeId;
final DatabaseSession _session;
final UuidValue _userId;
final CrdtSyncPeerMode _mode;
final UuidValue _peerNodeId;
/// Session-lived scope manager. Reused across cycles so its in-memory cache
/// of already-materialized scopes survives, sparing a follower a per-cycle
/// `getOrCreate` round-trip for scopes it has already created this session.
late final CrdtScopeManager _scopeManager = CrdtScopeManager(_session);
/// The grant set this peer last announced (null until the first announcement).
List<CrdtScopeGrant>? _announcedGrants;
/// This peer's current local grants, refreshed by [reconcile].
List<CrdtScopeGrant> _localGrants = const [];
/// The grant set the peer last announced, adopted by [adoptPeerGrants].
List<CrdtScopeGrant> _peerGrants = const [];
/// The scopes cycled this round, sorted, materialized for a follower.
List<UuidValue> _activeScopeIds = const [];
/// UUID strings of [_activeScopeIds] and of [_peerGrants], for O(1) lookups.
Set<String> _activeUuids = const {};
Set<String> _peerUuids = const {};
/// Active scopes this peer may send local writes for.
Set<String> _writableUuids = const {};
/// Scopes for which this peer has already sent its [CrdtSyncSinceHlc].
final Set<String> _sinceHlcSent = {};
/// The peer's node id from its connect handshake.
UuidValue get peerNodeId => _peerNodeId;
/// What the peer has seen per scope (advances as we send), seeded from its
/// [CrdtSyncSinceHlc] — presence means the scope's handshake completed both
/// ways, so it can stream changes.
final Map<UuidValue, Map<UuidValue, Hlc>> _checkpointsByScope = {};
/// The grants this peer should announce this cycle.
List<CrdtScopeGrant> get localGrants => _localGrants;
/// The scopes cycled this round (sorted, deterministic on both peers).
List<UuidValue> get activeScopeIds => _activeScopeIds;
/// The authenticated user this sync session represents.
UuidValue get userId => _userId;
/// Whether this peer is authoritative for scope membership.
bool get isAuthoritative => _mode == CrdtSyncPeerMode.authoritative;
/// Whether the local grants changed since the last announcement.
bool get shouldAnnounce => !_grantsEqual(_announcedGrants, _localGrants);
/// Records that [localGrants] was just announced, so it is not re-sent until
/// it changes again.
void markAnnounced() => _announcedGrants = _localGrants;
/// Re-resolves local membership, then recomputes the active scope set.
///
/// Used at the top of every data-loop cycle so an authoritative peer picks up
/// its own membership changes. Establishment instead recomputes from the
/// *announced* grants (via [adoptPeerGrants]) without re-reading, so a grant
/// that lands mid-handshake cannot make this peer handshake a different scope
/// count than it announced.
Future<void> reconcile() async {
_localGrants = _sortedUniqueGrants(await _resolveLocalGrants());
await _recomputeActiveScopes();
_refreshWritableScopes();
}
/// Adopts the peer's announced [peerGrants] and recomputes the active scope
/// set from the grants already resolved (no membership re-read). A follower
/// materializes the announced scopes locally and projects their roles into the
/// members cache (projection needs the scope rows to exist); an authoritative
/// peer keeps cycling its own membership.
Future<void> adoptPeerGrants(List<CrdtScopeGrant> peerGrants) async {
_peerGrants = _sortedUniqueGrants(peerGrants);
_peerUuids = {for (final g in _peerGrants) g.uuidScopeId.uuid};
await _recomputeActiveScopes();
if (_mode == CrdtSyncPeerMode.follower) {
await CrdtScopeMembership.projectFollowerMembership(
_session,
userUuid: _userId,
grants: _peerGrants,
);
}
_refreshWritableScopes();
}
/// Recomputes the active scope set from the current local and peer grants
/// (materializing a follower's adopted scopes), then prunes in-session state
/// for scopes that left the set — e.g. a membership the peer revoked.
Future<void> _recomputeActiveScopes() async {
_activeScopeIds = _sortedUniqueScopeIds(
await _resolveOrderedScopeIds(
localScopeIds: [for (final g in _localGrants) g.uuidScopeId],
peerScopeIds: [for (final g in _peerGrants) g.uuidScopeId],
),
);
_activeUuids = {for (final s in _activeScopeIds) s.uuid};
_sinceHlcSent.removeWhere((uuid) => !_activeUuids.contains(uuid));
_checkpointsByScope.removeWhere((s, _) => !_activeUuids.contains(s.uuid));
}
void _refreshWritableScopes() {
if (_mode == CrdtSyncPeerMode.authoritative) {
_writableUuids = _activeUuids;
return;
}
// The peer's announced grants already carry the authoritative roles, so a
// follower derives writability in memory instead of re-reading the cache
// it projected from these same grants. The personal scope is always
// writable; consumers only consult scopes in the active set.
_writableUuids = {
_userId.uuid,
for (final grant in _peerGrants)
if (grant.role.canWrite) grant.uuidScopeId.uuid,
};
}
/// Marks that this peer is sending its [CrdtSyncSinceHlc] for [scopeId],
/// returning whether the scope was newly handshaked (so the driver sends one).
bool markHandshakeSent(UuidValue scopeId) => _sinceHlcSent.add(scopeId.uuid);
/// Records the peer's resume vector for [scopeId] from its [sinceHlc],
/// completing the scope's handshake from this peer's side.
void recordPeerHandshake(UuidValue scopeId, CrdtSyncSinceHlc sinceHlc) {
_checkpointsByScope[scopeId] = {
for (final checkpoint in sinceHlc.nodeCheckpoints) checkpoint.nodeId: checkpoint,
};
}
/// Checkpoints for writable scopes whose handshake completed both ways, keyed
/// by scope — the input to a pending-change collection pass.
Map<UuidValue, List<Hlc>> get sendableCheckpoints => {
for (final scopeId in _activeScopeIds)
if (_checkpointsByScope[scopeId] != null && _writableUuids.contains(scopeId.uuid))
scopeId: _checkpointsByScope[scopeId]!.values.toList(),
};
/// Whether any active scope still lacks the peer handshake state.
bool get hasIncompleteActiveHandshake => _activeScopeIds.any(
(scopeId) => _checkpointsByScope[scopeId] == null,
);
/// Advances the in-session checkpoint for [scopeId] past a just-sent [change],
/// so the next collection does not resend it.
void advanceCheckpoint(UuidValue scopeId, CrdtMergeChange change) {
final checkpoints = _checkpointsByScope[scopeId];
if (checkpoints == null) return;
checkpoints[change.uuidNodeId] = change.hlc.maxBetween(
checkpoints[change.uuidNodeId],
);
}
/// Whether this peer authorizes acting in [scopeId]: an authoritative peer
/// trusts its own membership, a follower trusts the peer's announced set. This
/// is the wire-side counterpart of the server's membership gate.
bool accepts(UuidValue scopeId) =>
(_mode == CrdtSyncPeerMode.follower ? _peerUuids : _activeUuids).contains(
scopeId.uuid,
);
/// The greatest checkpoint HLC tracked for [scopeId], or null if none.
Hlc? checkpointMaxOf(UuidValue scopeId) {
final checkpoints = _checkpointsByScope[scopeId];
if (checkpoints == null || checkpoints.isEmpty) return null;
return checkpoints.values.max;
}
/// Resolves the local grants this peer announces in its [CrdtSyncScopeSet].
///
/// Authoritative peers enumerate [CrdtScopeMembership.memberGrants].
/// Followers send an empty set because the authoritative peer never widens
/// access from follower-reported scope state.
Future<List<CrdtScopeGrant>> _resolveLocalGrants() async {
switch (_mode) {
case CrdtSyncPeerMode.authoritative:
return CrdtScopeMembership.memberGrants(_session, _userId);
case CrdtSyncPeerMode.follower:
await _scopeManager.getOrCreate(_userId);
return const [];
}
}
/// Resolves the ordered lockstep scopes cycled this round.
///
/// Authoritative peers dictate their own set; followers adopt the peer's
/// announced set, materializing each scope locally.
Future<List<UuidValue>> _resolveOrderedScopeIds({
required List<UuidValue> localScopeIds,
required List<UuidValue> peerScopeIds,
}) async {
switch (_mode) {
case CrdtSyncPeerMode.authoritative:
return localScopeIds;
case CrdtSyncPeerMode.follower:
final scopeIds = _sortedUniqueScopeIds(peerScopeIds);
for (final scopeId in scopeIds) {
await _scopeManager.getOrCreate(scopeId);
}
return scopeIds;
}
}
}

View file

@ -13,9 +13,14 @@ import 'exceptions.dart';
import 'extensions.dart'; import 'extensions.dart';
import 'integrity_violation.dart'; import 'integrity_violation.dart';
import 'merge.dart'; import 'merge.dart';
import 'scope_membership.dart';
import 'scope_sync.dart';
export 'scope_sync.dart' show CrdtSyncPeerMode;
/// Callback function for when a merge is successful. /// Callback function for when a merge is successful.
typedef CrdtSyncOnMergeSuccess = FutureOr<void> Function(Hlc syncedHlc); typedef CrdtSyncOnMergeSuccess =
FutureOr<void> Function(UuidValue scopeUuid, Hlc syncedHlc);
/// A tuple representing the ownership of a domain row. /// A tuple representing the ownership of a domain row.
typedef DomainRowOwner = ({bool exists, int? scopeId}); typedef DomainRowOwner = ({bool exists, int? scopeId});
@ -123,7 +128,7 @@ class CrdtSync {
'${uuid.v5(Namespace.oid.value, canonicalSignature)}'; '${uuid.v5(Namespace.oid.value, canonicalSignature)}';
} }
/// Streams local CRDT changes that a peer node has not seen yet. /// Streams pending changes for every scope in [checkpointsByScopeUuid].
/// ///
/// Changes are emitted in insert, update, then delete order. Domain row and /// Changes are emitted in insert, update, then delete order. Domain row and
/// column payloads are resolved incrementally as each change is yielded. /// column payloads are resolved incrementally as each change is yielded.
@ -133,20 +138,39 @@ class CrdtSync {
/// in the domain table. Peers need the attempted fact to converge; local FK /// in the domain table. Peers need the attempted fact to converge; local FK
/// projection materializes only the safe visible value into domain tables. /// projection materializes only the safe visible value into domain tables.
/// ///
/// All changes for nodes that are not present in the [nodeCheckpoints] list /// Resolves the scopes' internal ids once and keeps their checkpoint vectors
/// are collected and emitted. Passing an empty list will collect all changes. /// scoped by those internal ids. Node ids are stable per replica and may
/// appear in multiple scopes, so checkpoint filtering must compare both
/// `scopeId` and `uuidNodeId`. This still runs one query per change kind for
/// the whole pass. Per-row ownership and integrity checks resolve against
/// each row's own scope.
///
/// All changes for nodes that are not present in a scope's checkpoint list
/// are collected and emitted. Passing an empty list for a scope will collect
/// all of its changes. Each change carries its [CrdtMergeChange.uuidScopeId].
Stream<CrdtMergeChange> collectPendingChanges( Stream<CrdtMergeChange> collectPendingChanges(
DatabaseSession session, { DatabaseSession session, {
required UuidValue userId, required Map<UuidValue, List<Hlc>> checkpointsByScopeUuid,
required UuidValue peerNodeId,
required List<Hlc> nodeCheckpoints,
}) async* { }) async* {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId); if (checkpointsByScopeUuid.isEmpty) return;
final scopes = await CrdtScope.db.find(
session,
where: (t) => t.uuidScopeId.inSet(checkpointsByScopeUuid.keys.toSet()),
);
final scopeUuidById = {
for (final scope in scopes) scope.id!: scope.uuidScopeId,
};
final checkpointsByScopeId = {
for (final scope in scopes)
scope.id!: checkpointsByScopeUuid[scope.uuidScopeId] ?? const <Hlc>[],
};
try { try {
await for (final change in _streamPendingChanges( await for (final change in _streamPendingChanges(
session, session,
crdtUser, scopeUuidById,
nodeCheckpoints, checkpointsByScopeId,
)) { )) {
yield change; yield change;
} }
@ -155,29 +179,31 @@ class CrdtSync {
} }
} }
/// Creates the [CrdtSyncSinceHlc] checkpoint for the post-connect handshake. /// Creates the [CrdtSyncSinceHlc] checkpoint for a scope handshake.
/// ///
/// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node /// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
/// has received from each known node, tagged with the source node id. /// has received from each known node, tagged with the source node id.
Future<CrdtSyncSinceHlc> createSyncSinceHlc( Future<CrdtSyncSinceHlc> createSyncSinceHlc(
DatabaseSession session, { DatabaseSession session, {
required UuidValue userId, required UuidValue scopeId,
required UuidValue peerNodeId,
}) async { }) async {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId); final scope = await CrdtScopeManager(session).getOrCreate(scopeId);
final localNodeId = scope.currentNode!.uuidNodeId;
final nodes = await CrdtNode.db.find( final scopeNodes = await CrdtScopeNode.db.find(
session, session,
where: (t) => where: (t) =>
t.scopeId.equals(crdtUser.id) & t.scopeId.equals(scope.id) & t.nodeId.notEquals(scope.currentNodeId),
t.uuidNodeId.notEquals(crdtUser.currentNode!.uuidNodeId), include: CrdtScopeNode.include(node: CrdtNode.include()),
); );
return CrdtSyncSinceHlc( return CrdtSyncSinceHlc(
uuidScopeId: scopeId,
nodeCheckpoints: [ nodeCheckpoints: [
// The local node is always included to avoid collecting its own changes. // The local node is always included to avoid collecting its own changes.
Hlc.now(crdtUser.currentNode!.uuidNodeId), Hlc.now(localNodeId),
for (final node in nodes) node.lastReceivedHlc ?? Hlc.zero(node.uuidNodeId), for (final scopeNode in scopeNodes)
scopeNode.lastReceivedHlc ?? Hlc.zero(scopeNode.node!.uuidNodeId),
], ],
); );
} }
@ -192,114 +218,111 @@ class CrdtSync {
/// ///
/// Returns the greatest HLC synced in the batch, or `null` if the batch is /// Returns the greatest HLC synced in the batch, or `null` if the batch is
/// empty. /// empty.
Future<Hlc?> mergeInboundBatch( Future<Hlc?> _mergeInboundBatch(
DatabaseSession session, { DatabaseSession session, {
required UuidValue userId, required UuidValue scopeId,
required UuidValue otherNodeId, required UuidValue otherNodeId,
required CrdtMergeSet mergeSet, required CrdtMergeSet mergeSet,
}) async { }) async {
if (mergeSet.isEmpty) return null; if (mergeSet.isEmpty) return null;
final maxSyncedHlc = mergeSet.maxHlc; final maxSyncedHlc = mergeSet.maxHlc;
final crdtDb = _openCrdtDatabase(session); final crdtDb = _openCrdtDatabase(session);
await crdtDb.mergeChanges(mergeSet, scopeId: userId); await crdtDb.mergeChanges(mergeSet, scopeId: scopeId);
if (maxSyncedHlc != null) { if (maxSyncedHlc != null) {
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: userId); await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: scopeId);
} }
return maxSyncedHlc; return maxSyncedHlc;
} }
/// Streams a framed outbound sync batch of merge changes.
Stream<CrdtSyncStreamEvent> streamOutboundBatch(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required List<Hlc> nodeCheckpoints,
}) async* {
yield* collectPendingChanges(
session,
userId: userId,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints,
)
.chunked(_syncBatchSize)
.map(
(changes) => CrdtSyncMergeChunk(changes: changes),
);
yield CrdtSyncEndOfBatch();
}
/// Runs a symmetric CRDT sync session over a bidirectional event stream. /// Runs a symmetric CRDT sync session over a bidirectional event stream.
/// ///
/// Both peers send [CrdtSyncConnect], read the peer connect frame, then send /// Both peers exchange [CrdtSyncConnect] and a lockstep [CrdtSyncScopeSet]
/// and read [CrdtSyncSinceHlc] once. After that, both sides exchange framed /// before the data loop. Each cycle sends a combined batch — scope
/// data batches using the peer's per-node checkpoints. Sent changes advance /// announcement when grants changed, [CrdtSyncSinceHlc] for newly active
/// the corresponding in-memory checkpoints for subsequent sends within the /// scopes, and [CrdtSyncMergeChunk]s — closed by [CrdtSyncEndOfBatch] when
/// session. /// anything was sent. Inbound frames are de-multiplexed by collectNextBatch
/// until [CrdtSyncEndOfBatch] when this peer sent a batch, or an idle timeout
/// when both peers had nothing to send.
/// ///
/// If a batch fails to merge, the exception propagates and the stream closes. /// When [once] is true the loop may run an extra cycle after handshakes
/// The next sync attempt resumes from the last persisted checkpoint on the /// complete so merge data can flow; then it closes symmetrically. Continuous
/// side that failed to merge. /// mode loops with [_continuousSyncInterval] between idle cycles.
///
/// When [once] is true, each peer sends one batch, reads one batch, sends a
/// [CrdtSyncClose] frame and awaits for the other to send its closing event
/// to complete the session.
Stream<CrdtSyncStreamEvent> sync( Stream<CrdtSyncStreamEvent> sync(
DatabaseSession session, { DatabaseSession session, {
required UuidValue userId, required UuidValue userId,
required Stream<CrdtSyncStreamEvent> inbound, required Stream<CrdtSyncStreamEvent> inbound,
required CrdtSyncPeerMode mode,
bool once = false, bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess, CrdtSyncOnMergeSuccess? onMergeSuccess,
}) async* { }) async* {
// Idle timeouts are a continuous-only affordance: they let an idle cycle
// settle into an empty batch without closing the stream. A `once` session
// is strictly lockstep — every batch ends with a [CrdtSyncEndOfBatch] and
// the session with a [CrdtSyncClose] — so it must wait for those end frames
// rather than truncate a slow peer's batch on a timeout.
final inboundIterator = StreamIterator( final inboundIterator = StreamIterator(
// Inject a timeout event if the inbound stream is idle for too long to once
// allow returning an empty batch and waiting for a change without closing ? inbound
// the stream. : inbound.timeout(
inbound.timeout( const Duration(seconds: 1),
const Duration(seconds: 1), onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()), ),
),
); );
var sessionCompleted = false; var sessionCompleted = false;
try { try {
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId); final scope = await CrdtScopeManager(session).getOrCreate(userId);
final localNodeId = crdtUser.currentNode!.uuidNodeId; final localNodeId = scope.currentNode!.uuidNodeId;
yield CrdtSyncConnect( yield CrdtSyncConnect(
localNodeId: localNodeId, localNodeId: localNodeId,
syncTablesHash: currentSyncTablesHash, syncTablesHash: currentSyncTablesHash,
); );
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>(); final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
final peerNodeId = peerConnect.localNodeId;
_validateSyncTablesHash(peerConnect.syncTablesHash); _validateSyncTablesHash(peerConnect.syncTablesHash);
yield await createSyncSinceHlc( final scopes = CrdtScopeSyncSession(
session, session,
userId: userId, userId: userId,
peerNodeId: peerNodeId, mode: mode,
peerNodeId: peerConnect.localNodeId,
); );
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>(); await scopes.reconcile();
final nodeCheckpoints = { yield CrdtSyncScopeSet(scopes: scopes.localGrants);
for (final checkpoint in peerSinceHlc.nodeCheckpoints) scopes.markAnnounced();
checkpoint.nodeId: checkpoint, final peerScopeSet = await inboundIterator.moveAndThrowIfNot<CrdtSyncScopeSet>();
}; await scopes.adoptPeerGrants(peerScopeSet.scopes);
while (true) { while (true) {
await scopes.reconcile();
final hadSendableCheckpoints = scopes.sendableCheckpoints.isNotEmpty;
var hasChanges = false;
final outboundScopes = <UuidValue>{};
if (scopes.shouldAnnounce) {
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
scopes.markAnnounced();
hasChanges = true;
}
for (final scopeId in scopes.activeScopeIds) {
if (!scopes.markHandshakeSent(scopeId)) continue;
yield await createSyncSinceHlc(session, scopeId: scopeId);
hasChanges = true;
}
final pendingLocalChanges = collectPendingChanges( final pendingLocalChanges = collectPendingChanges(
session, session,
userId: userId, checkpointsByScopeUuid: scopes.sendableCheckpoints,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints.values.toList(),
); );
var hasChanges = false;
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) { await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
hasChanges = true; hasChanges = true;
for (final change in changes) { for (final change in changes) {
final lastCheckpoint = nodeCheckpoints[change.uuidNodeId]; scopes.advanceCheckpoint(change.uuidScopeId, change);
nodeCheckpoints[change.uuidNodeId] = change.hlc.maxBetween(lastCheckpoint); outboundScopes.add(change.uuidScopeId);
} }
yield CrdtSyncMergeChunk(changes: changes); yield CrdtSyncMergeChunk(changes: changes);
} }
@ -307,28 +330,32 @@ class CrdtSync {
yield CrdtSyncEndOfBatch(); yield CrdtSyncEndOfBatch();
} }
final inboundMergeSet = await inboundIterator.collectNextBatch( final batch = await inboundIterator.collectNextBatch(
allowCloseBeforeBatch: !once, allowCloseBeforeBatch: !once,
); );
if (inboundMergeSet == null) { if (batch == null) {
if (once) {
yield CrdtSyncClose();
}
sessionCompleted = true; sessionCompleted = true;
return; return;
} }
final lastReceivedFromPeer = await mergeInboundBatch( await _applyCycleBatch(
session, session,
userId: userId, scopes,
otherNodeId: peerNodeId, batch,
mergeSet: inboundMergeSet, outboundScopes,
onMergeSuccess,
); );
if (hasChanges || inboundMergeSet.isNotEmpty) {
await onMergeSuccess?.call(
nodeCheckpoints.values.max.maxBetween(lastReceivedFromPeer),
);
}
if (once) { if (once) {
if (scopes.hasIncompleteActiveHandshake) {
continue;
}
if (!hadSendableCheckpoints && scopes.sendableCheckpoints.isNotEmpty) {
continue;
}
yield CrdtSyncClose(); yield CrdtSyncClose();
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>(); await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
sessionCompleted = true; sessionCompleted = true;
@ -349,6 +376,17 @@ class CrdtSync {
// Wait for the configured interval before checking for local changes again. // Wait for the configured interval before checking for local changes again.
await Future<void>.delayed(_continuousSyncInterval); await Future<void>.delayed(_continuousSyncInterval);
} }
} on CrdtSyncStreamClosedException {
// A continuous session ending is normal: the peer closed its outbound
// (typically a cancel). Whether the stream closed between batches or
// mid-batch, nothing is left to do — complete gracefully so the `finally`
// skips the force-cancel that races WebSocket teardown and surfaces
// spurious "connection closed" errors on the peer. A `once` session has a
// completion contract (full handshake plus the symmetric Close), so a
// close before that is a real truncation and must propagate.
if (once) rethrow;
sessionCompleted = true;
return;
} finally { } finally {
// Cancelling inbound on normal completion races with WebSocket stream // Cancelling inbound on normal completion races with WebSocket stream
// teardown and produces "connection closed" errors on the peer. On normal // teardown and produces "connection closed" errors on the peer. On normal
@ -363,6 +401,112 @@ class CrdtSync {
} }
} }
Future<void> _applyCycleBatch(
DatabaseSession session,
CrdtScopeSyncSession scopes,
CrdtSyncCycleBatch batch,
Set<UuidValue> outboundScopes,
CrdtSyncOnMergeSuccess? onMergeSuccess,
) async {
if (batch.scopeSet != null) {
await scopes.adoptPeerGrants(batch.scopeSet!.scopes);
}
for (final entry in batch.sinceHlcs.entries) {
if (scopes.accepts(entry.key)) {
scopes.recordPeerHandshake(entry.key, entry.value);
}
}
final mergedScopes = <UuidValue>{};
final changesByScope = <UuidValue, List<CrdtMergeChange>>{};
for (final change in batch.changes) {
changesByScope.putIfAbsent(change.uuidScopeId, () => []).add(change);
}
for (final entry in changesByScope.entries) {
final scopeId = entry.key;
if (!scopes.accepts(scopeId)) continue;
mergedScopes.add(scopeId);
if (scopes.isAuthoritative) {
await _assertCanMergeInboundScope(
session,
scopeId: scopeId,
userId: scopes.userId,
changes: entry.value,
);
}
final receivedHlc = await _mergeInboundBatch(
session,
scopeId: scopeId,
otherNodeId: scopes.peerNodeId,
mergeSet: entry.value,
);
await _reportMerge(onMergeSuccess, scopes, scopeId, receivedHlc);
}
for (final scopeId in outboundScopes.difference(mergedScopes)) {
await _reportMerge(onMergeSuccess, scopes, scopeId, null);
}
}
Future<void> _assertCanMergeInboundScope(
DatabaseSession session, {
required UuidValue scopeId,
required UuidValue userId,
required List<CrdtMergeChange> changes,
}) async {
if (changes.isEmpty || userId == scopeId) return;
final role = await CrdtScopeMembership.roleOf(
session,
userUuid: userId,
scopeUuid: scopeId,
);
if (role.canWrite) return;
final firstChange = changes.first;
final now = DateTime.now().toUtc();
final violation = CrdtSyncIntegrityViolation(
type: CrdtSyncViolationType.unauthorizedWrite,
domainTableName: firstChange.tableName,
uuidRowId: firstChange.uuidRowId,
ownerScopeUuid: null,
incomingScopeUuid: scopeId,
operation: _operationForInboundChange(firstChange),
uuidNodeId: firstChange.uuidNodeId,
crdtDataRowId: null,
hlcDatetime: firstChange.hlcDatetime,
hlcCounter: firstChange.hlcCounter,
firstSeenAt: now,
lastSeenAt: now,
occurrences: 1,
);
final persisted = await recordCrdtSyncIntegrityViolation(
session,
violation: violation,
);
throw CrdtSyncIntegrityViolationException(persisted);
}
CrdtSyncViolationOperation _operationForInboundChange(CrdtMergeChange change) {
return switch (change) {
CrdtMergeInsert() => CrdtSyncViolationOperation.mergeInsert,
CrdtMergeUpdate() => CrdtSyncViolationOperation.mergeUpdate,
CrdtMergeDelete() => CrdtSyncViolationOperation.mergeDelete,
};
}
/// Reports a successful merge for [scopeId] to [onMergeSuccess], combining the
/// scope's checkpoint high-water mark with the [receivedHlc] just merged.
Future<void> _reportMerge(
CrdtSyncOnMergeSuccess? onMergeSuccess,
CrdtScopeSyncSession scopes,
UuidValue scopeId,
Hlc? receivedHlc,
) async {
final checkpointMax = scopes.checkpointMaxOf(scopeId);
if (checkpointMax == null) return;
await onMergeSuccess?.call(scopeId, checkpointMax.maxBetween(receivedHlc));
}
/// Drains [iterator] until the peer closes the stream. /// Drains [iterator] until the peer closes the stream.
/// ///
/// Used to settle the inbound transport after the `once` close handshake so /// Used to settle the inbound transport after the `once` close handshake so
@ -400,26 +544,26 @@ class CrdtSync {
Stream<CrdtMergeChange> _streamPendingChanges( Stream<CrdtMergeChange> _streamPendingChanges(
DatabaseSession session, DatabaseSession session,
CrdtScope crdtUser, Map<int, UuidValue> scopeUuidById,
List<Hlc> nodeCheckpoints, Map<int, List<Hlc>> checkpointsByScopeId,
) async* { ) async* {
// Domain ownership is immutable while a collection runs, so read each // Domain ownership is immutable while a collection runs, so read each
// row's owner at most once across all three streams. // row's owner at most once across all three streams.
final ownerCache = DomainRowOwnerCache(); final ownerCache = DomainRowOwnerCache();
yield* _streamInserts(session, crdtUser, nodeCheckpoints, ownerCache); yield* _streamInserts(session, scopeUuidById, checkpointsByScopeId, ownerCache);
yield* _streamUpdates(session, crdtUser, nodeCheckpoints, ownerCache); yield* _streamUpdates(session, scopeUuidById, checkpointsByScopeId, ownerCache);
yield* _streamDeletes(session, crdtUser, nodeCheckpoints, ownerCache); yield* _streamDeletes(session, scopeUuidById, checkpointsByScopeId, ownerCache);
} }
Stream<CrdtMergeInsert> _streamInserts( Stream<CrdtMergeInsert> _streamInserts(
DatabaseSession session, DatabaseSession session,
CrdtScope crdtUser, Map<int, UuidValue> scopeUuidById,
List<Hlc> nodeCheckpoints, Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache, DomainRowOwnerCache ownerCache,
) async* { ) async* {
final rows = await CrdtDataRow.db.find( final rows = await CrdtDataRow.db.find(
session, session,
where: (t) => _rowHlcAfterFilter(t, crdtUser, nodeCheckpoints), where: (t) => _rowHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataRow.include( include: CrdtDataRow.include(
tbl: CrdtSchemaTable.include(), tbl: CrdtSchemaTable.include(),
node: CrdtNode.include(), node: CrdtNode.include(),
@ -439,6 +583,9 @@ class CrdtSync {
final dartName = _classNamesByTableName[tableName]; final dartName = _classNamesByTableName[tableName];
if (dartName == null) continue; if (dartName == null) continue;
final scopeId = row.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final domainRow = await _fetchDomainRow( final domainRow = await _fetchDomainRow(
session, session,
tableName, tableName,
@ -446,7 +593,7 @@ class CrdtSync {
table, table,
dartName, dartName,
foreignKeyAttemptFieldsByRowId[row.id!], foreignKeyAttemptFieldsByRowId[row.id!],
crdtUser.id!, scopeId,
ownerCache, ownerCache,
); );
if (!domainRow.exists) { if (!domainRow.exists) {
@ -457,12 +604,12 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
rowId: row.uuidRowId, rowId: row.uuidRowId,
ownerScopeId: null, ownerScopeId: null,
incomingScopeUuid: crdtUser.uuidScopeId, incomingScopeUuid: scopeUuid,
uuidNodeId: row.node!.uuidNodeId, uuidNodeId: row.node!.uuidNodeId,
hlc: row.hlc, hlc: row.hlc,
); );
} }
if (domainRow.ownerScopeId != crdtUser.id) { if (domainRow.ownerScopeId != scopeId) {
_throwPendingIntegrityViolation( _throwPendingIntegrityViolation(
crdtDataRowId: row.id, crdtDataRowId: row.id,
type: CrdtSyncViolationType.ownershipCollision, type: CrdtSyncViolationType.ownershipCollision,
@ -470,13 +617,14 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
rowId: row.uuidRowId, rowId: row.uuidRowId,
ownerScopeId: domainRow.ownerScopeId, ownerScopeId: domainRow.ownerScopeId,
incomingScopeUuid: crdtUser.uuidScopeId, incomingScopeUuid: scopeUuid,
uuidNodeId: row.node!.uuidNodeId, uuidNodeId: row.node!.uuidNodeId,
hlc: row.hlc, hlc: row.hlc,
); );
} }
yield CrdtMergeInsert( yield CrdtMergeInsert(
uuidScopeId: scopeUuid,
hlcDatetime: row.hlcDatetime, hlcDatetime: row.hlcDatetime,
hlcCounter: row.hlcCounter, hlcCounter: row.hlcCounter,
tableName: tableName, tableName: tableName,
@ -489,13 +637,13 @@ class CrdtSync {
Stream<CrdtMergeUpdate> _streamUpdates( Stream<CrdtMergeUpdate> _streamUpdates(
DatabaseSession session, DatabaseSession session,
CrdtScope crdtUser, Map<int, UuidValue> scopeUuidById,
List<Hlc> nodeCheckpoints, Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache, DomainRowOwnerCache ownerCache,
) async* { ) async* {
final fields = await CrdtDataField.db.find( final fields = await CrdtDataField.db.find(
session, session,
where: (t) => _fieldHlcAfterFilter(t, crdtUser, nodeCheckpoints), where: (t) => _fieldHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataField.include( include: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()), row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(), column: CrdtSchemaColumn.include(),
@ -513,6 +661,8 @@ class CrdtSync {
continue; continue;
} }
final scopeId = field.row!.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final columnName = field.column!.name; final columnName = field.column!.name;
final columnValue = await _fetchOwnedColumnValue( final columnValue = await _fetchOwnedColumnValue(
session, session,
@ -520,7 +670,7 @@ class CrdtSync {
field.row!.uuidRowId, field.row!.uuidRowId,
columnName, columnName,
field.foreignKey, field.foreignKey,
crdtUser.id!, scopeId,
ownerCache, ownerCache,
); );
if (!columnValue.exists) { if (!columnValue.exists) {
@ -531,12 +681,12 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
rowId: field.row!.uuidRowId, rowId: field.row!.uuidRowId,
ownerScopeId: null, ownerScopeId: null,
incomingScopeUuid: crdtUser.uuidScopeId, incomingScopeUuid: scopeUuid,
uuidNodeId: field.node!.uuidNodeId, uuidNodeId: field.node!.uuidNodeId,
hlc: field.hlc, hlc: field.hlc,
); );
} }
if (columnValue.ownerScopeId != crdtUser.id) { if (columnValue.ownerScopeId != scopeId) {
_throwPendingIntegrityViolation( _throwPendingIntegrityViolation(
crdtDataRowId: field.row!.id, crdtDataRowId: field.row!.id,
type: CrdtSyncViolationType.ownershipCollision, type: CrdtSyncViolationType.ownershipCollision,
@ -544,13 +694,14 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
rowId: field.row!.uuidRowId, rowId: field.row!.uuidRowId,
ownerScopeId: columnValue.ownerScopeId, ownerScopeId: columnValue.ownerScopeId,
incomingScopeUuid: crdtUser.uuidScopeId, incomingScopeUuid: scopeUuid,
uuidNodeId: field.node!.uuidNodeId, uuidNodeId: field.node!.uuidNodeId,
hlc: field.hlc, hlc: field.hlc,
); );
} }
yield CrdtMergeUpdate( yield CrdtMergeUpdate(
uuidScopeId: scopeUuid,
hlcDatetime: field.hlcDatetime, hlcDatetime: field.hlcDatetime,
hlcCounter: field.hlcCounter, hlcCounter: field.hlcCounter,
tableName: tableName, tableName: tableName,
@ -564,13 +715,13 @@ class CrdtSync {
Stream<CrdtMergeDelete> _streamDeletes( Stream<CrdtMergeDelete> _streamDeletes(
DatabaseSession session, DatabaseSession session,
CrdtScope crdtUser, Map<int, UuidValue> scopeUuidById,
List<Hlc> nodeCheckpoints, Map<int, List<Hlc>> checkpointsByScopeId,
DomainRowOwnerCache ownerCache, DomainRowOwnerCache ownerCache,
) async* { ) async* {
final tombstones = await CrdtDataDeleted.db.find( final tombstones = await CrdtDataDeleted.db.find(
session, session,
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, nodeCheckpoints), where: (t) => _tombstoneHlcAfterFilter(t, checkpointsByScopeId),
include: CrdtDataDeleted.include( include: CrdtDataDeleted.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()), row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
node: CrdtNode.include(), node: CrdtNode.include(),
@ -582,13 +733,16 @@ class CrdtSync {
final tableName = tombstone.row!.tbl!.name; final tableName = tombstone.row!.tbl!.name;
if (!_syncTablesByName.containsKey(tableName)) continue; if (!_syncTablesByName.containsKey(tableName)) continue;
final scopeId = tombstone.row!.scopeId;
final scopeUuid = scopeUuidById[scopeId]!;
final owner = await _readDomainRowOwner( final owner = await _readDomainRowOwner(
session, session,
tableName, tableName,
tombstone.row!.uuidRowId, tombstone.row!.uuidRowId,
ownerCache, ownerCache,
); );
if (owner.exists && owner.scopeId != crdtUser.id) { if (owner.exists && owner.scopeId != scopeId) {
_throwPendingIntegrityViolation( _throwPendingIntegrityViolation(
crdtDataRowId: tombstone.row!.id, crdtDataRowId: tombstone.row!.id,
type: CrdtSyncViolationType.ownershipCollision, type: CrdtSyncViolationType.ownershipCollision,
@ -596,13 +750,14 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
rowId: tombstone.row!.uuidRowId, rowId: tombstone.row!.uuidRowId,
ownerScopeId: owner.scopeId, ownerScopeId: owner.scopeId,
incomingScopeUuid: crdtUser.uuidScopeId, incomingScopeUuid: scopeUuid,
uuidNodeId: tombstone.node!.uuidNodeId, uuidNodeId: tombstone.node!.uuidNodeId,
hlc: tombstone.hlc, hlc: tombstone.hlc,
); );
} }
yield CrdtMergeDelete( yield CrdtMergeDelete(
uuidScopeId: scopeUuid,
hlcDatetime: tombstone.hlcDatetime, hlcDatetime: tombstone.hlcDatetime,
hlcCounter: tombstone.hlcCounter, hlcCounter: tombstone.hlcCounter,
tableName: tableName, tableName: tableName,
@ -616,41 +771,41 @@ class CrdtSync {
Expression _rowHlcAfterFilter( Expression _rowHlcAfterFilter(
CrdtDataRowTable t, CrdtDataRowTable t,
CrdtScope crdtUser, Map<int, List<Hlc>> checkpointsByScopeId,
List<Hlc> nodeCheckpoints,
) => ) =>
t.scopeId.equals(crdtUser.id) & t.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
t.node.afterAnyCheckpointFilter( _afterAnyScopeCheckpointFilter(
t.scopeId,
t.node.uuidNodeId, t.node.uuidNodeId,
t.hlcDatetime, t.hlcDatetime,
t.hlcCounter, t.hlcCounter,
nodeCheckpoints, checkpointsByScopeId,
); );
Expression _fieldHlcAfterFilter( Expression _fieldHlcAfterFilter(
CrdtDataFieldTable t, CrdtDataFieldTable t,
CrdtScope crdtUser, Map<int, List<Hlc>> checkpointsByScopeId,
List<Hlc> nodeCheckpoints,
) => ) =>
t.row.scopeId.equals(crdtUser.id) & t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
t.node.afterAnyCheckpointFilter( _afterAnyScopeCheckpointFilter(
t.row.scopeId,
t.node.uuidNodeId, t.node.uuidNodeId,
t.hlcDatetime, t.hlcDatetime,
t.hlcCounter, t.hlcCounter,
nodeCheckpoints, checkpointsByScopeId,
); );
Expression _tombstoneHlcAfterFilter( Expression _tombstoneHlcAfterFilter(
CrdtDataDeletedTable t, CrdtDataDeletedTable t,
CrdtScope crdtUser, Map<int, List<Hlc>> checkpointsByScopeId,
List<Hlc> nodeCheckpoints,
) => ) =>
t.row.scopeId.equals(crdtUser.id) & t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
t.node.afterAnyCheckpointFilter( _afterAnyScopeCheckpointFilter(
t.row.scopeId,
t.node.uuidNodeId, t.node.uuidNodeId,
t.hlcDatetime, t.hlcDatetime,
t.hlcCounter, t.hlcCounter,
nodeCheckpoints, checkpointsByScopeId,
); );
/// Loads a domain row for outbound insert sync. /// Loads a domain row for outbound insert sync.
@ -959,27 +1114,31 @@ class CrdtSync {
} }
} }
extension on CrdtNodeTable { Expression _afterAnyScopeCheckpointFilter(
Expression afterAnyCheckpointFilter( ColumnInt scopeId,
ColumnUuid uuidNodeId, ColumnUuid uuidNodeId,
ColumnDateTime hlcDatetime, ColumnDateTime hlcDatetime,
ColumnInt hlcCounter, ColumnInt hlcCounter,
List<Hlc> nodeCheckpoints, Map<int, List<Hlc>> checkpointsByScopeId,
) { ) {
if (nodeCheckpoints.isEmpty) return Constant.bool(true); final caseExpression = Case();
var hasCheckpoint = false;
final caseExpression = Case(); for (final MapEntry(key: normalizedScopeId, value: checkpoints)
for (final checkpoint in nodeCheckpoints) { in checkpointsByScopeId.entries) {
for (final checkpoint in checkpoints) {
hasCheckpoint = true;
caseExpression.when( caseExpression.when(
uuidNodeId.equals(checkpoint.nodeId), scopeId.equals(normalizedScopeId) & uuidNodeId.equals(checkpoint.nodeId),
then: then:
(hlcDatetime > checkpoint.datetime) | (hlcDatetime > checkpoint.datetime) |
(hlcDatetime.equals(checkpoint.datetime) & (hlcDatetime.equals(checkpoint.datetime) &
(hlcCounter > checkpoint.counter)), (hlcCounter > checkpoint.counter)),
); );
} }
return caseExpression.orElse(Constant.bool(true));
} }
return hasCheckpoint
? caseExpression.orElse(Constant.bool(true))
: Constant.bool(true);
} }
extension on Map<String, dynamic> { extension on Map<String, dynamic> {

View file

@ -2,13 +2,17 @@
// flag internal Serverpod APIs used by generated code. // flag internal Serverpod APIs used by generated code.
// ignore_for_file: invalid_use_of_internal_member // ignore_for_file: invalid_use_of_internal_member
import 'dart:async';
import 'package:serverpod_database/serverpod_database.dart'; import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart'; import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../crdt/exceptions.dart'; import '../crdt/exceptions.dart';
import '../crdt/extensions.dart';
import '../crdt/integrity_violation.dart'; import '../crdt/integrity_violation.dart';
import '../crdt/merge.dart'; import '../crdt/merge.dart';
import '../crdt/scope_membership.dart';
import '../crdt/sync.dart'; import '../crdt/sync.dart';
import '../protocol/protocol.dart'; import '../protocol/protocol.dart';
import 'recorder.dart'; import 'recorder.dart';
@ -20,6 +24,9 @@ part 'scope.dart';
/// Map of transaction hashes to the scope they are associated with. /// Map of transaction hashes to the scope they are associated with.
final scopeForTransaction = <Transaction, CrdtScope>{}; final scopeForTransaction = <Transaction, CrdtScope>{};
/// Map of transaction hashes to the authenticated user associated with them.
final userForTransaction = <Transaction, UuidValue>{};
/// Database proxy that runs insert/update/delete ORM operations inside a /// Database proxy that runs insert/update/delete ORM operations inside a
/// transaction to record each change in the CRDT tables. /// transaction to record each change in the CRDT tables.
class CrdtDatabase implements Database { class CrdtDatabase implements Database {
@ -111,6 +118,7 @@ class CrdtDatabase implements Database {
/// Runs a symmetric CRDT sync session over a bidirectional event stream. /// Runs a symmetric CRDT sync session over a bidirectional event stream.
Stream<CrdtSyncStreamEvent> sync({ Stream<CrdtSyncStreamEvent> sync({
required Stream<CrdtSyncStreamEvent> inbound, required Stream<CrdtSyncStreamEvent> inbound,
required CrdtSyncPeerMode mode,
UuidValue? userId, UuidValue? userId,
bool once = false, bool once = false,
CrdtSyncOnMergeSuccess? onMergeSuccess, CrdtSyncOnMergeSuccess? onMergeSuccess,
@ -123,6 +131,7 @@ class CrdtDatabase implements Database {
inbound: inbound, inbound: inbound,
once: once, once: once,
onMergeSuccess: onMergeSuccess, onMergeSuccess: onMergeSuccess,
mode: mode,
); );
} }
@ -184,20 +193,26 @@ class CrdtDatabase implements Database {
_delegate.serializationManager; _delegate.serializationManager;
/// Merges [where] with the CRDT visibility predicates scoped to the queried /// Merges [where] with the CRDT visibility predicates scoped to the queried
/// tables and the user associated with [transaction] (or the persistent /// tables and the user associated with [transaction] (or the persistent user).
/// user). Without a user scope (e.g. server-side admin reads), a row is ///
/// only hidden when every user tracking it has it hidden. /// Read filters are membership-wide. Write filters stay pinned to the acting
Expression? _whereVisibleWithTombstone<T extends TableRow>( /// scope so mutations cannot cross scope boundaries.
Future<Expression?> _whereVisibleWithTombstone<T extends TableRow>(
Expression? where, Expression? where,
Include? include, Include? include,
Transaction? transaction, Transaction? transaction, {
) { required bool membershipWide,
}) async {
final scopeIds = await _scopeIdsForQueries(
transaction,
membershipWide: membershipWide,
);
return mergeWhereWithTombstone<T>( return mergeWhereWithTombstone<T>(
serializationManager, serializationManager,
where, where,
include, include,
tableIdForName: _recorder.tableIdForName, tableIdForName: _recorder.tableIdForName,
scopeId: () => _recorder.scopeForQueries(transaction)?.id, scopeIds: () => scopeIds,
); );
} }
@ -216,7 +231,12 @@ class CrdtDatabase implements Database {
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
final result = await _delegate.find<T>( final result = await _delegate.find<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction), where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
limit: limit, limit: limit,
offset: offset, offset: offset,
orderBy: orderBy, orderBy: orderBy,
@ -244,7 +264,12 @@ class CrdtDatabase implements Database {
final table = serializationManager.getTableForType(T); final table = serializationManager.getTableForType(T);
final where = table?.id.equals(id); final where = table?.id.equals(id);
final result = await _delegate.findFirstRow<T>( final result = await _delegate.findFirstRow<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction), where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
transaction: transaction, transaction: transaction,
include: include, include: include,
lockMode: lockMode, lockMode: lockMode,
@ -268,7 +293,12 @@ class CrdtDatabase implements Database {
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
final result = await _delegate.findFirstRow<T>( final result = await _delegate.findFirstRow<T>(
where: _whereVisibleWithTombstone<T>(where, include, transaction), where: await _whereVisibleWithTombstone<T>(
where,
include,
transaction,
membershipWide: true,
),
offset: offset, offset: offset,
orderBy: orderBy, orderBy: orderBy,
orderByList: orderByList, orderByList: orderByList,
@ -289,6 +319,7 @@ class CrdtDatabase implements Database {
List<T> rows, { List<T> rows, {
Transaction? transaction, Transaction? transaction,
bool ignoreConflicts = false, bool ignoreConflicts = false,
bool noReturn = false,
}) async { }) async {
if (rows.isEmpty) return []; if (rows.isEmpty) return [];
await _ensureInitialized(); await _ensureInitialized();
@ -348,6 +379,7 @@ class CrdtDatabase implements Database {
List<Column>? updateColumns, List<Column>? updateColumns,
Expression? updateWhere, Expression? updateWhere,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
// TODO: Implement CRDT upsert. // TODO: Implement CRDT upsert.
@ -381,6 +413,7 @@ class CrdtDatabase implements Database {
List<T> rows, { List<T> rows, {
List<Column>? columns, List<Column>? columns,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
if (rows.isEmpty) return []; if (rows.isEmpty) return [];
await _ensureInitialized(); await _ensureInitialized();
@ -444,7 +477,12 @@ class CrdtDatabase implements Database {
final where = row.table.id.equals(row.id); final where = row.table.id.equals(row.id);
final updatedRows = await _delegate.updateWhere<T>( final updatedRows = await _delegate.updateWhere<T>(
columnValues: columnValues, columnValues: columnValues,
where: _whereVisibleWithTombstone<T>(where, null, transaction)!, where: (await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: false,
))!,
transaction: transaction, transaction: transaction,
); );
@ -489,6 +527,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
if (_recorder.isCrdtTracked<T>()) { if (_recorder.isCrdtTracked<T>()) {
@ -501,7 +540,12 @@ class CrdtDatabase implements Database {
(tx) async { (tx) async {
final result = await _delegate.updateWhere<T>( final result = await _delegate.updateWhere<T>(
columnValues: columnValues, columnValues: columnValues,
where: _whereVisibleWithTombstone<T>(where, null, tx)!, where: (await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
))!,
limit: limit, limit: limit,
offset: offset, offset: offset,
orderBy: orderBy, orderBy: orderBy,
@ -529,6 +573,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
if (rows.isEmpty) return []; if (rows.isEmpty) return [];
await _ensureInitialized(); await _ensureInitialized();
@ -572,6 +617,7 @@ class CrdtDatabase implements Database {
List<Column>? orderByList, List<Column>? orderByList,
bool orderDescending = false, bool orderDescending = false,
Transaction? transaction, Transaction? transaction,
bool noReturn = false,
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
if (!_recorder.isCrdtTracked<T>()) { if (!_recorder.isCrdtTracked<T>()) {
@ -591,7 +637,12 @@ class CrdtDatabase implements Database {
transaction, transaction,
(tx) async { (tx) async {
final rows = await _delegate.find<T>( final rows = await _delegate.find<T>(
where: _whereVisibleWithTombstone<T>(where, null, tx), where: await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
),
orderBy: orderBy, orderBy: orderBy,
orderByList: orderByList, orderByList: orderByList,
// Remove this once the deprecated member is removed. // Remove this once the deprecated member is removed.
@ -615,7 +666,12 @@ class CrdtDatabase implements Database {
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
return _delegate.count<T>( return _delegate.count<T>(
where: _whereVisibleWithTombstone<T>(where, null, transaction), where: await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: true,
),
limit: limit, limit: limit,
useCache: useCache, useCache: useCache,
transaction: transaction, transaction: transaction,
@ -631,7 +687,12 @@ class CrdtDatabase implements Database {
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
return _delegate.lockRows<T>( return _delegate.lockRows<T>(
where: _whereVisibleWithTombstone<T>(where, null, transaction)!, where: (await _whereVisibleWithTombstone<T>(
where,
null,
transaction,
membershipWide: false,
))!,
lockMode: lockMode, lockMode: lockMode,
transaction: transaction, transaction: transaction,
lockBehavior: lockBehavior, lockBehavior: lockBehavior,
@ -654,31 +715,100 @@ class CrdtDatabase implements Database {
/// Executes the [transactionFunction] in a transaction with the provided [userId]. /// Executes the [transactionFunction] in a transaction with the provided [userId].
/// ///
/// The [userId] is used to record the CRDT operations in the database. This method /// Without [scopeId], writes act in the user's personal scope. With [scopeId],
/// must be used instead of [transaction] when performing operations on the server, /// [userId] stays the authenticated identity and [scopeId] is the scope being
/// where the database knows multiple users. /// acted in; the pair is checked before the transaction starts.
Future<R> transactionForUser<R>( Future<R> transactionForUser<R>(
UuidValue userId, UuidValue userId,
TransactionFunction<R> transactionFunction, { TransactionFunction<R> transactionFunction, {
UuidValue? scopeId,
TransactionSettings? settings, TransactionSettings? settings,
}) async { }) async {
await _ensureInitialized(); await _ensureInitialized();
// Ensure that the user exists with a node before starting the transaction. final effectiveScopeId = scopeId ?? userId;
final user = await _recorder.getOrCreateScope(userId); await _assertCanActInScope(userId, effectiveScopeId);
// Ensure that the scope exists with a node before starting the transaction.
final scope = await _recorder.getOrCreateScope(effectiveScopeId);
return transaction<R>( return transaction<R>(
(tx) async { (tx) async {
try { try {
scopeForTransaction[tx] = user; scopeForTransaction[tx] = scope;
userForTransaction[tx] = userId;
return await transactionFunction(tx); return await transactionFunction(tx);
} finally { } finally {
scopeForTransaction.remove(tx); scopeForTransaction.remove(tx);
userForTransaction.remove(tx);
} }
}, },
settings: settings, settings: settings,
); );
} }
Future<void> _assertCanActInScope(UuidValue userId, UuidValue scopeId) async {
if (userId == scopeId) return;
// Authoritative membership: the source of truth on the server, the
// read-only cache on a follower (empty until populated). A null role means
// no membership row at all; a non-writable role means membership without
// write access.
final role = await CrdtScopeMembership.roleOf(
_delegate.session,
userUuid: userId,
scopeUuid: scopeId,
);
if (role == null) {
throw CrdtScopeMembershipException(userId: userId, scopeId: scopeId);
}
if (!role.canWrite) {
throw CrdtScopeRoleException(userId: userId, scopeId: scopeId, role: role);
}
}
Future<List<int>?> _scopeIdsForQueries(
Transaction? transaction, {
required bool membershipWide,
}) async {
if (!membershipWide) {
return _actingScopeIdsForQueries(transaction);
}
final userId = _userIdForQueries(transaction);
if (userId == null) return null;
// On the server this is authoritative membership; on a persistent client it
// is the server-projected membership cache.
return _scopeIdsForUuids(
await CrdtScopeMembership.memberScopes(_delegate.session, userId),
);
}
List<int>? _actingScopeIdsForQueries(Transaction? transaction) {
final scopeId = _recorder.scopeForQueries(transaction)?.id;
return scopeId == null ? null : [scopeId];
}
UuidValue? _userIdForQueries(Transaction? transaction) {
if (transaction != null) {
final userId = userForTransaction[transaction];
if (userId != null) return userId;
}
return _recorder.persistentUserId;
}
Future<List<int>> _scopeIdsForUuids(List<UuidValue> scopeUuids) async {
if (scopeUuids.isEmpty) return [];
final scopes = await CrdtScope.db.find(
_delegate.session,
where: (t) => t.uuidScopeId.inSet(scopeUuids.toSet()),
);
return [
for (final scope in scopes)
if (scope.id != null) scope.id!,
];
}
Future<UuidValue> _requireUserId(UuidValue? userId) async { Future<UuidValue> _requireUserId(UuidValue? userId) async {
return userId ?? return userId ??
_recorder.persistentUserId ?? _recorder.persistentUserId ??

View file

@ -10,6 +10,10 @@ typedef _MergeContext = ({
Map<_MergeRowKey, CrdtDataDeleted> tombstones, Map<_MergeRowKey, CrdtDataDeleted> tombstones,
Map<_MergeRowKey, _DomainOwner> domainOwners, Map<_MergeRowKey, _DomainOwner> domainOwners,
}); });
typedef _MergeNodes = ({
Map<UuidValue, CrdtNode> nodesByUuid,
Map<UuidValue, CrdtScopeNode> scopeNodesByUuid,
});
/// Adds merge-specific behavior to [CrdtMutationRecorder]. /// Adds merge-specific behavior to [CrdtMutationRecorder].
extension CrdtMergeRecorderExtension on CrdtMutationRecorder { extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
@ -47,6 +51,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
{for (final change in operations) change.uuidNodeId}, {for (final change in operations) change.uuidNodeId},
transaction, transaction,
); );
final nodesByUuid = remoteNodes.nodesByUuid;
final context = await _loadMergeContext(mergeSet, transaction); final context = await _loadMergeContext(mergeSet, transaction);
for (final operation in operations) { for (final operation in operations) {
@ -58,21 +63,21 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
case final CrdtMergeInsert insert: case final CrdtMergeInsert insert:
await _applyMergeInsert( await _applyMergeInsert(
insert, insert,
remoteNodes, nodesByUuid,
context, context,
transaction, transaction,
); );
case final CrdtMergeUpdate update: case final CrdtMergeUpdate update:
await _applyMergeUpdate( await _applyMergeUpdate(
update, update,
remoteNodes, nodesByUuid,
context, context,
transaction, transaction,
); );
case final CrdtMergeDelete delete: case final CrdtMergeDelete delete:
await _applyMergeDelete( await _applyMergeDelete(
delete, delete,
remoteNodes, nodesByUuid,
context, context,
transaction, transaction,
); );
@ -91,14 +96,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
Future<void> _updateHlcFromIncomingOperations( Future<void> _updateHlcFromIncomingOperations(
List<CrdtMergeChange> operations, List<CrdtMergeChange> operations,
Map<UuidValue, CrdtNode> remoteNodes, _MergeNodes remoteNodes,
Transaction transaction, Transaction transaction,
) async { ) async {
final maxIncomingHlc = operations.fold<Hlc?>( final maxIncomingHlc = operations.fold<Hlc?>(
null, null,
(current, change) => change.hlc.maxBetween(current), (current, change) => change.hlc.maxBetween(current),
); );
final nodesToUpdate = <CrdtNode>[]; final scopeNodesToUpdate = <CrdtScopeNode>[];
if (maxIncomingHlc != null) { if (maxIncomingHlc != null) {
final hlcManager = _getHlcManager(transaction); final hlcManager = _getHlcManager(transaction);
@ -110,7 +115,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
hlcManager.merge(maxIncomingHlc); hlcManager.merge(maxIncomingHlc);
} }
nodesToUpdate.add(hlcManager.getNode()); await _persistCurrentNodeHlc(hlcManager, transaction);
} }
final maxIncomingHlcByNode = <UuidValue, Hlc>{}; final maxIncomingHlcByNode = <UuidValue, Hlc>{};
@ -122,66 +127,59 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
for (final MapEntry(key: nodeId, value: incomingHlc) for (final MapEntry(key: nodeId, value: incomingHlc)
in maxIncomingHlcByNode.entries) { in maxIncomingHlcByNode.entries) {
final remoteNode = remoteNodes[nodeId]; final remoteNode = remoteNodes.scopeNodesByUuid[nodeId];
if (remoteNode == null) continue; if (remoteNode == null) continue;
final updatedNode = remoteNode.copyWith( final updatedScopeNode = remoteNode.copyWith(
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc), lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
); );
if (updatedNode.lastReceivedHlc == remoteNode.lastReceivedHlc) continue; if (updatedScopeNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
continue;
}
nodesToUpdate.add(updatedNode); scopeNodesToUpdate.add(updatedScopeNode);
remoteNodes[nodeId] = updatedNode; remoteNodes.scopeNodesByUuid[nodeId] = updatedScopeNode;
} }
if (nodesToUpdate.isNotEmpty) { if (scopeNodesToUpdate.isNotEmpty) {
await CrdtNode.db.update( await CrdtScopeNode.db.update(
_session, _session,
nodesToUpdate, scopeNodesToUpdate,
columns: (t) => [t.lastReceivedHlc], columns: (t) => [t.lastReceivedHlc],
transaction: transaction, transaction: transaction,
); );
} }
} }
Future<Map<UuidValue, CrdtNode>> _findOrCreateNodesForMerge( Future<_MergeNodes> _findOrCreateNodesForMerge(
int userId, int userId,
Set<UuidValue> nodeIds, Set<UuidValue> nodeIds,
Transaction transaction, Transaction transaction,
) async { ) async {
if (nodeIds.isEmpty) return {}; if (nodeIds.isEmpty) {
return (
var nodes = await CrdtNode.db.find( nodesByUuid: const <UuidValue, CrdtNode>{},
_session, scopeNodesByUuid: const <UuidValue, CrdtScopeNode>{},
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
transaction: transaction,
);
final existingNodeIds = nodes.map((node) => node.uuidNodeId).toSet();
final missingNodeIds = nodeIds.difference(existingNodeIds);
if (missingNodeIds.isNotEmpty) {
await CrdtNode.db.insert(
_session,
[
for (final uuidNodeId in missingNodeIds)
CrdtNode(
scopeId: userId,
uuidNodeId: uuidNodeId,
),
],
transaction: transaction,
ignoreConflicts: true,
);
nodes = await CrdtNode.db.find(
_session,
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
transaction: transaction,
); );
} }
return { final nodesByUuid = {
for (final node in nodes) node.uuidNodeId: node, for (final uuidNodeId in nodeIds)
uuidNodeId: await _findOrCreateNode(uuidNodeId, transaction),
}; };
final scopeNodesByUuid = {
for (final entry in nodesByUuid.entries)
entry.key: await _findOrCreateScopeNode(
userId,
entry.value.id!,
transaction,
),
};
return (
nodesByUuid: nodesByUuid,
scopeNodesByUuid: scopeNodesByUuid,
);
} }
Future<_MergeContext> _loadMergeContext( Future<_MergeContext> _loadMergeContext(

View file

@ -357,28 +357,23 @@ class CrdtMutationRecorder {
UuidValue otherNodeId, UuidValue otherNodeId,
Hlc syncedHlc, Hlc syncedHlc,
) async { ) async {
final user = await _scopeManager.getOrCreate(userId); final scope = await _scopeManager.getOrCreate(userId);
await _db.transaction((transaction) async { await _db.transaction((transaction) async {
var node = await CrdtNode.db.findFirstRow( final node = await _findOrCreateNode(otherNodeId, transaction);
_session, final scopeNode = await _findOrCreateScopeNode(
where: (t) => t.scopeId.equals(user.id) & t.uuidNodeId.equals(otherNodeId), scope.id!,
transaction: transaction, node.id!,
transaction,
); );
node ??= await CrdtNode.db.insertRow( final currentSyncHlc = scopeNode.lastReceivedHlc;
_session,
CrdtNode(scopeId: user.id!, uuidNodeId: otherNodeId),
transaction: transaction,
);
final currentSyncHlc = node.lastReceivedHlc;
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) { if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
return; return;
} }
await CrdtNode.db.updateRow( await CrdtScopeNode.db.updateRow(
_session, _session,
node.copyWith(lastReceivedHlc: syncedHlc), scopeNode.copyWith(lastReceivedHlc: syncedHlc),
columns: (t) => [t.lastReceivedHlc], columns: (t) => [t.lastReceivedHlc],
transaction: transaction, transaction: transaction,
); );
@ -607,11 +602,13 @@ WHERE c."id" IN ($whereRowIds)
if (rows.isEmpty) return; if (rows.isEmpty) return;
if (!isCrdtTracked<T>(rows.first.table)) return; if (!isCrdtTracked<T>(rows.first.table)) return;
final hlcManager = _getHlcManager(transaction);
await record( await record(
rows.first.table.tableName, rows.first.table.tableName,
rows.uuidRowIds, rows.uuidRowIds,
_getHlcManager(transaction), hlcManager,
); );
await _persistCurrentNodeHlc(hlcManager, transaction);
} }
CrdtDataRow _newCrdtDataRow( CrdtDataRow _newCrdtDataRow(
@ -1331,6 +1328,72 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
); );
} }
Future<void> _persistCurrentNodeHlc(
HlcManager hlcManager,
Transaction transaction,
) async {
await CrdtNode.db.updateRow(
_session,
hlcManager.getNode(),
columns: (t) => [t.lastHlc],
transaction: transaction,
);
}
Future<CrdtNode> _findOrCreateNode(
UuidValue uuidNodeId,
Transaction transaction,
) async {
var node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.uuidNodeId.equals(uuidNodeId),
transaction: transaction,
);
if (node != null) return node;
await CrdtNode.db.insert(
_session,
[CrdtNode(uuidNodeId: uuidNodeId)],
transaction: transaction,
ignoreConflicts: true,
);
node = await CrdtNode.db.findFirstRow(
_session,
where: (t) => t.uuidNodeId.equals(uuidNodeId),
transaction: transaction,
);
return node ?? (throw StateError('Could not create CRDT node "$uuidNodeId".'));
}
Future<CrdtScopeNode> _findOrCreateScopeNode(
int scopeId,
int nodeId,
Transaction transaction,
) async {
var scopeNode = await CrdtScopeNode.db.findFirstRow(
_session,
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
transaction: transaction,
);
if (scopeNode != null) return scopeNode;
await CrdtScopeNode.db.insert(
_session,
[CrdtScopeNode(scopeId: scopeId, nodeId: nodeId)],
transaction: transaction,
ignoreConflicts: true,
);
scopeNode = await CrdtScopeNode.db.findFirstRow(
_session,
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
transaction: transaction,
);
return scopeNode ??
(throw StateError(
'Could not create CRDT scope-node row for scope $scopeId and node $nodeId.',
));
}
CrdtScope _getEffectiveScope(Transaction transaction) { CrdtScope _getEffectiveScope(Transaction transaction) {
return scopeForQueries(transaction) ?? return scopeForQueries(transaction) ??
(throw StateError( (throw StateError(

View file

@ -170,3 +170,48 @@ extension _CrdtDatabaseScope on CrdtDatabase {
} }
} }
} }
/// Extension on [Table] that exposes [scopeEquals] for use in `where`
/// clauses to restrict membership-wide reads to a single scope.
extension ScopeUuidFilter on Table {
/// Expression that narrows a membership-wide read to [scopeUuid].
///
/// Reads are membership-wide and strip the database-local `scopeId`, so a
/// caller holding several scopes cannot otherwise restrict a query to one scope
/// or tell which scope a row came from. The placeholder is resolved to a
/// filter on the reserved `scopeId` column before the query runs. When the
/// scope UUID is not known locally the filter matches no rows.
///
/// Example:
/// ```dart
/// // Returns only persons in the shared scope.
/// final sharedPeople = await Person.db.find(
/// session,
/// where: (t) => t.scopeEquals(sharedScopeUuid),
/// );
///
/// // Combines with other filters.
/// final aliceInShared = await Person.db.find(
/// session,
/// where: (t) => t.name.equals('Alice') & t.scopeEquals(sharedScopeUuid),
/// );
/// ```
Expression scopeEquals(UuidValue scopeUuid) =>
_crdtScope.uuidScopeId.equals(scopeUuid);
CrdtScopeTable get _crdtScope {
return createRelationTable<CrdtScopeTable>(
relationFieldName: '${tableName}_crdt_scope',
field: _scopeIdColumn,
foreignField: CrdtScope.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
CrdtScopeTable(tableRelation: foreignTableRelation),
);
}
Column get _scopeIdColumn => columns.singleWhere(
(column) => column.columnName == 'scopeId' && column is ColumnInt,
orElse: () => throw StateError('Table "$tableName" has no scopeId column.'),
);
}

Some files were not shown because too many files have changed in this diff Show more