feat: Implement shared scopes with role enforcement (#90)
This commit is contained in:
parent
517abde4a1
commit
8acfa7f269
54 changed files with 4868 additions and 485 deletions
|
|
@ -94,6 +94,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
|||
CrdtMergeInsert insertChangeFor(TableRow<UuidValue?> row) {
|
||||
final hlc = _nextRemoteHlc();
|
||||
return CrdtMergeInsert(
|
||||
uuidScopeId: _userId,
|
||||
tableName: row.table.tableName,
|
||||
uuidRowId: row.id!,
|
||||
uuidNodeId: hlc.nodeId,
|
||||
|
|
@ -110,6 +111,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
|||
) {
|
||||
final hlc = _nextRemoteHlc();
|
||||
return CrdtMergeUpdate(
|
||||
uuidScopeId: _userId,
|
||||
tableName: row.table.tableName,
|
||||
uuidRowId: row.id!,
|
||||
uuidNodeId: hlc.nodeId,
|
||||
|
|
@ -123,6 +125,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
|||
CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) {
|
||||
final hlc = _nextRemoteHlc();
|
||||
return CrdtMergeDelete(
|
||||
uuidScopeId: _userId,
|
||||
tableName: row.table.tableName,
|
||||
uuidRowId: row.id!,
|
||||
uuidNodeId: hlc.nodeId,
|
||||
|
|
|
|||
|
|
@ -107,12 +107,14 @@ class QueryCountingDatabase implements Database {
|
|||
List<T> rows, {
|
||||
Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.insert<T>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +134,7 @@ class QueryCountingDatabase implements Database {
|
|||
List<Column>? updateColumns,
|
||||
Expression? updateWhere,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.upsert<T>(
|
||||
|
|
@ -140,6 +143,7 @@ class QueryCountingDatabase implements Database {
|
|||
updateColumns: updateColumns,
|
||||
updateWhere: updateWhere,
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -166,9 +170,15 @@ class QueryCountingDatabase implements Database {
|
|||
List<T> rows, {
|
||||
List<Column>? columns,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.update<T>(rows, columns: columns, transaction: transaction);
|
||||
return _delegate.update<T>(
|
||||
rows,
|
||||
columns: columns,
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -205,6 +215,7 @@ class QueryCountingDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.updateWhere<T>(
|
||||
|
|
@ -218,6 +229,7 @@ class QueryCountingDatabase implements Database {
|
|||
// ignore: deprecated_member_use
|
||||
orderDescending: orderDescending,
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +240,7 @@ class QueryCountingDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.delete<T>(
|
||||
|
|
@ -238,6 +251,7 @@ class QueryCountingDatabase implements Database {
|
|||
// ignore: deprecated_member_use
|
||||
orderDescending: orderDescending,
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +271,7 @@ class QueryCountingDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
queryCount++;
|
||||
return _delegate.deleteWhere<T>(
|
||||
|
|
@ -267,6 +282,7 @@ class QueryCountingDatabase implements Database {
|
|||
// ignore: deprecated_member_use
|
||||
orderDescending: orderDescending,
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
''');
|
||||
await db.unsafeExecute('''
|
||||
INSERT INTO "crdt_nodes" ("scopeId")
|
||||
SELECT u."id" FROM "crdt_scopes" u
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "crdt_nodes" n WHERE n."scopeId" = u."id")
|
||||
WITH local_node(id) AS (
|
||||
SELECT "currentNodeId" FROM "crdt_scopes" WHERE "id" = $benchUserId
|
||||
)
|
||||
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
|
||||
// 10% tombstoned. Random UUIDs keep them unrelated to the domain rows.
|
||||
await db.unsafeExecute('''
|
||||
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1),
|
||||
users(rowidx, uid) AS (
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id"
|
||||
FROM "crdt_scopes" WHERE "id" <> $benchUserId
|
||||
users(rowidx, uid, nodeId) AS (
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY s."id") - 1, s."id", sn."nodeId"
|
||||
FROM "crdt_scopes" s
|
||||
JOIN "crdt_scope_nodes" sn ON sn."scopeId" = s."id"
|
||||
WHERE s."id" <> $benchUserId
|
||||
),
|
||||
tbls(tblidx, tid) AS (
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
|
||||
)
|
||||
INSERT INTO "crdt_data_rows"
|
||||
("hlcDatetime", "hlcCounter", "scopeId", "tblId", "uuidRowId", "nodeId", "visibility")
|
||||
SELECT 0, 0, u.uid, t.tid, randomblob(16),
|
||||
(SELECT nd."id" FROM "crdt_nodes" nd WHERE nd."scopeId" = u.uid LIMIT 1),
|
||||
SELECT 0, 0, u.uid, t.tid, randomblob(16), u.nodeId,
|
||||
CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END
|
||||
FROM n
|
||||
JOIN users u ON u.rowidx = n.i % $noiseUsers
|
||||
|
|
|
|||
104
docs/outbound-collection-consistency.md
Normal file
104
docs/outbound-collection-consistency.md
Normal 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.
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
# Row ownership and scope isolation
|
||||
|
||||
Status: implemented.
|
||||
The *Future: shared scopes* section is direction, not commitment.
|
||||
|
||||
## Summary
|
||||
|
||||
|
|
@ -20,9 +19,8 @@ This document makes ownership explicit. The core decisions:
|
|||
> never materialize or sync the same `(table, uuidRowId)`. Cross-scope UUID
|
||||
> reuse is a terminal ownership collision, not valid multi-tenant state. Sync
|
||||
> fails on that collision, records a durable violation, and does not advance
|
||||
> the sender past the rejected change. Sharing, when it arrives, will be
|
||||
> modeled as shared scopes that multiple identities subscribe to — never as
|
||||
> two scopes aliasing one row.
|
||||
> the sender past the rejected change. Sharing is modeled as shared scopes that
|
||||
> multiple identities subscribe to — never as two scopes aliasing one row.
|
||||
|
||||
> **Every synced table declares `scopeId`, identically on server and
|
||||
> 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
|
||||
model cannot represent what multi-scope tracking promises, so allowing it is
|
||||
not granting flexibility — it is permitting silent cross-tenant data
|
||||
corruption. Applications that need shared data need shared *scopes* (see
|
||||
*Future: shared scopes*), which preserve the one-chain-per-row property that
|
||||
makes LWW merging coherent.
|
||||
corruption. Applications that need shared data use shared *scopes* (see
|
||||
`shared-scopes.md`), which preserve the one-chain-per-row property that makes
|
||||
LWW merging coherent.
|
||||
|
||||
## The `scopeId` column
|
||||
|
||||
|
|
@ -475,9 +473,7 @@ actually key — scopes:
|
|||
- **Internals and storage** adopt scope naming in one sweep: `crdt_users` →
|
||||
`crdt_scopes` (`CrdtUser` → `CrdtScope`, `uuidUserId` → `uuidScopeId`),
|
||||
`crdt_data_rows.userId` → `scopeId` (matching the domain column),
|
||||
`userForTransaction`, the effective-user helpers, and the HLC manager
|
||||
keys. Doing this now is what keeps the *Future: shared scopes* section a
|
||||
pure addition instead of a rename project.
|
||||
`userForTransaction`, the effective-user helpers, and the HLC manager keys.
|
||||
- **Unscoped reads** remain supported as admin reads.
|
||||
|
||||
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
|
||||
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
|
||||
chains, and visibility. Sharing becomes an indirection in front of it, not a
|
||||
|
|
@ -575,9 +571,9 @@ change to row ownership:
|
|||
changes.
|
||||
|
||||
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
|
||||
of this section sketches the direction in enough detail to keep today's
|
||||
decisions compatible with it; it is not committed design.
|
||||
reason the column and concept are named `scope` rather than `user`.
|
||||
Implementation details and current limits live in `shared-scopes.md`; the rest
|
||||
of this section summarizes why the ownership rules remain compatible.
|
||||
|
||||
### Users authenticate, scopes act
|
||||
|
||||
|
|
@ -641,15 +637,18 @@ filter and RLS become two enforcements of one predicate.
|
|||
|
||||
### Sync and clients
|
||||
|
||||
- A device syncs one chain per scope it holds: the sync session (or one
|
||||
session per scope) enumerates the authenticated user's member scopes,
|
||||
verified server-side against `crdt_scope_members` at the handshake; the
|
||||
membership table itself is not synced.
|
||||
- A device syncs one chain per scope it holds: each sync call enumerates the
|
||||
authenticated user's member scopes, verified server-side against
|
||||
`crdt_scope_members` at the start of every sync cycle; the membership table
|
||||
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
|
||||
universal — so a sharing-enabled client simply holds several scopes' rows.
|
||||
One database file per scope remains a valid alternative layout.
|
||||
- Checkpoints stay per `(scope, node)`; a device is a node in each scope's
|
||||
chain it participates in.
|
||||
- Checkpoints stay per `(scope, node)`: `crdt_nodes` stores the stable replica
|
||||
identity, while `crdt_scope_nodes` stores each scope's checkpoint for that
|
||||
node.
|
||||
|
||||
### What sharing does not change
|
||||
|
||||
|
|
@ -729,12 +728,15 @@ not relitigated by accident:
|
|||
application with a pre-existing, semantically different `scopeId` column
|
||||
on a synced table has no escape hatch other than renaming its column.
|
||||
Acceptable for now; revisit if it bites real users.
|
||||
3. **Membership revocation semantics (sharing).** When a user loses
|
||||
membership of a scope, the server stops streaming it at the next
|
||||
handshake, but the device still holds the scope's data and may hold
|
||||
unsynced local changes for it. Purge-vs-keep policy, and whether revoked
|
||||
unsynced changes are surfaced or dropped, need their own design.
|
||||
4. **Roles within a scope (sharing).** `crdt_scope_members.role` is sketched
|
||||
but undefined: read-only members would need write rejection at merge time
|
||||
(another fail-and-record case), and the interaction with offline-written
|
||||
changes by a demoted member overlaps with question 3.
|
||||
3. **Membership revocation semantics (sharing).** When a user loses membership
|
||||
of a scope, the server stops streaming it at the next sync cycle. 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 unsynced changes are surfaced or dropped, need their own
|
||||
design.
|
||||
4. **Roles within a scope (sharing).** Resolved in `shared-scopes.md`.
|
||||
`crdt_scope_members.role` is enforced for writes through the closed
|
||||
`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.
|
||||
|
|
|
|||
208
docs/scope-management-api.md
Normal file
208
docs/scope-management-api.md
Normal 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
638
docs/shared-scopes.md
Normal 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`.
|
||||
|
|
@ -286,7 +286,8 @@ class DemoController extends ChangeNotifier {
|
|||
try {
|
||||
await session.syncOnce(
|
||||
client,
|
||||
onMergeSuccess: (hlc) => _handleReplicaMerge(slot, hlc),
|
||||
onMergeSuccess: (scopeUuid, hlc) =>
|
||||
_handleReplicaMerge(slot, scopeUuid, hlc),
|
||||
);
|
||||
state.phase = SyncPhase.idle;
|
||||
state.lastSyncedLabel = 'synced ${_timeLabel()}';
|
||||
|
|
@ -312,7 +313,8 @@ class DemoController extends ChangeNotifier {
|
|||
() async {
|
||||
final stream = session.syncContinuously(
|
||||
client,
|
||||
onMergeSuccess: (hlc) => unawaited(_handleReplicaMerge(slot, hlc)),
|
||||
onMergeSuccess: (scopeUuid, hlc) =>
|
||||
unawaited(_handleReplicaMerge(slot, scopeUuid, hlc)),
|
||||
);
|
||||
state.stream = stream;
|
||||
state.phase = SyncPhase.streaming;
|
||||
|
|
@ -418,8 +420,12 @@ class DemoController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _handleReplicaMerge(ReplicaSlot slot, Object hlc) async {
|
||||
replicas[slot]!.lastSyncedLabel = 'merged $hlc';
|
||||
Future<void> _handleReplicaMerge(
|
||||
ReplicaSlot slot,
|
||||
offline.UuidValue scopeUuid,
|
||||
Object hlc,
|
||||
) async {
|
||||
replicas[slot]!.lastSyncedLabel = 'merged $scopeUuid at $hlc';
|
||||
await _refreshReplica(slot, notify: false);
|
||||
await _fetchServer(notify: false);
|
||||
replicas[slot]!.changed();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export 'src/crdt/client_sync.dart';
|
||||
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
|
||||
export 'src/crdt/merge.dart';
|
||||
export 'src/crdt/scope_membership.dart';
|
||||
export 'src/crdt/sync.dart';
|
||||
export 'src/database/database.dart';
|
||||
export 'src/database/recorder.dart';
|
||||
|
|
|
|||
|
|
@ -68,7 +68,12 @@ class CrdtSyncClient {
|
|||
);
|
||||
|
||||
final subscription = session.crdtDb
|
||||
.sync(inbound: remoteStream, once: once, onMergeSuccess: onMergeSuccess)
|
||||
.sync(
|
||||
inbound: remoteStream,
|
||||
once: once,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
mode: CrdtSyncPeerMode.follower,
|
||||
)
|
||||
.listen(
|
||||
outboundChanges.addIfNotClosed,
|
||||
onDone: doneCompleter.completeOrSkip,
|
||||
|
|
|
|||
|
|
@ -43,26 +43,6 @@ final class CrdtSyncUnexpectedEventException<T extends CrdtSyncStreamEvent>
|
|||
'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.
|
||||
final class SyncTablesHashMismatchException extends CrdtSyncException {
|
||||
/// Creates a [SyncTablesHashMismatchException].
|
||||
|
|
@ -109,10 +89,58 @@ final class CrdtSyncIntegrityViolationException extends CrdtSyncException {
|
|||
'${violation.type.name}/${violation.operation.name} references '
|
||||
'missing domain row $row for scope '
|
||||
'${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
|
||||
final class PendingOutboundIntegrityViolation implements Exception {
|
||||
const PendingOutboundIntegrityViolation({
|
||||
|
|
|
|||
|
|
@ -69,3 +69,12 @@ extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
|
|||
/// start with 'user' are synced.
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,38 +20,53 @@ typedef CrdtMergeMetadataLookup = ({
|
|||
extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> {
|
||||
/// Collects the next framed sync batch from this iterator.
|
||||
///
|
||||
/// Each batch is zero or more [CrdtSyncMergeChunk] events followed by
|
||||
/// [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
|
||||
/// Each batch is zero or more scope, handshake, and/or merge frames followed
|
||||
/// by [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
|
||||
/// empty batch is returned.
|
||||
///
|
||||
/// When [allowCloseBeforeBatch] is true, returns `null` if the stream closes
|
||||
/// before the next batch starts. Otherwise, closing before a batch starts is
|
||||
/// treated as a [CrdtSyncStreamClosedException].
|
||||
/// When [allowCloseBeforeBatch] is true, returns `null` if the transport
|
||||
/// stream closes before the next batch starts. [CrdtSyncClose] is an
|
||||
/// 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
|
||||
/// partial batch is still treated as an error.
|
||||
Future<CrdtMergeSet?> collectNextBatch({
|
||||
/// If the transport stream closes after a frame was already received without
|
||||
/// a [CrdtSyncClose] control frame, the partial batch is still treated as an
|
||||
/// 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,
|
||||
}) async {
|
||||
final mergeSet = <CrdtMergeChange>[];
|
||||
final batch = CrdtSyncCycleBatch();
|
||||
|
||||
while (await moveNext()) {
|
||||
switch (current) {
|
||||
case CrdtSyncMergeChunk(changes: final changes):
|
||||
mergeSet.addAll(changes);
|
||||
case final CrdtSyncScopeSet event:
|
||||
batch.scopeSet = event;
|
||||
case final CrdtSyncSinceHlc event:
|
||||
batch.sinceHlcs[event.uuidScopeId] = event;
|
||||
case CrdtSyncMergeChunk(:final changes):
|
||||
batch.changes.addAll(changes);
|
||||
case CrdtSyncIdleTimeout():
|
||||
if (mergeSet.isEmpty) return mergeSet;
|
||||
if (batch.isEmpty) return batch;
|
||||
case CrdtSyncEndOfBatch():
|
||||
return mergeSet;
|
||||
return batch;
|
||||
case CrdtSyncClose():
|
||||
return null;
|
||||
default:
|
||||
throw CrdtSyncUnexpectedEventException(
|
||||
expected: '"CrdtSyncMergeChunk" or "CrdtSyncEndOfBatch"',
|
||||
expected: 'a sync cycle frame',
|
||||
received: current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeSet.isEmpty && allowCloseBeforeBatch) return null;
|
||||
if (batch.isEmpty && allowCloseBeforeBatch) return null;
|
||||
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.
|
||||
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
|
||||
/// Emits lists with at most [batchSize] changes from this stream.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,9 +13,14 @@ import 'exceptions.dart';
|
|||
import 'extensions.dart';
|
||||
import 'integrity_violation.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.
|
||||
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.
|
||||
typedef DomainRowOwner = ({bool exists, int? scopeId});
|
||||
|
|
@ -123,7 +128,7 @@ class CrdtSync {
|
|||
'${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
|
||||
/// 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
|
||||
/// projection materializes only the safe visible value into domain tables.
|
||||
///
|
||||
/// All changes for nodes that are not present in the [nodeCheckpoints] list
|
||||
/// are collected and emitted. Passing an empty list will collect all changes.
|
||||
/// Resolves the scopes' internal ids once and keeps their checkpoint vectors
|
||||
/// 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(
|
||||
DatabaseSession session, {
|
||||
required UuidValue userId,
|
||||
required UuidValue peerNodeId,
|
||||
required List<Hlc> nodeCheckpoints,
|
||||
required Map<UuidValue, List<Hlc>> checkpointsByScopeUuid,
|
||||
}) 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 {
|
||||
await for (final change in _streamPendingChanges(
|
||||
session,
|
||||
crdtUser,
|
||||
nodeCheckpoints,
|
||||
scopeUuidById,
|
||||
checkpointsByScopeId,
|
||||
)) {
|
||||
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
|
||||
/// has received from each known node, tagged with the source node id.
|
||||
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
|
||||
DatabaseSession session, {
|
||||
required UuidValue userId,
|
||||
required UuidValue peerNodeId,
|
||||
required UuidValue scopeId,
|
||||
}) 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,
|
||||
where: (t) =>
|
||||
t.scopeId.equals(crdtUser.id) &
|
||||
t.uuidNodeId.notEquals(crdtUser.currentNode!.uuidNodeId),
|
||||
t.scopeId.equals(scope.id) & t.nodeId.notEquals(scope.currentNodeId),
|
||||
include: CrdtScopeNode.include(node: CrdtNode.include()),
|
||||
);
|
||||
|
||||
return CrdtSyncSinceHlc(
|
||||
uuidScopeId: scopeId,
|
||||
nodeCheckpoints: [
|
||||
// The local node is always included to avoid collecting its own changes.
|
||||
Hlc.now(crdtUser.currentNode!.uuidNodeId),
|
||||
for (final node in nodes) node.lastReceivedHlc ?? Hlc.zero(node.uuidNodeId),
|
||||
Hlc.now(localNodeId),
|
||||
for (final scopeNode in scopeNodes)
|
||||
scopeNode.lastReceivedHlc ?? Hlc.zero(scopeNode.node!.uuidNodeId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -192,69 +218,52 @@ class CrdtSync {
|
|||
///
|
||||
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
|
||||
/// empty.
|
||||
Future<Hlc?> mergeInboundBatch(
|
||||
Future<Hlc?> _mergeInboundBatch(
|
||||
DatabaseSession session, {
|
||||
required UuidValue userId,
|
||||
required UuidValue scopeId,
|
||||
required UuidValue otherNodeId,
|
||||
required CrdtMergeSet mergeSet,
|
||||
}) async {
|
||||
if (mergeSet.isEmpty) return null;
|
||||
final maxSyncedHlc = mergeSet.maxHlc;
|
||||
final crdtDb = _openCrdtDatabase(session);
|
||||
await crdtDb.mergeChanges(mergeSet, scopeId: userId);
|
||||
await crdtDb.mergeChanges(mergeSet, scopeId: scopeId);
|
||||
if (maxSyncedHlc != null) {
|
||||
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: userId);
|
||||
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: scopeId);
|
||||
}
|
||||
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.
|
||||
///
|
||||
/// Both peers send [CrdtSyncConnect], read the peer connect frame, then send
|
||||
/// and read [CrdtSyncSinceHlc] once. After that, both sides exchange framed
|
||||
/// data batches using the peer's per-node checkpoints. Sent changes advance
|
||||
/// the corresponding in-memory checkpoints for subsequent sends within the
|
||||
/// session.
|
||||
/// Both peers exchange [CrdtSyncConnect] and a lockstep [CrdtSyncScopeSet]
|
||||
/// before the data loop. Each cycle sends a combined batch — scope
|
||||
/// announcement when grants changed, [CrdtSyncSinceHlc] for newly active
|
||||
/// scopes, and [CrdtSyncMergeChunk]s — closed by [CrdtSyncEndOfBatch] when
|
||||
/// 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.
|
||||
/// The next sync attempt resumes from the last persisted checkpoint on the
|
||||
/// side that failed to merge.
|
||||
///
|
||||
/// 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.
|
||||
/// When [once] is true the loop may run an extra cycle after handshakes
|
||||
/// complete so merge data can flow; then it closes symmetrically. Continuous
|
||||
/// mode loops with [_continuousSyncInterval] between idle cycles.
|
||||
Stream<CrdtSyncStreamEvent> sync(
|
||||
DatabaseSession session, {
|
||||
required UuidValue userId,
|
||||
required Stream<CrdtSyncStreamEvent> inbound,
|
||||
required CrdtSyncPeerMode mode,
|
||||
bool once = false,
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) 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(
|
||||
// Inject a timeout event if the inbound stream is idle for too long to
|
||||
// allow returning an empty batch and waiting for a change without closing
|
||||
// the stream.
|
||||
inbound.timeout(
|
||||
once
|
||||
? inbound
|
||||
: inbound.timeout(
|
||||
const Duration(seconds: 1),
|
||||
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
|
||||
),
|
||||
|
|
@ -262,44 +271,58 @@ class CrdtSync {
|
|||
|
||||
var sessionCompleted = false;
|
||||
try {
|
||||
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
|
||||
final localNodeId = crdtUser.currentNode!.uuidNodeId;
|
||||
|
||||
final scope = await CrdtScopeManager(session).getOrCreate(userId);
|
||||
final localNodeId = scope.currentNode!.uuidNodeId;
|
||||
yield CrdtSyncConnect(
|
||||
localNodeId: localNodeId,
|
||||
syncTablesHash: currentSyncTablesHash,
|
||||
);
|
||||
|
||||
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
|
||||
final peerNodeId = peerConnect.localNodeId;
|
||||
_validateSyncTablesHash(peerConnect.syncTablesHash);
|
||||
|
||||
yield await createSyncSinceHlc(
|
||||
final scopes = CrdtScopeSyncSession(
|
||||
session,
|
||||
userId: userId,
|
||||
peerNodeId: peerNodeId,
|
||||
mode: mode,
|
||||
peerNodeId: peerConnect.localNodeId,
|
||||
);
|
||||
|
||||
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>();
|
||||
final nodeCheckpoints = {
|
||||
for (final checkpoint in peerSinceHlc.nodeCheckpoints)
|
||||
checkpoint.nodeId: checkpoint,
|
||||
};
|
||||
await scopes.reconcile();
|
||||
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
|
||||
scopes.markAnnounced();
|
||||
final peerScopeSet = await inboundIterator.moveAndThrowIfNot<CrdtSyncScopeSet>();
|
||||
await scopes.adoptPeerGrants(peerScopeSet.scopes);
|
||||
|
||||
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(
|
||||
session,
|
||||
userId: userId,
|
||||
peerNodeId: peerNodeId,
|
||||
nodeCheckpoints: nodeCheckpoints.values.toList(),
|
||||
checkpointsByScopeUuid: scopes.sendableCheckpoints,
|
||||
);
|
||||
|
||||
var hasChanges = false;
|
||||
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
|
||||
hasChanges = true;
|
||||
for (final change in changes) {
|
||||
final lastCheckpoint = nodeCheckpoints[change.uuidNodeId];
|
||||
nodeCheckpoints[change.uuidNodeId] = change.hlc.maxBetween(lastCheckpoint);
|
||||
scopes.advanceCheckpoint(change.uuidScopeId, change);
|
||||
outboundScopes.add(change.uuidScopeId);
|
||||
}
|
||||
yield CrdtSyncMergeChunk(changes: changes);
|
||||
}
|
||||
|
|
@ -307,28 +330,32 @@ class CrdtSync {
|
|||
yield CrdtSyncEndOfBatch();
|
||||
}
|
||||
|
||||
final inboundMergeSet = await inboundIterator.collectNextBatch(
|
||||
final batch = await inboundIterator.collectNextBatch(
|
||||
allowCloseBeforeBatch: !once,
|
||||
);
|
||||
if (inboundMergeSet == null) {
|
||||
if (batch == null) {
|
||||
if (once) {
|
||||
yield CrdtSyncClose();
|
||||
}
|
||||
sessionCompleted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final lastReceivedFromPeer = await mergeInboundBatch(
|
||||
await _applyCycleBatch(
|
||||
session,
|
||||
userId: userId,
|
||||
otherNodeId: peerNodeId,
|
||||
mergeSet: inboundMergeSet,
|
||||
scopes,
|
||||
batch,
|
||||
outboundScopes,
|
||||
onMergeSuccess,
|
||||
);
|
||||
|
||||
if (hasChanges || inboundMergeSet.isNotEmpty) {
|
||||
await onMergeSuccess?.call(
|
||||
nodeCheckpoints.values.max.maxBetween(lastReceivedFromPeer),
|
||||
);
|
||||
}
|
||||
|
||||
if (once) {
|
||||
if (scopes.hasIncompleteActiveHandshake) {
|
||||
continue;
|
||||
}
|
||||
if (!hadSendableCheckpoints && scopes.sendableCheckpoints.isNotEmpty) {
|
||||
continue;
|
||||
}
|
||||
yield CrdtSyncClose();
|
||||
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
|
||||
sessionCompleted = true;
|
||||
|
|
@ -349,6 +376,17 @@ class CrdtSync {
|
|||
// Wait for the configured interval before checking for local changes again.
|
||||
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 {
|
||||
// Cancelling inbound on normal completion races with WebSocket stream
|
||||
// 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.
|
||||
///
|
||||
/// Used to settle the inbound transport after the `once` close handshake so
|
||||
|
|
@ -400,26 +544,26 @@ class CrdtSync {
|
|||
|
||||
Stream<CrdtMergeChange> _streamPendingChanges(
|
||||
DatabaseSession session,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, UuidValue> scopeUuidById,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
) async* {
|
||||
// Domain ownership is immutable while a collection runs, so read each
|
||||
// row's owner at most once across all three streams.
|
||||
final ownerCache = DomainRowOwnerCache();
|
||||
yield* _streamInserts(session, crdtUser, nodeCheckpoints, ownerCache);
|
||||
yield* _streamUpdates(session, crdtUser, nodeCheckpoints, ownerCache);
|
||||
yield* _streamDeletes(session, crdtUser, nodeCheckpoints, ownerCache);
|
||||
yield* _streamInserts(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||
yield* _streamUpdates(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||
yield* _streamDeletes(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||
}
|
||||
|
||||
Stream<CrdtMergeInsert> _streamInserts(
|
||||
DatabaseSession session,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, UuidValue> scopeUuidById,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
DomainRowOwnerCache ownerCache,
|
||||
) async* {
|
||||
final rows = await CrdtDataRow.db.find(
|
||||
session,
|
||||
where: (t) => _rowHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
||||
where: (t) => _rowHlcAfterFilter(t, checkpointsByScopeId),
|
||||
include: CrdtDataRow.include(
|
||||
tbl: CrdtSchemaTable.include(),
|
||||
node: CrdtNode.include(),
|
||||
|
|
@ -439,6 +583,9 @@ class CrdtSync {
|
|||
final dartName = _classNamesByTableName[tableName];
|
||||
if (dartName == null) continue;
|
||||
|
||||
final scopeId = row.scopeId;
|
||||
final scopeUuid = scopeUuidById[scopeId]!;
|
||||
|
||||
final domainRow = await _fetchDomainRow(
|
||||
session,
|
||||
tableName,
|
||||
|
|
@ -446,7 +593,7 @@ class CrdtSync {
|
|||
table,
|
||||
dartName,
|
||||
foreignKeyAttemptFieldsByRowId[row.id!],
|
||||
crdtUser.id!,
|
||||
scopeId,
|
||||
ownerCache,
|
||||
);
|
||||
if (!domainRow.exists) {
|
||||
|
|
@ -457,12 +604,12 @@ class CrdtSync {
|
|||
tableName: tableName,
|
||||
rowId: row.uuidRowId,
|
||||
ownerScopeId: null,
|
||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
||||
incomingScopeUuid: scopeUuid,
|
||||
uuidNodeId: row.node!.uuidNodeId,
|
||||
hlc: row.hlc,
|
||||
);
|
||||
}
|
||||
if (domainRow.ownerScopeId != crdtUser.id) {
|
||||
if (domainRow.ownerScopeId != scopeId) {
|
||||
_throwPendingIntegrityViolation(
|
||||
crdtDataRowId: row.id,
|
||||
type: CrdtSyncViolationType.ownershipCollision,
|
||||
|
|
@ -470,13 +617,14 @@ class CrdtSync {
|
|||
tableName: tableName,
|
||||
rowId: row.uuidRowId,
|
||||
ownerScopeId: domainRow.ownerScopeId,
|
||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
||||
incomingScopeUuid: scopeUuid,
|
||||
uuidNodeId: row.node!.uuidNodeId,
|
||||
hlc: row.hlc,
|
||||
);
|
||||
}
|
||||
|
||||
yield CrdtMergeInsert(
|
||||
uuidScopeId: scopeUuid,
|
||||
hlcDatetime: row.hlcDatetime,
|
||||
hlcCounter: row.hlcCounter,
|
||||
tableName: tableName,
|
||||
|
|
@ -489,13 +637,13 @@ class CrdtSync {
|
|||
|
||||
Stream<CrdtMergeUpdate> _streamUpdates(
|
||||
DatabaseSession session,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, UuidValue> scopeUuidById,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
DomainRowOwnerCache ownerCache,
|
||||
) async* {
|
||||
final fields = await CrdtDataField.db.find(
|
||||
session,
|
||||
where: (t) => _fieldHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
||||
where: (t) => _fieldHlcAfterFilter(t, checkpointsByScopeId),
|
||||
include: CrdtDataField.include(
|
||||
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
||||
column: CrdtSchemaColumn.include(),
|
||||
|
|
@ -513,6 +661,8 @@ class CrdtSync {
|
|||
continue;
|
||||
}
|
||||
|
||||
final scopeId = field.row!.scopeId;
|
||||
final scopeUuid = scopeUuidById[scopeId]!;
|
||||
final columnName = field.column!.name;
|
||||
final columnValue = await _fetchOwnedColumnValue(
|
||||
session,
|
||||
|
|
@ -520,7 +670,7 @@ class CrdtSync {
|
|||
field.row!.uuidRowId,
|
||||
columnName,
|
||||
field.foreignKey,
|
||||
crdtUser.id!,
|
||||
scopeId,
|
||||
ownerCache,
|
||||
);
|
||||
if (!columnValue.exists) {
|
||||
|
|
@ -531,12 +681,12 @@ class CrdtSync {
|
|||
tableName: tableName,
|
||||
rowId: field.row!.uuidRowId,
|
||||
ownerScopeId: null,
|
||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
||||
incomingScopeUuid: scopeUuid,
|
||||
uuidNodeId: field.node!.uuidNodeId,
|
||||
hlc: field.hlc,
|
||||
);
|
||||
}
|
||||
if (columnValue.ownerScopeId != crdtUser.id) {
|
||||
if (columnValue.ownerScopeId != scopeId) {
|
||||
_throwPendingIntegrityViolation(
|
||||
crdtDataRowId: field.row!.id,
|
||||
type: CrdtSyncViolationType.ownershipCollision,
|
||||
|
|
@ -544,13 +694,14 @@ class CrdtSync {
|
|||
tableName: tableName,
|
||||
rowId: field.row!.uuidRowId,
|
||||
ownerScopeId: columnValue.ownerScopeId,
|
||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
||||
incomingScopeUuid: scopeUuid,
|
||||
uuidNodeId: field.node!.uuidNodeId,
|
||||
hlc: field.hlc,
|
||||
);
|
||||
}
|
||||
|
||||
yield CrdtMergeUpdate(
|
||||
uuidScopeId: scopeUuid,
|
||||
hlcDatetime: field.hlcDatetime,
|
||||
hlcCounter: field.hlcCounter,
|
||||
tableName: tableName,
|
||||
|
|
@ -564,13 +715,13 @@ class CrdtSync {
|
|||
|
||||
Stream<CrdtMergeDelete> _streamDeletes(
|
||||
DatabaseSession session,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, UuidValue> scopeUuidById,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
DomainRowOwnerCache ownerCache,
|
||||
) async* {
|
||||
final tombstones = await CrdtDataDeleted.db.find(
|
||||
session,
|
||||
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
||||
where: (t) => _tombstoneHlcAfterFilter(t, checkpointsByScopeId),
|
||||
include: CrdtDataDeleted.include(
|
||||
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
||||
node: CrdtNode.include(),
|
||||
|
|
@ -582,13 +733,16 @@ class CrdtSync {
|
|||
|
||||
final tableName = tombstone.row!.tbl!.name;
|
||||
if (!_syncTablesByName.containsKey(tableName)) continue;
|
||||
|
||||
final scopeId = tombstone.row!.scopeId;
|
||||
final scopeUuid = scopeUuidById[scopeId]!;
|
||||
final owner = await _readDomainRowOwner(
|
||||
session,
|
||||
tableName,
|
||||
tombstone.row!.uuidRowId,
|
||||
ownerCache,
|
||||
);
|
||||
if (owner.exists && owner.scopeId != crdtUser.id) {
|
||||
if (owner.exists && owner.scopeId != scopeId) {
|
||||
_throwPendingIntegrityViolation(
|
||||
crdtDataRowId: tombstone.row!.id,
|
||||
type: CrdtSyncViolationType.ownershipCollision,
|
||||
|
|
@ -596,13 +750,14 @@ class CrdtSync {
|
|||
tableName: tableName,
|
||||
rowId: tombstone.row!.uuidRowId,
|
||||
ownerScopeId: owner.scopeId,
|
||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
||||
incomingScopeUuid: scopeUuid,
|
||||
uuidNodeId: tombstone.node!.uuidNodeId,
|
||||
hlc: tombstone.hlc,
|
||||
);
|
||||
}
|
||||
|
||||
yield CrdtMergeDelete(
|
||||
uuidScopeId: scopeUuid,
|
||||
hlcDatetime: tombstone.hlcDatetime,
|
||||
hlcCounter: tombstone.hlcCounter,
|
||||
tableName: tableName,
|
||||
|
|
@ -616,41 +771,41 @@ class CrdtSync {
|
|||
|
||||
Expression _rowHlcAfterFilter(
|
||||
CrdtDataRowTable t,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
) =>
|
||||
t.scopeId.equals(crdtUser.id) &
|
||||
t.node.afterAnyCheckpointFilter(
|
||||
t.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||
_afterAnyScopeCheckpointFilter(
|
||||
t.scopeId,
|
||||
t.node.uuidNodeId,
|
||||
t.hlcDatetime,
|
||||
t.hlcCounter,
|
||||
nodeCheckpoints,
|
||||
checkpointsByScopeId,
|
||||
);
|
||||
|
||||
Expression _fieldHlcAfterFilter(
|
||||
CrdtDataFieldTable t,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
) =>
|
||||
t.row.scopeId.equals(crdtUser.id) &
|
||||
t.node.afterAnyCheckpointFilter(
|
||||
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||
_afterAnyScopeCheckpointFilter(
|
||||
t.row.scopeId,
|
||||
t.node.uuidNodeId,
|
||||
t.hlcDatetime,
|
||||
t.hlcCounter,
|
||||
nodeCheckpoints,
|
||||
checkpointsByScopeId,
|
||||
);
|
||||
|
||||
Expression _tombstoneHlcAfterFilter(
|
||||
CrdtDataDeletedTable t,
|
||||
CrdtScope crdtUser,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
) =>
|
||||
t.row.scopeId.equals(crdtUser.id) &
|
||||
t.node.afterAnyCheckpointFilter(
|
||||
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||
_afterAnyScopeCheckpointFilter(
|
||||
t.row.scopeId,
|
||||
t.node.uuidNodeId,
|
||||
t.hlcDatetime,
|
||||
t.hlcCounter,
|
||||
nodeCheckpoints,
|
||||
checkpointsByScopeId,
|
||||
);
|
||||
|
||||
/// Loads a domain row for outbound insert sync.
|
||||
|
|
@ -959,27 +1114,31 @@ class CrdtSync {
|
|||
}
|
||||
}
|
||||
|
||||
extension on CrdtNodeTable {
|
||||
Expression afterAnyCheckpointFilter(
|
||||
Expression _afterAnyScopeCheckpointFilter(
|
||||
ColumnInt scopeId,
|
||||
ColumnUuid uuidNodeId,
|
||||
ColumnDateTime hlcDatetime,
|
||||
ColumnInt hlcCounter,
|
||||
List<Hlc> nodeCheckpoints,
|
||||
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||
) {
|
||||
if (nodeCheckpoints.isEmpty) return Constant.bool(true);
|
||||
|
||||
final caseExpression = Case();
|
||||
for (final checkpoint in nodeCheckpoints) {
|
||||
var hasCheckpoint = false;
|
||||
for (final MapEntry(key: normalizedScopeId, value: checkpoints)
|
||||
in checkpointsByScopeId.entries) {
|
||||
for (final checkpoint in checkpoints) {
|
||||
hasCheckpoint = true;
|
||||
caseExpression.when(
|
||||
uuidNodeId.equals(checkpoint.nodeId),
|
||||
scopeId.equals(normalizedScopeId) & uuidNodeId.equals(checkpoint.nodeId),
|
||||
then:
|
||||
(hlcDatetime > checkpoint.datetime) |
|
||||
(hlcDatetime.equals(checkpoint.datetime) &
|
||||
(hlcCounter > checkpoint.counter)),
|
||||
);
|
||||
}
|
||||
return caseExpression.orElse(Constant.bool(true));
|
||||
}
|
||||
return hasCheckpoint
|
||||
? caseExpression.orElse(Constant.bool(true))
|
||||
: Constant.bool(true);
|
||||
}
|
||||
|
||||
extension on Map<String, dynamic> {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@
|
|||
// flag internal Serverpod APIs used by generated code.
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../crdt/exceptions.dart';
|
||||
import '../crdt/extensions.dart';
|
||||
import '../crdt/integrity_violation.dart';
|
||||
import '../crdt/merge.dart';
|
||||
import '../crdt/scope_membership.dart';
|
||||
import '../crdt/sync.dart';
|
||||
import '../protocol/protocol.dart';
|
||||
import 'recorder.dart';
|
||||
|
|
@ -20,6 +24,9 @@ part 'scope.dart';
|
|||
/// Map of transaction hashes to the scope they are associated with.
|
||||
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
|
||||
/// transaction to record each change in the CRDT tables.
|
||||
class CrdtDatabase implements Database {
|
||||
|
|
@ -111,6 +118,7 @@ class CrdtDatabase implements Database {
|
|||
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
|
||||
Stream<CrdtSyncStreamEvent> sync({
|
||||
required Stream<CrdtSyncStreamEvent> inbound,
|
||||
required CrdtSyncPeerMode mode,
|
||||
UuidValue? userId,
|
||||
bool once = false,
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
|
|
@ -123,6 +131,7 @@ class CrdtDatabase implements Database {
|
|||
inbound: inbound,
|
||||
once: once,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
mode: mode,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -184,20 +193,26 @@ class CrdtDatabase implements Database {
|
|||
_delegate.serializationManager;
|
||||
|
||||
/// Merges [where] with the CRDT visibility predicates scoped to the queried
|
||||
/// tables and the user associated with [transaction] (or the persistent
|
||||
/// user). Without a user scope (e.g. server-side admin reads), a row is
|
||||
/// only hidden when every user tracking it has it hidden.
|
||||
Expression? _whereVisibleWithTombstone<T extends TableRow>(
|
||||
/// tables and the user associated with [transaction] (or the persistent user).
|
||||
///
|
||||
/// Read filters are membership-wide. Write filters stay pinned to the acting
|
||||
/// scope so mutations cannot cross scope boundaries.
|
||||
Future<Expression?> _whereVisibleWithTombstone<T extends TableRow>(
|
||||
Expression? where,
|
||||
Include? include,
|
||||
Transaction? transaction,
|
||||
) {
|
||||
Transaction? transaction, {
|
||||
required bool membershipWide,
|
||||
}) async {
|
||||
final scopeIds = await _scopeIdsForQueries(
|
||||
transaction,
|
||||
membershipWide: membershipWide,
|
||||
);
|
||||
return mergeWhereWithTombstone<T>(
|
||||
serializationManager,
|
||||
where,
|
||||
include,
|
||||
tableIdForName: _recorder.tableIdForName,
|
||||
scopeId: () => _recorder.scopeForQueries(transaction)?.id,
|
||||
scopeIds: () => scopeIds,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +231,12 @@ class CrdtDatabase implements Database {
|
|||
}) async {
|
||||
await _ensureInitialized();
|
||||
final result = await _delegate.find<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
||||
where: await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
include,
|
||||
transaction,
|
||||
membershipWide: true,
|
||||
),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy,
|
||||
|
|
@ -244,7 +264,12 @@ class CrdtDatabase implements Database {
|
|||
final table = serializationManager.getTableForType(T);
|
||||
final where = table?.id.equals(id);
|
||||
final result = await _delegate.findFirstRow<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
||||
where: await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
include,
|
||||
transaction,
|
||||
membershipWide: true,
|
||||
),
|
||||
transaction: transaction,
|
||||
include: include,
|
||||
lockMode: lockMode,
|
||||
|
|
@ -268,7 +293,12 @@ class CrdtDatabase implements Database {
|
|||
}) async {
|
||||
await _ensureInitialized();
|
||||
final result = await _delegate.findFirstRow<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
||||
where: await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
include,
|
||||
transaction,
|
||||
membershipWide: true,
|
||||
),
|
||||
offset: offset,
|
||||
orderBy: orderBy,
|
||||
orderByList: orderByList,
|
||||
|
|
@ -289,6 +319,7 @@ class CrdtDatabase implements Database {
|
|||
List<T> rows, {
|
||||
Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
if (rows.isEmpty) return [];
|
||||
await _ensureInitialized();
|
||||
|
|
@ -348,6 +379,7 @@ class CrdtDatabase implements Database {
|
|||
List<Column>? updateColumns,
|
||||
Expression? updateWhere,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
// TODO: Implement CRDT upsert.
|
||||
|
|
@ -381,6 +413,7 @@ class CrdtDatabase implements Database {
|
|||
List<T> rows, {
|
||||
List<Column>? columns,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
if (rows.isEmpty) return [];
|
||||
await _ensureInitialized();
|
||||
|
|
@ -444,7 +477,12 @@ class CrdtDatabase implements Database {
|
|||
final where = row.table.id.equals(row.id);
|
||||
final updatedRows = await _delegate.updateWhere<T>(
|
||||
columnValues: columnValues,
|
||||
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
|
||||
where: (await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
null,
|
||||
transaction,
|
||||
membershipWide: false,
|
||||
))!,
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
|
|
@ -489,6 +527,7 @@ class CrdtDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
if (_recorder.isCrdtTracked<T>()) {
|
||||
|
|
@ -501,7 +540,12 @@ class CrdtDatabase implements Database {
|
|||
(tx) async {
|
||||
final result = await _delegate.updateWhere<T>(
|
||||
columnValues: columnValues,
|
||||
where: _whereVisibleWithTombstone<T>(where, null, tx)!,
|
||||
where: (await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
null,
|
||||
tx,
|
||||
membershipWide: false,
|
||||
))!,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy,
|
||||
|
|
@ -529,6 +573,7 @@ class CrdtDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
if (rows.isEmpty) return [];
|
||||
await _ensureInitialized();
|
||||
|
|
@ -572,6 +617,7 @@ class CrdtDatabase implements Database {
|
|||
List<Column>? orderByList,
|
||||
bool orderDescending = false,
|
||||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
if (!_recorder.isCrdtTracked<T>()) {
|
||||
|
|
@ -591,7 +637,12 @@ class CrdtDatabase implements Database {
|
|||
transaction,
|
||||
(tx) async {
|
||||
final rows = await _delegate.find<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, null, tx),
|
||||
where: await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
null,
|
||||
tx,
|
||||
membershipWide: false,
|
||||
),
|
||||
orderBy: orderBy,
|
||||
orderByList: orderByList,
|
||||
// Remove this once the deprecated member is removed.
|
||||
|
|
@ -615,7 +666,12 @@ class CrdtDatabase implements Database {
|
|||
}) async {
|
||||
await _ensureInitialized();
|
||||
return _delegate.count<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, null, transaction),
|
||||
where: await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
null,
|
||||
transaction,
|
||||
membershipWide: true,
|
||||
),
|
||||
limit: limit,
|
||||
useCache: useCache,
|
||||
transaction: transaction,
|
||||
|
|
@ -631,7 +687,12 @@ class CrdtDatabase implements Database {
|
|||
}) async {
|
||||
await _ensureInitialized();
|
||||
return _delegate.lockRows<T>(
|
||||
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
|
||||
where: (await _whereVisibleWithTombstone<T>(
|
||||
where,
|
||||
null,
|
||||
transaction,
|
||||
membershipWide: false,
|
||||
))!,
|
||||
lockMode: lockMode,
|
||||
transaction: transaction,
|
||||
lockBehavior: lockBehavior,
|
||||
|
|
@ -654,31 +715,100 @@ class CrdtDatabase implements Database {
|
|||
|
||||
/// Executes the [transactionFunction] in a transaction with the provided [userId].
|
||||
///
|
||||
/// The [userId] is used to record the CRDT operations in the database. This method
|
||||
/// must be used instead of [transaction] when performing operations on the server,
|
||||
/// where the database knows multiple users.
|
||||
/// Without [scopeId], writes act in the user's personal scope. With [scopeId],
|
||||
/// [userId] stays the authenticated identity and [scopeId] is the scope being
|
||||
/// acted in; the pair is checked before the transaction starts.
|
||||
Future<R> transactionForUser<R>(
|
||||
UuidValue userId,
|
||||
TransactionFunction<R> transactionFunction, {
|
||||
UuidValue? scopeId,
|
||||
TransactionSettings? settings,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
// Ensure that the user exists with a node before starting the transaction.
|
||||
final user = await _recorder.getOrCreateScope(userId);
|
||||
final effectiveScopeId = scopeId ?? 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>(
|
||||
(tx) async {
|
||||
try {
|
||||
scopeForTransaction[tx] = user;
|
||||
scopeForTransaction[tx] = scope;
|
||||
userForTransaction[tx] = userId;
|
||||
return await transactionFunction(tx);
|
||||
} finally {
|
||||
scopeForTransaction.remove(tx);
|
||||
userForTransaction.remove(tx);
|
||||
}
|
||||
},
|
||||
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 {
|
||||
return userId ??
|
||||
_recorder.persistentUserId ??
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ typedef _MergeContext = ({
|
|||
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||
Map<_MergeRowKey, _DomainOwner> domainOwners,
|
||||
});
|
||||
typedef _MergeNodes = ({
|
||||
Map<UuidValue, CrdtNode> nodesByUuid,
|
||||
Map<UuidValue, CrdtScopeNode> scopeNodesByUuid,
|
||||
});
|
||||
|
||||
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
||||
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||
|
|
@ -47,6 +51,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
{for (final change in operations) change.uuidNodeId},
|
||||
transaction,
|
||||
);
|
||||
final nodesByUuid = remoteNodes.nodesByUuid;
|
||||
final context = await _loadMergeContext(mergeSet, transaction);
|
||||
|
||||
for (final operation in operations) {
|
||||
|
|
@ -58,21 +63,21 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
case final CrdtMergeInsert insert:
|
||||
await _applyMergeInsert(
|
||||
insert,
|
||||
remoteNodes,
|
||||
nodesByUuid,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
case final CrdtMergeUpdate update:
|
||||
await _applyMergeUpdate(
|
||||
update,
|
||||
remoteNodes,
|
||||
nodesByUuid,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
case final CrdtMergeDelete delete:
|
||||
await _applyMergeDelete(
|
||||
delete,
|
||||
remoteNodes,
|
||||
nodesByUuid,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
|
|
@ -91,14 +96,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
|
||||
Future<void> _updateHlcFromIncomingOperations(
|
||||
List<CrdtMergeChange> operations,
|
||||
Map<UuidValue, CrdtNode> remoteNodes,
|
||||
_MergeNodes remoteNodes,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
final maxIncomingHlc = operations.fold<Hlc?>(
|
||||
null,
|
||||
(current, change) => change.hlc.maxBetween(current),
|
||||
);
|
||||
final nodesToUpdate = <CrdtNode>[];
|
||||
final scopeNodesToUpdate = <CrdtScopeNode>[];
|
||||
|
||||
if (maxIncomingHlc != null) {
|
||||
final hlcManager = _getHlcManager(transaction);
|
||||
|
|
@ -110,7 +115,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
hlcManager.merge(maxIncomingHlc);
|
||||
}
|
||||
|
||||
nodesToUpdate.add(hlcManager.getNode());
|
||||
await _persistCurrentNodeHlc(hlcManager, transaction);
|
||||
}
|
||||
|
||||
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
|
||||
|
|
@ -122,66 +127,59 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
|
||||
for (final MapEntry(key: nodeId, value: incomingHlc)
|
||||
in maxIncomingHlcByNode.entries) {
|
||||
final remoteNode = remoteNodes[nodeId];
|
||||
final remoteNode = remoteNodes.scopeNodesByUuid[nodeId];
|
||||
if (remoteNode == null) continue;
|
||||
final updatedNode = remoteNode.copyWith(
|
||||
final updatedScopeNode = remoteNode.copyWith(
|
||||
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
|
||||
);
|
||||
if (updatedNode.lastReceivedHlc == remoteNode.lastReceivedHlc) continue;
|
||||
|
||||
nodesToUpdate.add(updatedNode);
|
||||
remoteNodes[nodeId] = updatedNode;
|
||||
if (updatedScopeNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodesToUpdate.isNotEmpty) {
|
||||
await CrdtNode.db.update(
|
||||
scopeNodesToUpdate.add(updatedScopeNode);
|
||||
remoteNodes.scopeNodesByUuid[nodeId] = updatedScopeNode;
|
||||
}
|
||||
|
||||
if (scopeNodesToUpdate.isNotEmpty) {
|
||||
await CrdtScopeNode.db.update(
|
||||
_session,
|
||||
nodesToUpdate,
|
||||
scopeNodesToUpdate,
|
||||
columns: (t) => [t.lastReceivedHlc],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<UuidValue, CrdtNode>> _findOrCreateNodesForMerge(
|
||||
Future<_MergeNodes> _findOrCreateNodesForMerge(
|
||||
int userId,
|
||||
Set<UuidValue> nodeIds,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
if (nodeIds.isEmpty) return {};
|
||||
|
||||
var nodes = await CrdtNode.db.find(
|
||||
_session,
|
||||
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,
|
||||
if (nodeIds.isEmpty) {
|
||||
return (
|
||||
nodesByUuid: const <UuidValue, CrdtNode>{},
|
||||
scopeNodesByUuid: const <UuidValue, CrdtScopeNode>{},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
for (final node in nodes) node.uuidNodeId: node,
|
||||
final nodesByUuid = {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -357,28 +357,23 @@ class CrdtMutationRecorder {
|
|||
UuidValue otherNodeId,
|
||||
Hlc syncedHlc,
|
||||
) async {
|
||||
final user = await _scopeManager.getOrCreate(userId);
|
||||
final scope = await _scopeManager.getOrCreate(userId);
|
||||
await _db.transaction((transaction) async {
|
||||
var node = await CrdtNode.db.findFirstRow(
|
||||
_session,
|
||||
where: (t) => t.scopeId.equals(user.id) & t.uuidNodeId.equals(otherNodeId),
|
||||
transaction: transaction,
|
||||
final node = await _findOrCreateNode(otherNodeId, transaction);
|
||||
final scopeNode = await _findOrCreateScopeNode(
|
||||
scope.id!,
|
||||
node.id!,
|
||||
transaction,
|
||||
);
|
||||
|
||||
node ??= await CrdtNode.db.insertRow(
|
||||
_session,
|
||||
CrdtNode(scopeId: user.id!, uuidNodeId: otherNodeId),
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
final currentSyncHlc = node.lastReceivedHlc;
|
||||
final currentSyncHlc = scopeNode.lastReceivedHlc;
|
||||
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CrdtNode.db.updateRow(
|
||||
await CrdtScopeNode.db.updateRow(
|
||||
_session,
|
||||
node.copyWith(lastReceivedHlc: syncedHlc),
|
||||
scopeNode.copyWith(lastReceivedHlc: syncedHlc),
|
||||
columns: (t) => [t.lastReceivedHlc],
|
||||
transaction: transaction,
|
||||
);
|
||||
|
|
@ -607,11 +602,13 @@ WHERE c."id" IN ($whereRowIds)
|
|||
if (rows.isEmpty) return;
|
||||
if (!isCrdtTracked<T>(rows.first.table)) return;
|
||||
|
||||
final hlcManager = _getHlcManager(transaction);
|
||||
await record(
|
||||
rows.first.table.tableName,
|
||||
rows.uuidRowIds,
|
||||
_getHlcManager(transaction),
|
||||
hlcManager,
|
||||
);
|
||||
await _persistCurrentNodeHlc(hlcManager, transaction);
|
||||
}
|
||||
|
||||
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) {
|
||||
return scopeForQueries(transaction) ??
|
||||
(throw StateError(
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue