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) {
|
CrdtMergeInsert insertChangeFor(TableRow<UuidValue?> row) {
|
||||||
final hlc = _nextRemoteHlc();
|
final hlc = _nextRemoteHlc();
|
||||||
return CrdtMergeInsert(
|
return CrdtMergeInsert(
|
||||||
|
uuidScopeId: _userId,
|
||||||
tableName: row.table.tableName,
|
tableName: row.table.tableName,
|
||||||
uuidRowId: row.id!,
|
uuidRowId: row.id!,
|
||||||
uuidNodeId: hlc.nodeId,
|
uuidNodeId: hlc.nodeId,
|
||||||
|
|
@ -110,6 +111,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
) {
|
) {
|
||||||
final hlc = _nextRemoteHlc();
|
final hlc = _nextRemoteHlc();
|
||||||
return CrdtMergeUpdate(
|
return CrdtMergeUpdate(
|
||||||
|
uuidScopeId: _userId,
|
||||||
tableName: row.table.tableName,
|
tableName: row.table.tableName,
|
||||||
uuidRowId: row.id!,
|
uuidRowId: row.id!,
|
||||||
uuidNodeId: hlc.nodeId,
|
uuidNodeId: hlc.nodeId,
|
||||||
|
|
@ -123,6 +125,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
|
||||||
CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) {
|
CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) {
|
||||||
final hlc = _nextRemoteHlc();
|
final hlc = _nextRemoteHlc();
|
||||||
return CrdtMergeDelete(
|
return CrdtMergeDelete(
|
||||||
|
uuidScopeId: _userId,
|
||||||
tableName: row.table.tableName,
|
tableName: row.table.tableName,
|
||||||
uuidRowId: row.id!,
|
uuidRowId: row.id!,
|
||||||
uuidNodeId: hlc.nodeId,
|
uuidNodeId: hlc.nodeId,
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,14 @@ class QueryCountingDatabase implements Database {
|
||||||
List<T> rows, {
|
List<T> rows, {
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
bool ignoreConflicts = false,
|
bool ignoreConflicts = false,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.insert<T>(
|
return _delegate.insert<T>(
|
||||||
rows,
|
rows,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
ignoreConflicts: ignoreConflicts,
|
ignoreConflicts: ignoreConflicts,
|
||||||
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +134,7 @@ class QueryCountingDatabase implements Database {
|
||||||
List<Column>? updateColumns,
|
List<Column>? updateColumns,
|
||||||
Expression? updateWhere,
|
Expression? updateWhere,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.upsert<T>(
|
return _delegate.upsert<T>(
|
||||||
|
|
@ -140,6 +143,7 @@ class QueryCountingDatabase implements Database {
|
||||||
updateColumns: updateColumns,
|
updateColumns: updateColumns,
|
||||||
updateWhere: updateWhere,
|
updateWhere: updateWhere,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,9 +170,15 @@ class QueryCountingDatabase implements Database {
|
||||||
List<T> rows, {
|
List<T> rows, {
|
||||||
List<Column>? columns,
|
List<Column>? columns,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.update<T>(rows, columns: columns, transaction: transaction);
|
return _delegate.update<T>(
|
||||||
|
rows,
|
||||||
|
columns: columns,
|
||||||
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -205,6 +215,7 @@ class QueryCountingDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.updateWhere<T>(
|
return _delegate.updateWhere<T>(
|
||||||
|
|
@ -218,6 +229,7 @@ class QueryCountingDatabase implements Database {
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
orderDescending: orderDescending,
|
orderDescending: orderDescending,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +240,7 @@ class QueryCountingDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.delete<T>(
|
return _delegate.delete<T>(
|
||||||
|
|
@ -238,6 +251,7 @@ class QueryCountingDatabase implements Database {
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
orderDescending: orderDescending,
|
orderDescending: orderDescending,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +271,7 @@ class QueryCountingDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
queryCount++;
|
queryCount++;
|
||||||
return _delegate.deleteWhere<T>(
|
return _delegate.deleteWhere<T>(
|
||||||
|
|
@ -267,6 +282,7 @@ class QueryCountingDatabase implements Database {
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
orderDescending: orderDescending,
|
orderDescending: orderDescending,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,26 +142,37 @@ WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseU
|
||||||
INSERT INTO "crdt_scopes" ("currentNodeId") SELECT NULL FROM n
|
INSERT INTO "crdt_scopes" ("currentNodeId") SELECT NULL FROM n
|
||||||
''');
|
''');
|
||||||
await db.unsafeExecute('''
|
await db.unsafeExecute('''
|
||||||
INSERT INTO "crdt_nodes" ("scopeId")
|
WITH local_node(id) AS (
|
||||||
SELECT u."id" FROM "crdt_scopes" u
|
SELECT "currentNodeId" FROM "crdt_scopes" WHERE "id" = $benchUserId
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "crdt_nodes" n WHERE n."scopeId" = u."id")
|
)
|
||||||
|
INSERT INTO "crdt_scope_nodes" ("scopeId", "nodeId")
|
||||||
|
SELECT u."id", local_node.id
|
||||||
|
FROM "crdt_scopes" u
|
||||||
|
CROSS JOIN local_node
|
||||||
|
WHERE local_node.id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "crdt_scope_nodes" sn
|
||||||
|
WHERE sn."scopeId" = u."id" AND sn."nodeId" = local_node.id
|
||||||
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// Noise rows for every noise user, spread across all synced tables, with
|
// Noise rows for every noise user, spread across all synced tables, with
|
||||||
// 10% tombstoned. Random UUIDs keep them unrelated to the domain rows.
|
// 10% tombstoned. Random UUIDs keep them unrelated to the domain rows.
|
||||||
await db.unsafeExecute('''
|
await db.unsafeExecute('''
|
||||||
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1),
|
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1),
|
||||||
users(rowidx, uid) AS (
|
users(rowidx, uid, nodeId) AS (
|
||||||
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id"
|
SELECT ROW_NUMBER() OVER (ORDER BY s."id") - 1, s."id", sn."nodeId"
|
||||||
FROM "crdt_scopes" WHERE "id" <> $benchUserId
|
FROM "crdt_scopes" s
|
||||||
|
JOIN "crdt_scope_nodes" sn ON sn."scopeId" = s."id"
|
||||||
|
WHERE s."id" <> $benchUserId
|
||||||
),
|
),
|
||||||
tbls(tblidx, tid) AS (
|
tbls(tblidx, tid) AS (
|
||||||
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
|
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
|
||||||
)
|
)
|
||||||
INSERT INTO "crdt_data_rows"
|
INSERT INTO "crdt_data_rows"
|
||||||
("hlcDatetime", "hlcCounter", "scopeId", "tblId", "uuidRowId", "nodeId", "visibility")
|
("hlcDatetime", "hlcCounter", "scopeId", "tblId", "uuidRowId", "nodeId", "visibility")
|
||||||
SELECT 0, 0, u.uid, t.tid, randomblob(16),
|
SELECT 0, 0, u.uid, t.tid, randomblob(16), u.nodeId,
|
||||||
(SELECT nd."id" FROM "crdt_nodes" nd WHERE nd."scopeId" = u.uid LIMIT 1),
|
|
||||||
CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END
|
CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END
|
||||||
FROM n
|
FROM n
|
||||||
JOIN users u ON u.rowidx = n.i % $noiseUsers
|
JOIN users u ON u.rowidx = n.i % $noiseUsers
|
||||||
|
|
|
||||||
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
|
# Row ownership and scope isolation
|
||||||
|
|
||||||
Status: implemented.
|
Status: implemented.
|
||||||
The *Future: shared scopes* section is direction, not commitment.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|
@ -20,9 +19,8 @@ This document makes ownership explicit. The core decisions:
|
||||||
> never materialize or sync the same `(table, uuidRowId)`. Cross-scope UUID
|
> never materialize or sync the same `(table, uuidRowId)`. Cross-scope UUID
|
||||||
> reuse is a terminal ownership collision, not valid multi-tenant state. Sync
|
> reuse is a terminal ownership collision, not valid multi-tenant state. Sync
|
||||||
> fails on that collision, records a durable violation, and does not advance
|
> fails on that collision, records a durable violation, and does not advance
|
||||||
> the sender past the rejected change. Sharing, when it arrives, will be
|
> the sender past the rejected change. Sharing is modeled as shared scopes that
|
||||||
> modeled as shared scopes that multiple identities subscribe to — never as
|
> multiple identities subscribe to — never as two scopes aliasing one row.
|
||||||
> two scopes aliasing one row.
|
|
||||||
|
|
||||||
> **Every synced table declares `scopeId`, identically on server and
|
> **Every synced table declares `scopeId`, identically on server and
|
||||||
> client.** The column is a hard requirement, never `scope=serverOnly`, so
|
> client.** The column is a hard requirement, never `scope=serverOnly`, so
|
||||||
|
|
@ -129,9 +127,9 @@ enforces that the answer is unique, and the domain storage assumes it is.
|
||||||
"Leave it up to users" is rejected explicitly: symptom 2 means the storage
|
"Leave it up to users" is rejected explicitly: symptom 2 means the storage
|
||||||
model cannot represent what multi-scope tracking promises, so allowing it is
|
model cannot represent what multi-scope tracking promises, so allowing it is
|
||||||
not granting flexibility — it is permitting silent cross-tenant data
|
not granting flexibility — it is permitting silent cross-tenant data
|
||||||
corruption. Applications that need shared data need shared *scopes* (see
|
corruption. Applications that need shared data use shared *scopes* (see
|
||||||
*Future: shared scopes*), which preserve the one-chain-per-row property that
|
`shared-scopes.md`), which preserve the one-chain-per-row property that makes
|
||||||
makes LWW merging coherent.
|
LWW merging coherent.
|
||||||
|
|
||||||
## The `scopeId` column
|
## The `scopeId` column
|
||||||
|
|
||||||
|
|
@ -475,9 +473,7 @@ actually key — scopes:
|
||||||
- **Internals and storage** adopt scope naming in one sweep: `crdt_users` →
|
- **Internals and storage** adopt scope naming in one sweep: `crdt_users` →
|
||||||
`crdt_scopes` (`CrdtUser` → `CrdtScope`, `uuidUserId` → `uuidScopeId`),
|
`crdt_scopes` (`CrdtUser` → `CrdtScope`, `uuidUserId` → `uuidScopeId`),
|
||||||
`crdt_data_rows.userId` → `scopeId` (matching the domain column),
|
`crdt_data_rows.userId` → `scopeId` (matching the domain column),
|
||||||
`userForTransaction`, the effective-user helpers, and the HLC manager
|
`userForTransaction`, the effective-user helpers, and the HLC manager keys.
|
||||||
keys. Doing this now is what keeps the *Future: shared scopes* section a
|
|
||||||
pure addition instead of a rename project.
|
|
||||||
- **Unscoped reads** remain supported as admin reads.
|
- **Unscoped reads** remain supported as admin reads.
|
||||||
|
|
||||||
Consequences for application code, stated as contract:
|
Consequences for application code, stated as contract:
|
||||||
|
|
@ -560,7 +556,7 @@ tested **once**, not per combination:
|
||||||
`withServerpod` sync roundtrip proving the wiring end to end. These exist
|
`withServerpod` sync roundtrip proving the wiring end to end. These exist
|
||||||
to prove deployment shapes compose, not to re-verify mechanisms.
|
to prove deployment shapes compose, not to re-verify mechanisms.
|
||||||
|
|
||||||
## Future: shared scopes
|
## Shared scopes
|
||||||
|
|
||||||
`crdt_scopes` is the entity everything is keyed by: sync, locking, HLC
|
`crdt_scopes` is the entity everything is keyed by: sync, locking, HLC
|
||||||
chains, and visibility. Sharing becomes an indirection in front of it, not a
|
chains, and visibility. Sharing becomes an indirection in front of it, not a
|
||||||
|
|
@ -575,9 +571,9 @@ change to row ownership:
|
||||||
changes.
|
changes.
|
||||||
|
|
||||||
This is the standard shape of sharing in local-first systems, and it is the
|
This is the standard shape of sharing in local-first systems, and it is the
|
||||||
reason the column and concept are named `scope` rather than `user`. The rest
|
reason the column and concept are named `scope` rather than `user`.
|
||||||
of this section sketches the direction in enough detail to keep today's
|
Implementation details and current limits live in `shared-scopes.md`; the rest
|
||||||
decisions compatible with it; it is not committed design.
|
of this section summarizes why the ownership rules remain compatible.
|
||||||
|
|
||||||
### Users authenticate, scopes act
|
### Users authenticate, scopes act
|
||||||
|
|
||||||
|
|
@ -641,15 +637,18 @@ filter and RLS become two enforcements of one predicate.
|
||||||
|
|
||||||
### Sync and clients
|
### Sync and clients
|
||||||
|
|
||||||
- A device syncs one chain per scope it holds: the sync session (or one
|
- A device syncs one chain per scope it holds: each sync call enumerates the
|
||||||
session per scope) enumerates the authenticated user's member scopes,
|
authenticated user's member scopes, verified server-side against
|
||||||
verified server-side against `crdt_scope_members` at the handshake; the
|
`crdt_scope_members` at the start of every sync cycle; the membership table
|
||||||
membership table itself is not synced.
|
itself is not synced. `CrdtScopeRole` is enforced as described in
|
||||||
|
`shared-scopes.md`: reads stay membership-wide, writes are role-gated, and the
|
||||||
|
server records `unauthorizedWrite` for rejected inbound changes.
|
||||||
- Every database is already sharing-capable schema-wise — the column is
|
- Every database is already sharing-capable schema-wise — the column is
|
||||||
universal — so a sharing-enabled client simply holds several scopes' rows.
|
universal — so a sharing-enabled client simply holds several scopes' rows.
|
||||||
One database file per scope remains a valid alternative layout.
|
One database file per scope remains a valid alternative layout.
|
||||||
- Checkpoints stay per `(scope, node)`; a device is a node in each scope's
|
- Checkpoints stay per `(scope, node)`: `crdt_nodes` stores the stable replica
|
||||||
chain it participates in.
|
identity, while `crdt_scope_nodes` stores each scope's checkpoint for that
|
||||||
|
node.
|
||||||
|
|
||||||
### What sharing does not change
|
### What sharing does not change
|
||||||
|
|
||||||
|
|
@ -729,12 +728,15 @@ not relitigated by accident:
|
||||||
application with a pre-existing, semantically different `scopeId` column
|
application with a pre-existing, semantically different `scopeId` column
|
||||||
on a synced table has no escape hatch other than renaming its column.
|
on a synced table has no escape hatch other than renaming its column.
|
||||||
Acceptable for now; revisit if it bites real users.
|
Acceptable for now; revisit if it bites real users.
|
||||||
3. **Membership revocation semantics (sharing).** When a user loses
|
3. **Membership revocation semantics (sharing).** When a user loses membership
|
||||||
membership of a scope, the server stops streaming it at the next
|
of a scope, the server stops streaming it at the next sync cycle. The device
|
||||||
handshake, but the device still holds the scope's data and may hold
|
may still store the scope's data and unsynced local changes, though
|
||||||
unsynced local changes for it. Purge-vs-keep policy, and whether revoked
|
membership-wide reads no longer expose revoked rows. Purge-vs-keep policy,
|
||||||
unsynced changes are surfaced or dropped, need their own design.
|
and whether revoked unsynced changes are surfaced or dropped, need their own
|
||||||
4. **Roles within a scope (sharing).** `crdt_scope_members.role` is sketched
|
design.
|
||||||
but undefined: read-only members would need write rejection at merge time
|
4. **Roles within a scope (sharing).** Resolved in `shared-scopes.md`.
|
||||||
(another fail-and-record case), and the interaction with offline-written
|
`crdt_scope_members.role` is enforced for writes through the closed
|
||||||
changes by a demoted member overlaps with question 3.
|
`CrdtScopeRole` enum: the server rejects and records unauthorized inbound
|
||||||
|
writes, the client rejects local writes from projected `readOnly` roles, and
|
||||||
|
reads remain membership-wide. The remaining cleanup question for demoted or
|
||||||
|
revoked unsynced local changes is part of question 3.
|
||||||
|
|
|
||||||
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 {
|
try {
|
||||||
await session.syncOnce(
|
await session.syncOnce(
|
||||||
client,
|
client,
|
||||||
onMergeSuccess: (hlc) => _handleReplicaMerge(slot, hlc),
|
onMergeSuccess: (scopeUuid, hlc) =>
|
||||||
|
_handleReplicaMerge(slot, scopeUuid, hlc),
|
||||||
);
|
);
|
||||||
state.phase = SyncPhase.idle;
|
state.phase = SyncPhase.idle;
|
||||||
state.lastSyncedLabel = 'synced ${_timeLabel()}';
|
state.lastSyncedLabel = 'synced ${_timeLabel()}';
|
||||||
|
|
@ -312,7 +313,8 @@ class DemoController extends ChangeNotifier {
|
||||||
() async {
|
() async {
|
||||||
final stream = session.syncContinuously(
|
final stream = session.syncContinuously(
|
||||||
client,
|
client,
|
||||||
onMergeSuccess: (hlc) => unawaited(_handleReplicaMerge(slot, hlc)),
|
onMergeSuccess: (scopeUuid, hlc) =>
|
||||||
|
unawaited(_handleReplicaMerge(slot, scopeUuid, hlc)),
|
||||||
);
|
);
|
||||||
state.stream = stream;
|
state.stream = stream;
|
||||||
state.phase = SyncPhase.streaming;
|
state.phase = SyncPhase.streaming;
|
||||||
|
|
@ -418,8 +420,12 @@ class DemoController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleReplicaMerge(ReplicaSlot slot, Object hlc) async {
|
Future<void> _handleReplicaMerge(
|
||||||
replicas[slot]!.lastSyncedLabel = 'merged $hlc';
|
ReplicaSlot slot,
|
||||||
|
offline.UuidValue scopeUuid,
|
||||||
|
Object hlc,
|
||||||
|
) async {
|
||||||
|
replicas[slot]!.lastSyncedLabel = 'merged $scopeUuid at $hlc';
|
||||||
await _refreshReplica(slot, notify: false);
|
await _refreshReplica(slot, notify: false);
|
||||||
await _fetchServer(notify: false);
|
await _fetchServer(notify: false);
|
||||||
replicas[slot]!.changed();
|
replicas[slot]!.changed();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export 'src/crdt/client_sync.dart';
|
export 'src/crdt/client_sync.dart';
|
||||||
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
|
export 'src/crdt/exceptions.dart' hide PendingOutboundIntegrityViolation;
|
||||||
export 'src/crdt/merge.dart';
|
export 'src/crdt/merge.dart';
|
||||||
|
export 'src/crdt/scope_membership.dart';
|
||||||
export 'src/crdt/sync.dart';
|
export 'src/crdt/sync.dart';
|
||||||
export 'src/database/database.dart';
|
export 'src/database/database.dart';
|
||||||
export 'src/database/recorder.dart';
|
export 'src/database/recorder.dart';
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,12 @@ class CrdtSyncClient {
|
||||||
);
|
);
|
||||||
|
|
||||||
final subscription = session.crdtDb
|
final subscription = session.crdtDb
|
||||||
.sync(inbound: remoteStream, once: once, onMergeSuccess: onMergeSuccess)
|
.sync(
|
||||||
|
inbound: remoteStream,
|
||||||
|
once: once,
|
||||||
|
onMergeSuccess: onMergeSuccess,
|
||||||
|
mode: CrdtSyncPeerMode.follower,
|
||||||
|
)
|
||||||
.listen(
|
.listen(
|
||||||
outboundChanges.addIfNotClosed,
|
outboundChanges.addIfNotClosed,
|
||||||
onDone: doneCompleter.completeOrSkip,
|
onDone: doneCompleter.completeOrSkip,
|
||||||
|
|
|
||||||
|
|
@ -43,26 +43,6 @@ final class CrdtSyncUnexpectedEventException<T extends CrdtSyncStreamEvent>
|
||||||
'received "${received.runtimeType.className}" instead.';
|
'received "${received.runtimeType.className}" instead.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thrown when a peer's since-HLC frame does not reference the local node id.
|
|
||||||
final class CrdtSyncInvalidSinceHlcException extends CrdtSyncException {
|
|
||||||
/// Creates a [CrdtSyncInvalidSinceHlcException].
|
|
||||||
const CrdtSyncInvalidSinceHlcException({
|
|
||||||
required this.receivedNodeId,
|
|
||||||
required this.expectedNodeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The node id carried by the peer's since-HLC frame.
|
|
||||||
final UuidValue receivedNodeId;
|
|
||||||
|
|
||||||
/// The local node id that was expected.
|
|
||||||
final UuidValue expectedNodeId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() =>
|
|
||||||
'CrdtSyncInvalidSinceHlcException: peer since HLC node id '
|
|
||||||
'"$receivedNodeId" does not match local node id "$expectedNodeId".';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thrown when the sync tables hash sent by a peer does not match locally.
|
/// Thrown when the sync tables hash sent by a peer does not match locally.
|
||||||
final class SyncTablesHashMismatchException extends CrdtSyncException {
|
final class SyncTablesHashMismatchException extends CrdtSyncException {
|
||||||
/// Creates a [SyncTablesHashMismatchException].
|
/// Creates a [SyncTablesHashMismatchException].
|
||||||
|
|
@ -109,10 +89,58 @@ final class CrdtSyncIntegrityViolationException extends CrdtSyncException {
|
||||||
'${violation.type.name}/${violation.operation.name} references '
|
'${violation.type.name}/${violation.operation.name} references '
|
||||||
'missing domain row $row for scope '
|
'missing domain row $row for scope '
|
||||||
'${violation.incomingScopeUuid}.$persisted',
|
'${violation.incomingScopeUuid}.$persisted',
|
||||||
|
CrdtSyncViolationType.unauthorizedWrite =>
|
||||||
|
'CrdtSyncIntegrityViolationException: '
|
||||||
|
'${violation.type.name}/${violation.operation.name} rejected '
|
||||||
|
'write to $row in scope ${violation.incomingScopeUuid}.$persisted',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thrown when a user attempts to act in a scope they are not a member of.
|
||||||
|
final class CrdtScopeMembershipException implements Exception {
|
||||||
|
/// Creates a [CrdtScopeMembershipException].
|
||||||
|
const CrdtScopeMembershipException({
|
||||||
|
required this.userId,
|
||||||
|
required this.scopeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The authenticated user id supplied to `transactionForUser`.
|
||||||
|
final UuidValue userId;
|
||||||
|
|
||||||
|
/// The scope id the transaction attempted to act in.
|
||||||
|
final UuidValue scopeId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'CrdtScopeMembershipException: user "$userId" is not a member of '
|
||||||
|
'scope "$scopeId".';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when a user attempts to write in a scope with a non-writable role.
|
||||||
|
final class CrdtScopeRoleException implements Exception {
|
||||||
|
/// Creates a [CrdtScopeRoleException].
|
||||||
|
const CrdtScopeRoleException({
|
||||||
|
required this.userId,
|
||||||
|
required this.scopeId,
|
||||||
|
this.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The authenticated user id supplied to `transactionForUser`.
|
||||||
|
final UuidValue userId;
|
||||||
|
|
||||||
|
/// The scope id the transaction attempted to write in.
|
||||||
|
final UuidValue scopeId;
|
||||||
|
|
||||||
|
/// The role that does not allow writes.
|
||||||
|
final CrdtScopeRole? role;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'CrdtScopeRoleException: user "$userId" with role "$role" cannot write '
|
||||||
|
'in scope "$scopeId".';
|
||||||
|
}
|
||||||
|
|
||||||
@internal
|
@internal
|
||||||
final class PendingOutboundIntegrityViolation implements Exception {
|
final class PendingOutboundIntegrityViolation implements Exception {
|
||||||
const PendingOutboundIntegrityViolation({
|
const PendingOutboundIntegrityViolation({
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,12 @@ extension CrdtDataDeletedReasonExtension on CrdtDataDeletedReason {
|
||||||
/// start with 'user' are synced.
|
/// start with 'user' are synced.
|
||||||
bool get isSynced => name.startsWith('user');
|
bool get isSynced => name.startsWith('user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CRDT write capability for a scope role.
|
||||||
|
///
|
||||||
|
/// The implicit personal scope resolves to [CrdtScopeRole.readWrite]. A missing
|
||||||
|
/// role means no shared membership row was found and does not grant writes.
|
||||||
|
extension CrdtScopeRoleWriteAccess on CrdtScopeRole? {
|
||||||
|
/// Whether this role may write CRDT rows in the scope.
|
||||||
|
bool get canWrite => this == CrdtScopeRole.readWrite;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,38 +20,53 @@ typedef CrdtMergeMetadataLookup = ({
|
||||||
extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> {
|
extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEvent> {
|
||||||
/// Collects the next framed sync batch from this iterator.
|
/// Collects the next framed sync batch from this iterator.
|
||||||
///
|
///
|
||||||
/// Each batch is zero or more [CrdtSyncMergeChunk] events followed by
|
/// Each batch is zero or more scope, handshake, and/or merge frames followed
|
||||||
/// [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
|
/// by [CrdtSyncEndOfBatch]. If the stream is idle before a batch starts, an
|
||||||
/// empty batch is returned.
|
/// empty batch is returned.
|
||||||
///
|
///
|
||||||
/// When [allowCloseBeforeBatch] is true, returns `null` if the stream closes
|
/// When [allowCloseBeforeBatch] is true, returns `null` if the transport
|
||||||
/// before the next batch starts. Otherwise, closing before a batch starts is
|
/// stream closes before the next batch starts. [CrdtSyncClose] is an
|
||||||
/// treated as a [CrdtSyncStreamClosedException].
|
/// intentional peer request to end the streaming session and returns `null`
|
||||||
|
/// even after frames for a partial batch have already arrived. The caller
|
||||||
|
/// must not persist checkpoint progress for such a discarded partial batch.
|
||||||
|
/// Otherwise, closing before a batch starts is treated as a
|
||||||
|
/// [CrdtSyncStreamClosedException].
|
||||||
///
|
///
|
||||||
/// If the stream closes after a merge change was already received, the
|
/// If the transport stream closes after a frame was already received without
|
||||||
/// partial batch is still treated as an error.
|
/// a [CrdtSyncClose] control frame, the partial batch is still treated as an
|
||||||
Future<CrdtMergeSet?> collectNextBatch({
|
/// error.
|
||||||
|
///
|
||||||
|
/// An idle timeout only ends an *empty* batch: once any frame has been
|
||||||
|
/// collected the timeout is ignored and collection waits for the explicit
|
||||||
|
/// terminator.
|
||||||
|
Future<CrdtSyncCycleBatch?> collectNextBatch({
|
||||||
bool allowCloseBeforeBatch = false,
|
bool allowCloseBeforeBatch = false,
|
||||||
}) async {
|
}) async {
|
||||||
final mergeSet = <CrdtMergeChange>[];
|
final batch = CrdtSyncCycleBatch();
|
||||||
|
|
||||||
while (await moveNext()) {
|
while (await moveNext()) {
|
||||||
switch (current) {
|
switch (current) {
|
||||||
case CrdtSyncMergeChunk(changes: final changes):
|
case final CrdtSyncScopeSet event:
|
||||||
mergeSet.addAll(changes);
|
batch.scopeSet = event;
|
||||||
|
case final CrdtSyncSinceHlc event:
|
||||||
|
batch.sinceHlcs[event.uuidScopeId] = event;
|
||||||
|
case CrdtSyncMergeChunk(:final changes):
|
||||||
|
batch.changes.addAll(changes);
|
||||||
case CrdtSyncIdleTimeout():
|
case CrdtSyncIdleTimeout():
|
||||||
if (mergeSet.isEmpty) return mergeSet;
|
if (batch.isEmpty) return batch;
|
||||||
case CrdtSyncEndOfBatch():
|
case CrdtSyncEndOfBatch():
|
||||||
return mergeSet;
|
return batch;
|
||||||
|
case CrdtSyncClose():
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
throw CrdtSyncUnexpectedEventException(
|
throw CrdtSyncUnexpectedEventException(
|
||||||
expected: '"CrdtSyncMergeChunk" or "CrdtSyncEndOfBatch"',
|
expected: 'a sync cycle frame',
|
||||||
received: current,
|
received: current,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mergeSet.isEmpty && allowCloseBeforeBatch) return null;
|
if (batch.isEmpty && allowCloseBeforeBatch) return null;
|
||||||
throw const CrdtSyncStreamClosedException(phase: 'end-of-batch');
|
throw const CrdtSyncStreamClosedException(phase: 'end-of-batch');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +85,21 @@ extension CrdtSyncStreamEventStreamExtension on StreamIterator<CrdtSyncStreamEve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One sync cycle's inbound frames.
|
||||||
|
class CrdtSyncCycleBatch {
|
||||||
|
/// The peer's scope announcement for this cycle, if it sent one.
|
||||||
|
CrdtSyncScopeSet? scopeSet;
|
||||||
|
|
||||||
|
/// The peer's resume vectors, keyed by scope.
|
||||||
|
final Map<UuidValue, CrdtSyncSinceHlc> sinceHlcs = {};
|
||||||
|
|
||||||
|
/// The peer's merge changes for this cycle.
|
||||||
|
final List<CrdtMergeChange> changes = [];
|
||||||
|
|
||||||
|
/// Whether the peer sent nothing this cycle (it was idle).
|
||||||
|
bool get isEmpty => scopeSet == null && sinceHlcs.isEmpty && changes.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
/// Helpers for grouping merge changes into stream payload batches.
|
/// Helpers for grouping merge changes into stream payload batches.
|
||||||
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
|
extension CrdtMergeChangeStreamExtension on Stream<CrdtMergeChange> {
|
||||||
/// Emits lists with at most [batchSize] changes from this stream.
|
/// Emits lists with at most [batchSize] changes from this stream.
|
||||||
|
|
|
||||||
|
|
@ -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 'extensions.dart';
|
||||||
import 'integrity_violation.dart';
|
import 'integrity_violation.dart';
|
||||||
import 'merge.dart';
|
import 'merge.dart';
|
||||||
|
import 'scope_membership.dart';
|
||||||
|
import 'scope_sync.dart';
|
||||||
|
|
||||||
|
export 'scope_sync.dart' show CrdtSyncPeerMode;
|
||||||
|
|
||||||
/// Callback function for when a merge is successful.
|
/// Callback function for when a merge is successful.
|
||||||
typedef CrdtSyncOnMergeSuccess = FutureOr<void> Function(Hlc syncedHlc);
|
typedef CrdtSyncOnMergeSuccess =
|
||||||
|
FutureOr<void> Function(UuidValue scopeUuid, Hlc syncedHlc);
|
||||||
|
|
||||||
/// A tuple representing the ownership of a domain row.
|
/// A tuple representing the ownership of a domain row.
|
||||||
typedef DomainRowOwner = ({bool exists, int? scopeId});
|
typedef DomainRowOwner = ({bool exists, int? scopeId});
|
||||||
|
|
@ -123,7 +128,7 @@ class CrdtSync {
|
||||||
'${uuid.v5(Namespace.oid.value, canonicalSignature)}';
|
'${uuid.v5(Namespace.oid.value, canonicalSignature)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streams local CRDT changes that a peer node has not seen yet.
|
/// Streams pending changes for every scope in [checkpointsByScopeUuid].
|
||||||
///
|
///
|
||||||
/// Changes are emitted in insert, update, then delete order. Domain row and
|
/// Changes are emitted in insert, update, then delete order. Domain row and
|
||||||
/// column payloads are resolved incrementally as each change is yielded.
|
/// column payloads are resolved incrementally as each change is yielded.
|
||||||
|
|
@ -133,20 +138,39 @@ class CrdtSync {
|
||||||
/// in the domain table. Peers need the attempted fact to converge; local FK
|
/// in the domain table. Peers need the attempted fact to converge; local FK
|
||||||
/// projection materializes only the safe visible value into domain tables.
|
/// projection materializes only the safe visible value into domain tables.
|
||||||
///
|
///
|
||||||
/// All changes for nodes that are not present in the [nodeCheckpoints] list
|
/// Resolves the scopes' internal ids once and keeps their checkpoint vectors
|
||||||
/// are collected and emitted. Passing an empty list will collect all changes.
|
/// scoped by those internal ids. Node ids are stable per replica and may
|
||||||
|
/// appear in multiple scopes, so checkpoint filtering must compare both
|
||||||
|
/// `scopeId` and `uuidNodeId`. This still runs one query per change kind for
|
||||||
|
/// the whole pass. Per-row ownership and integrity checks resolve against
|
||||||
|
/// each row's own scope.
|
||||||
|
///
|
||||||
|
/// All changes for nodes that are not present in a scope's checkpoint list
|
||||||
|
/// are collected and emitted. Passing an empty list for a scope will collect
|
||||||
|
/// all of its changes. Each change carries its [CrdtMergeChange.uuidScopeId].
|
||||||
Stream<CrdtMergeChange> collectPendingChanges(
|
Stream<CrdtMergeChange> collectPendingChanges(
|
||||||
DatabaseSession session, {
|
DatabaseSession session, {
|
||||||
required UuidValue userId,
|
required Map<UuidValue, List<Hlc>> checkpointsByScopeUuid,
|
||||||
required UuidValue peerNodeId,
|
|
||||||
required List<Hlc> nodeCheckpoints,
|
|
||||||
}) async* {
|
}) async* {
|
||||||
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
|
if (checkpointsByScopeUuid.isEmpty) return;
|
||||||
|
|
||||||
|
final scopes = await CrdtScope.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidScopeId.inSet(checkpointsByScopeUuid.keys.toSet()),
|
||||||
|
);
|
||||||
|
final scopeUuidById = {
|
||||||
|
for (final scope in scopes) scope.id!: scope.uuidScopeId,
|
||||||
|
};
|
||||||
|
final checkpointsByScopeId = {
|
||||||
|
for (final scope in scopes)
|
||||||
|
scope.id!: checkpointsByScopeUuid[scope.uuidScopeId] ?? const <Hlc>[],
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await for (final change in _streamPendingChanges(
|
await for (final change in _streamPendingChanges(
|
||||||
session,
|
session,
|
||||||
crdtUser,
|
scopeUuidById,
|
||||||
nodeCheckpoints,
|
checkpointsByScopeId,
|
||||||
)) {
|
)) {
|
||||||
yield change;
|
yield change;
|
||||||
}
|
}
|
||||||
|
|
@ -155,29 +179,31 @@ class CrdtSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the [CrdtSyncSinceHlc] checkpoint for the post-connect handshake.
|
/// Creates the [CrdtSyncSinceHlc] checkpoint for a scope handshake.
|
||||||
///
|
///
|
||||||
/// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
|
/// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
|
||||||
/// has received from each known node, tagged with the source node id.
|
/// has received from each known node, tagged with the source node id.
|
||||||
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
|
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
|
||||||
DatabaseSession session, {
|
DatabaseSession session, {
|
||||||
required UuidValue userId,
|
required UuidValue scopeId,
|
||||||
required UuidValue peerNodeId,
|
|
||||||
}) async {
|
}) async {
|
||||||
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
|
final scope = await CrdtScopeManager(session).getOrCreate(scopeId);
|
||||||
|
final localNodeId = scope.currentNode!.uuidNodeId;
|
||||||
|
|
||||||
final nodes = await CrdtNode.db.find(
|
final scopeNodes = await CrdtScopeNode.db.find(
|
||||||
session,
|
session,
|
||||||
where: (t) =>
|
where: (t) =>
|
||||||
t.scopeId.equals(crdtUser.id) &
|
t.scopeId.equals(scope.id) & t.nodeId.notEquals(scope.currentNodeId),
|
||||||
t.uuidNodeId.notEquals(crdtUser.currentNode!.uuidNodeId),
|
include: CrdtScopeNode.include(node: CrdtNode.include()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return CrdtSyncSinceHlc(
|
return CrdtSyncSinceHlc(
|
||||||
|
uuidScopeId: scopeId,
|
||||||
nodeCheckpoints: [
|
nodeCheckpoints: [
|
||||||
// The local node is always included to avoid collecting its own changes.
|
// The local node is always included to avoid collecting its own changes.
|
||||||
Hlc.now(crdtUser.currentNode!.uuidNodeId),
|
Hlc.now(localNodeId),
|
||||||
for (final node in nodes) node.lastReceivedHlc ?? Hlc.zero(node.uuidNodeId),
|
for (final scopeNode in scopeNodes)
|
||||||
|
scopeNode.lastReceivedHlc ?? Hlc.zero(scopeNode.node!.uuidNodeId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -192,114 +218,111 @@ class CrdtSync {
|
||||||
///
|
///
|
||||||
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
|
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
|
||||||
/// empty.
|
/// empty.
|
||||||
Future<Hlc?> mergeInboundBatch(
|
Future<Hlc?> _mergeInboundBatch(
|
||||||
DatabaseSession session, {
|
DatabaseSession session, {
|
||||||
required UuidValue userId,
|
required UuidValue scopeId,
|
||||||
required UuidValue otherNodeId,
|
required UuidValue otherNodeId,
|
||||||
required CrdtMergeSet mergeSet,
|
required CrdtMergeSet mergeSet,
|
||||||
}) async {
|
}) async {
|
||||||
if (mergeSet.isEmpty) return null;
|
if (mergeSet.isEmpty) return null;
|
||||||
final maxSyncedHlc = mergeSet.maxHlc;
|
final maxSyncedHlc = mergeSet.maxHlc;
|
||||||
final crdtDb = _openCrdtDatabase(session);
|
final crdtDb = _openCrdtDatabase(session);
|
||||||
await crdtDb.mergeChanges(mergeSet, scopeId: userId);
|
await crdtDb.mergeChanges(mergeSet, scopeId: scopeId);
|
||||||
if (maxSyncedHlc != null) {
|
if (maxSyncedHlc != null) {
|
||||||
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: userId);
|
await crdtDb.recordSyncCheckpoint(otherNodeId, maxSyncedHlc, userId: scopeId);
|
||||||
}
|
}
|
||||||
return maxSyncedHlc;
|
return maxSyncedHlc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streams a framed outbound sync batch of merge changes.
|
|
||||||
Stream<CrdtSyncStreamEvent> streamOutboundBatch(
|
|
||||||
DatabaseSession session, {
|
|
||||||
required UuidValue userId,
|
|
||||||
required UuidValue peerNodeId,
|
|
||||||
required List<Hlc> nodeCheckpoints,
|
|
||||||
}) async* {
|
|
||||||
yield* collectPendingChanges(
|
|
||||||
session,
|
|
||||||
userId: userId,
|
|
||||||
peerNodeId: peerNodeId,
|
|
||||||
nodeCheckpoints: nodeCheckpoints,
|
|
||||||
)
|
|
||||||
.chunked(_syncBatchSize)
|
|
||||||
.map(
|
|
||||||
(changes) => CrdtSyncMergeChunk(changes: changes),
|
|
||||||
);
|
|
||||||
yield CrdtSyncEndOfBatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
|
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
|
||||||
///
|
///
|
||||||
/// Both peers send [CrdtSyncConnect], read the peer connect frame, then send
|
/// Both peers exchange [CrdtSyncConnect] and a lockstep [CrdtSyncScopeSet]
|
||||||
/// and read [CrdtSyncSinceHlc] once. After that, both sides exchange framed
|
/// before the data loop. Each cycle sends a combined batch — scope
|
||||||
/// data batches using the peer's per-node checkpoints. Sent changes advance
|
/// announcement when grants changed, [CrdtSyncSinceHlc] for newly active
|
||||||
/// the corresponding in-memory checkpoints for subsequent sends within the
|
/// scopes, and [CrdtSyncMergeChunk]s — closed by [CrdtSyncEndOfBatch] when
|
||||||
/// session.
|
/// anything was sent. Inbound frames are de-multiplexed by collectNextBatch
|
||||||
|
/// until [CrdtSyncEndOfBatch] when this peer sent a batch, or an idle timeout
|
||||||
|
/// when both peers had nothing to send.
|
||||||
///
|
///
|
||||||
/// If a batch fails to merge, the exception propagates and the stream closes.
|
/// When [once] is true the loop may run an extra cycle after handshakes
|
||||||
/// The next sync attempt resumes from the last persisted checkpoint on the
|
/// complete so merge data can flow; then it closes symmetrically. Continuous
|
||||||
/// side that failed to merge.
|
/// mode loops with [_continuousSyncInterval] between idle cycles.
|
||||||
///
|
|
||||||
/// When [once] is true, each peer sends one batch, reads one batch, sends a
|
|
||||||
/// [CrdtSyncClose] frame and awaits for the other to send its closing event
|
|
||||||
/// to complete the session.
|
|
||||||
Stream<CrdtSyncStreamEvent> sync(
|
Stream<CrdtSyncStreamEvent> sync(
|
||||||
DatabaseSession session, {
|
DatabaseSession session, {
|
||||||
required UuidValue userId,
|
required UuidValue userId,
|
||||||
required Stream<CrdtSyncStreamEvent> inbound,
|
required Stream<CrdtSyncStreamEvent> inbound,
|
||||||
|
required CrdtSyncPeerMode mode,
|
||||||
bool once = false,
|
bool once = false,
|
||||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||||
}) async* {
|
}) async* {
|
||||||
|
// Idle timeouts are a continuous-only affordance: they let an idle cycle
|
||||||
|
// settle into an empty batch without closing the stream. A `once` session
|
||||||
|
// is strictly lockstep — every batch ends with a [CrdtSyncEndOfBatch] and
|
||||||
|
// the session with a [CrdtSyncClose] — so it must wait for those end frames
|
||||||
|
// rather than truncate a slow peer's batch on a timeout.
|
||||||
final inboundIterator = StreamIterator(
|
final inboundIterator = StreamIterator(
|
||||||
// Inject a timeout event if the inbound stream is idle for too long to
|
once
|
||||||
// allow returning an empty batch and waiting for a change without closing
|
? inbound
|
||||||
// the stream.
|
: inbound.timeout(
|
||||||
inbound.timeout(
|
const Duration(seconds: 1),
|
||||||
const Duration(seconds: 1),
|
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
|
||||||
onTimeout: (sink) => sink.add(CrdtSyncIdleTimeout()),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var sessionCompleted = false;
|
var sessionCompleted = false;
|
||||||
try {
|
try {
|
||||||
final crdtUser = await CrdtScopeManager(session).getOrCreate(userId);
|
final scope = await CrdtScopeManager(session).getOrCreate(userId);
|
||||||
final localNodeId = crdtUser.currentNode!.uuidNodeId;
|
final localNodeId = scope.currentNode!.uuidNodeId;
|
||||||
|
|
||||||
yield CrdtSyncConnect(
|
yield CrdtSyncConnect(
|
||||||
localNodeId: localNodeId,
|
localNodeId: localNodeId,
|
||||||
syncTablesHash: currentSyncTablesHash,
|
syncTablesHash: currentSyncTablesHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
|
final peerConnect = await inboundIterator.moveAndThrowIfNot<CrdtSyncConnect>();
|
||||||
final peerNodeId = peerConnect.localNodeId;
|
|
||||||
_validateSyncTablesHash(peerConnect.syncTablesHash);
|
_validateSyncTablesHash(peerConnect.syncTablesHash);
|
||||||
|
|
||||||
yield await createSyncSinceHlc(
|
final scopes = CrdtScopeSyncSession(
|
||||||
session,
|
session,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
peerNodeId: peerNodeId,
|
mode: mode,
|
||||||
|
peerNodeId: peerConnect.localNodeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>();
|
await scopes.reconcile();
|
||||||
final nodeCheckpoints = {
|
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
|
||||||
for (final checkpoint in peerSinceHlc.nodeCheckpoints)
|
scopes.markAnnounced();
|
||||||
checkpoint.nodeId: checkpoint,
|
final peerScopeSet = await inboundIterator.moveAndThrowIfNot<CrdtSyncScopeSet>();
|
||||||
};
|
await scopes.adoptPeerGrants(peerScopeSet.scopes);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
await scopes.reconcile();
|
||||||
|
|
||||||
|
final hadSendableCheckpoints = scopes.sendableCheckpoints.isNotEmpty;
|
||||||
|
var hasChanges = false;
|
||||||
|
final outboundScopes = <UuidValue>{};
|
||||||
|
|
||||||
|
if (scopes.shouldAnnounce) {
|
||||||
|
yield CrdtSyncScopeSet(scopes: scopes.localGrants);
|
||||||
|
scopes.markAnnounced();
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final scopeId in scopes.activeScopeIds) {
|
||||||
|
if (!scopes.markHandshakeSent(scopeId)) continue;
|
||||||
|
yield await createSyncSinceHlc(session, scopeId: scopeId);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
final pendingLocalChanges = collectPendingChanges(
|
final pendingLocalChanges = collectPendingChanges(
|
||||||
session,
|
session,
|
||||||
userId: userId,
|
checkpointsByScopeUuid: scopes.sendableCheckpoints,
|
||||||
peerNodeId: peerNodeId,
|
|
||||||
nodeCheckpoints: nodeCheckpoints.values.toList(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var hasChanges = false;
|
|
||||||
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
|
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
for (final change in changes) {
|
for (final change in changes) {
|
||||||
final lastCheckpoint = nodeCheckpoints[change.uuidNodeId];
|
scopes.advanceCheckpoint(change.uuidScopeId, change);
|
||||||
nodeCheckpoints[change.uuidNodeId] = change.hlc.maxBetween(lastCheckpoint);
|
outboundScopes.add(change.uuidScopeId);
|
||||||
}
|
}
|
||||||
yield CrdtSyncMergeChunk(changes: changes);
|
yield CrdtSyncMergeChunk(changes: changes);
|
||||||
}
|
}
|
||||||
|
|
@ -307,28 +330,32 @@ class CrdtSync {
|
||||||
yield CrdtSyncEndOfBatch();
|
yield CrdtSyncEndOfBatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
final inboundMergeSet = await inboundIterator.collectNextBatch(
|
final batch = await inboundIterator.collectNextBatch(
|
||||||
allowCloseBeforeBatch: !once,
|
allowCloseBeforeBatch: !once,
|
||||||
);
|
);
|
||||||
if (inboundMergeSet == null) {
|
if (batch == null) {
|
||||||
|
if (once) {
|
||||||
|
yield CrdtSyncClose();
|
||||||
|
}
|
||||||
sessionCompleted = true;
|
sessionCompleted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final lastReceivedFromPeer = await mergeInboundBatch(
|
await _applyCycleBatch(
|
||||||
session,
|
session,
|
||||||
userId: userId,
|
scopes,
|
||||||
otherNodeId: peerNodeId,
|
batch,
|
||||||
mergeSet: inboundMergeSet,
|
outboundScopes,
|
||||||
|
onMergeSuccess,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasChanges || inboundMergeSet.isNotEmpty) {
|
|
||||||
await onMergeSuccess?.call(
|
|
||||||
nodeCheckpoints.values.max.maxBetween(lastReceivedFromPeer),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (once) {
|
if (once) {
|
||||||
|
if (scopes.hasIncompleteActiveHandshake) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!hadSendableCheckpoints && scopes.sendableCheckpoints.isNotEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
yield CrdtSyncClose();
|
yield CrdtSyncClose();
|
||||||
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
|
await inboundIterator.moveAndThrowIfNot<CrdtSyncClose>();
|
||||||
sessionCompleted = true;
|
sessionCompleted = true;
|
||||||
|
|
@ -349,6 +376,17 @@ class CrdtSync {
|
||||||
// Wait for the configured interval before checking for local changes again.
|
// Wait for the configured interval before checking for local changes again.
|
||||||
await Future<void>.delayed(_continuousSyncInterval);
|
await Future<void>.delayed(_continuousSyncInterval);
|
||||||
}
|
}
|
||||||
|
} on CrdtSyncStreamClosedException {
|
||||||
|
// A continuous session ending is normal: the peer closed its outbound
|
||||||
|
// (typically a cancel). Whether the stream closed between batches or
|
||||||
|
// mid-batch, nothing is left to do — complete gracefully so the `finally`
|
||||||
|
// skips the force-cancel that races WebSocket teardown and surfaces
|
||||||
|
// spurious "connection closed" errors on the peer. A `once` session has a
|
||||||
|
// completion contract (full handshake plus the symmetric Close), so a
|
||||||
|
// close before that is a real truncation and must propagate.
|
||||||
|
if (once) rethrow;
|
||||||
|
sessionCompleted = true;
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
// Cancelling inbound on normal completion races with WebSocket stream
|
// Cancelling inbound on normal completion races with WebSocket stream
|
||||||
// teardown and produces "connection closed" errors on the peer. On normal
|
// teardown and produces "connection closed" errors on the peer. On normal
|
||||||
|
|
@ -363,6 +401,112 @@ class CrdtSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyCycleBatch(
|
||||||
|
DatabaseSession session,
|
||||||
|
CrdtScopeSyncSession scopes,
|
||||||
|
CrdtSyncCycleBatch batch,
|
||||||
|
Set<UuidValue> outboundScopes,
|
||||||
|
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||||
|
) async {
|
||||||
|
if (batch.scopeSet != null) {
|
||||||
|
await scopes.adoptPeerGrants(batch.scopeSet!.scopes);
|
||||||
|
}
|
||||||
|
for (final entry in batch.sinceHlcs.entries) {
|
||||||
|
if (scopes.accepts(entry.key)) {
|
||||||
|
scopes.recordPeerHandshake(entry.key, entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mergedScopes = <UuidValue>{};
|
||||||
|
final changesByScope = <UuidValue, List<CrdtMergeChange>>{};
|
||||||
|
for (final change in batch.changes) {
|
||||||
|
changesByScope.putIfAbsent(change.uuidScopeId, () => []).add(change);
|
||||||
|
}
|
||||||
|
for (final entry in changesByScope.entries) {
|
||||||
|
final scopeId = entry.key;
|
||||||
|
if (!scopes.accepts(scopeId)) continue;
|
||||||
|
mergedScopes.add(scopeId);
|
||||||
|
if (scopes.isAuthoritative) {
|
||||||
|
await _assertCanMergeInboundScope(
|
||||||
|
session,
|
||||||
|
scopeId: scopeId,
|
||||||
|
userId: scopes.userId,
|
||||||
|
changes: entry.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final receivedHlc = await _mergeInboundBatch(
|
||||||
|
session,
|
||||||
|
scopeId: scopeId,
|
||||||
|
otherNodeId: scopes.peerNodeId,
|
||||||
|
mergeSet: entry.value,
|
||||||
|
);
|
||||||
|
await _reportMerge(onMergeSuccess, scopes, scopeId, receivedHlc);
|
||||||
|
}
|
||||||
|
for (final scopeId in outboundScopes.difference(mergedScopes)) {
|
||||||
|
await _reportMerge(onMergeSuccess, scopes, scopeId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _assertCanMergeInboundScope(
|
||||||
|
DatabaseSession session, {
|
||||||
|
required UuidValue scopeId,
|
||||||
|
required UuidValue userId,
|
||||||
|
required List<CrdtMergeChange> changes,
|
||||||
|
}) async {
|
||||||
|
if (changes.isEmpty || userId == scopeId) return;
|
||||||
|
|
||||||
|
final role = await CrdtScopeMembership.roleOf(
|
||||||
|
session,
|
||||||
|
userUuid: userId,
|
||||||
|
scopeUuid: scopeId,
|
||||||
|
);
|
||||||
|
if (role.canWrite) return;
|
||||||
|
|
||||||
|
final firstChange = changes.first;
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final violation = CrdtSyncIntegrityViolation(
|
||||||
|
type: CrdtSyncViolationType.unauthorizedWrite,
|
||||||
|
domainTableName: firstChange.tableName,
|
||||||
|
uuidRowId: firstChange.uuidRowId,
|
||||||
|
ownerScopeUuid: null,
|
||||||
|
incomingScopeUuid: scopeId,
|
||||||
|
operation: _operationForInboundChange(firstChange),
|
||||||
|
uuidNodeId: firstChange.uuidNodeId,
|
||||||
|
crdtDataRowId: null,
|
||||||
|
hlcDatetime: firstChange.hlcDatetime,
|
||||||
|
hlcCounter: firstChange.hlcCounter,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
occurrences: 1,
|
||||||
|
);
|
||||||
|
final persisted = await recordCrdtSyncIntegrityViolation(
|
||||||
|
session,
|
||||||
|
violation: violation,
|
||||||
|
);
|
||||||
|
throw CrdtSyncIntegrityViolationException(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
CrdtSyncViolationOperation _operationForInboundChange(CrdtMergeChange change) {
|
||||||
|
return switch (change) {
|
||||||
|
CrdtMergeInsert() => CrdtSyncViolationOperation.mergeInsert,
|
||||||
|
CrdtMergeUpdate() => CrdtSyncViolationOperation.mergeUpdate,
|
||||||
|
CrdtMergeDelete() => CrdtSyncViolationOperation.mergeDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports a successful merge for [scopeId] to [onMergeSuccess], combining the
|
||||||
|
/// scope's checkpoint high-water mark with the [receivedHlc] just merged.
|
||||||
|
Future<void> _reportMerge(
|
||||||
|
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||||
|
CrdtScopeSyncSession scopes,
|
||||||
|
UuidValue scopeId,
|
||||||
|
Hlc? receivedHlc,
|
||||||
|
) async {
|
||||||
|
final checkpointMax = scopes.checkpointMaxOf(scopeId);
|
||||||
|
if (checkpointMax == null) return;
|
||||||
|
await onMergeSuccess?.call(scopeId, checkpointMax.maxBetween(receivedHlc));
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains [iterator] until the peer closes the stream.
|
/// Drains [iterator] until the peer closes the stream.
|
||||||
///
|
///
|
||||||
/// Used to settle the inbound transport after the `once` close handshake so
|
/// Used to settle the inbound transport after the `once` close handshake so
|
||||||
|
|
@ -400,26 +544,26 @@ class CrdtSync {
|
||||||
|
|
||||||
Stream<CrdtMergeChange> _streamPendingChanges(
|
Stream<CrdtMergeChange> _streamPendingChanges(
|
||||||
DatabaseSession session,
|
DatabaseSession session,
|
||||||
CrdtScope crdtUser,
|
Map<int, UuidValue> scopeUuidById,
|
||||||
List<Hlc> nodeCheckpoints,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
) async* {
|
) async* {
|
||||||
// Domain ownership is immutable while a collection runs, so read each
|
// Domain ownership is immutable while a collection runs, so read each
|
||||||
// row's owner at most once across all three streams.
|
// row's owner at most once across all three streams.
|
||||||
final ownerCache = DomainRowOwnerCache();
|
final ownerCache = DomainRowOwnerCache();
|
||||||
yield* _streamInserts(session, crdtUser, nodeCheckpoints, ownerCache);
|
yield* _streamInserts(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||||
yield* _streamUpdates(session, crdtUser, nodeCheckpoints, ownerCache);
|
yield* _streamUpdates(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||||
yield* _streamDeletes(session, crdtUser, nodeCheckpoints, ownerCache);
|
yield* _streamDeletes(session, scopeUuidById, checkpointsByScopeId, ownerCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<CrdtMergeInsert> _streamInserts(
|
Stream<CrdtMergeInsert> _streamInserts(
|
||||||
DatabaseSession session,
|
DatabaseSession session,
|
||||||
CrdtScope crdtUser,
|
Map<int, UuidValue> scopeUuidById,
|
||||||
List<Hlc> nodeCheckpoints,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
DomainRowOwnerCache ownerCache,
|
DomainRowOwnerCache ownerCache,
|
||||||
) async* {
|
) async* {
|
||||||
final rows = await CrdtDataRow.db.find(
|
final rows = await CrdtDataRow.db.find(
|
||||||
session,
|
session,
|
||||||
where: (t) => _rowHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
where: (t) => _rowHlcAfterFilter(t, checkpointsByScopeId),
|
||||||
include: CrdtDataRow.include(
|
include: CrdtDataRow.include(
|
||||||
tbl: CrdtSchemaTable.include(),
|
tbl: CrdtSchemaTable.include(),
|
||||||
node: CrdtNode.include(),
|
node: CrdtNode.include(),
|
||||||
|
|
@ -439,6 +583,9 @@ class CrdtSync {
|
||||||
final dartName = _classNamesByTableName[tableName];
|
final dartName = _classNamesByTableName[tableName];
|
||||||
if (dartName == null) continue;
|
if (dartName == null) continue;
|
||||||
|
|
||||||
|
final scopeId = row.scopeId;
|
||||||
|
final scopeUuid = scopeUuidById[scopeId]!;
|
||||||
|
|
||||||
final domainRow = await _fetchDomainRow(
|
final domainRow = await _fetchDomainRow(
|
||||||
session,
|
session,
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -446,7 +593,7 @@ class CrdtSync {
|
||||||
table,
|
table,
|
||||||
dartName,
|
dartName,
|
||||||
foreignKeyAttemptFieldsByRowId[row.id!],
|
foreignKeyAttemptFieldsByRowId[row.id!],
|
||||||
crdtUser.id!,
|
scopeId,
|
||||||
ownerCache,
|
ownerCache,
|
||||||
);
|
);
|
||||||
if (!domainRow.exists) {
|
if (!domainRow.exists) {
|
||||||
|
|
@ -457,12 +604,12 @@ class CrdtSync {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
rowId: row.uuidRowId,
|
rowId: row.uuidRowId,
|
||||||
ownerScopeId: null,
|
ownerScopeId: null,
|
||||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
incomingScopeUuid: scopeUuid,
|
||||||
uuidNodeId: row.node!.uuidNodeId,
|
uuidNodeId: row.node!.uuidNodeId,
|
||||||
hlc: row.hlc,
|
hlc: row.hlc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (domainRow.ownerScopeId != crdtUser.id) {
|
if (domainRow.ownerScopeId != scopeId) {
|
||||||
_throwPendingIntegrityViolation(
|
_throwPendingIntegrityViolation(
|
||||||
crdtDataRowId: row.id,
|
crdtDataRowId: row.id,
|
||||||
type: CrdtSyncViolationType.ownershipCollision,
|
type: CrdtSyncViolationType.ownershipCollision,
|
||||||
|
|
@ -470,13 +617,14 @@ class CrdtSync {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
rowId: row.uuidRowId,
|
rowId: row.uuidRowId,
|
||||||
ownerScopeId: domainRow.ownerScopeId,
|
ownerScopeId: domainRow.ownerScopeId,
|
||||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
incomingScopeUuid: scopeUuid,
|
||||||
uuidNodeId: row.node!.uuidNodeId,
|
uuidNodeId: row.node!.uuidNodeId,
|
||||||
hlc: row.hlc,
|
hlc: row.hlc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield CrdtMergeInsert(
|
yield CrdtMergeInsert(
|
||||||
|
uuidScopeId: scopeUuid,
|
||||||
hlcDatetime: row.hlcDatetime,
|
hlcDatetime: row.hlcDatetime,
|
||||||
hlcCounter: row.hlcCounter,
|
hlcCounter: row.hlcCounter,
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
|
|
@ -489,13 +637,13 @@ class CrdtSync {
|
||||||
|
|
||||||
Stream<CrdtMergeUpdate> _streamUpdates(
|
Stream<CrdtMergeUpdate> _streamUpdates(
|
||||||
DatabaseSession session,
|
DatabaseSession session,
|
||||||
CrdtScope crdtUser,
|
Map<int, UuidValue> scopeUuidById,
|
||||||
List<Hlc> nodeCheckpoints,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
DomainRowOwnerCache ownerCache,
|
DomainRowOwnerCache ownerCache,
|
||||||
) async* {
|
) async* {
|
||||||
final fields = await CrdtDataField.db.find(
|
final fields = await CrdtDataField.db.find(
|
||||||
session,
|
session,
|
||||||
where: (t) => _fieldHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
where: (t) => _fieldHlcAfterFilter(t, checkpointsByScopeId),
|
||||||
include: CrdtDataField.include(
|
include: CrdtDataField.include(
|
||||||
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
||||||
column: CrdtSchemaColumn.include(),
|
column: CrdtSchemaColumn.include(),
|
||||||
|
|
@ -513,6 +661,8 @@ class CrdtSync {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final scopeId = field.row!.scopeId;
|
||||||
|
final scopeUuid = scopeUuidById[scopeId]!;
|
||||||
final columnName = field.column!.name;
|
final columnName = field.column!.name;
|
||||||
final columnValue = await _fetchOwnedColumnValue(
|
final columnValue = await _fetchOwnedColumnValue(
|
||||||
session,
|
session,
|
||||||
|
|
@ -520,7 +670,7 @@ class CrdtSync {
|
||||||
field.row!.uuidRowId,
|
field.row!.uuidRowId,
|
||||||
columnName,
|
columnName,
|
||||||
field.foreignKey,
|
field.foreignKey,
|
||||||
crdtUser.id!,
|
scopeId,
|
||||||
ownerCache,
|
ownerCache,
|
||||||
);
|
);
|
||||||
if (!columnValue.exists) {
|
if (!columnValue.exists) {
|
||||||
|
|
@ -531,12 +681,12 @@ class CrdtSync {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
rowId: field.row!.uuidRowId,
|
rowId: field.row!.uuidRowId,
|
||||||
ownerScopeId: null,
|
ownerScopeId: null,
|
||||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
incomingScopeUuid: scopeUuid,
|
||||||
uuidNodeId: field.node!.uuidNodeId,
|
uuidNodeId: field.node!.uuidNodeId,
|
||||||
hlc: field.hlc,
|
hlc: field.hlc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (columnValue.ownerScopeId != crdtUser.id) {
|
if (columnValue.ownerScopeId != scopeId) {
|
||||||
_throwPendingIntegrityViolation(
|
_throwPendingIntegrityViolation(
|
||||||
crdtDataRowId: field.row!.id,
|
crdtDataRowId: field.row!.id,
|
||||||
type: CrdtSyncViolationType.ownershipCollision,
|
type: CrdtSyncViolationType.ownershipCollision,
|
||||||
|
|
@ -544,13 +694,14 @@ class CrdtSync {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
rowId: field.row!.uuidRowId,
|
rowId: field.row!.uuidRowId,
|
||||||
ownerScopeId: columnValue.ownerScopeId,
|
ownerScopeId: columnValue.ownerScopeId,
|
||||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
incomingScopeUuid: scopeUuid,
|
||||||
uuidNodeId: field.node!.uuidNodeId,
|
uuidNodeId: field.node!.uuidNodeId,
|
||||||
hlc: field.hlc,
|
hlc: field.hlc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield CrdtMergeUpdate(
|
yield CrdtMergeUpdate(
|
||||||
|
uuidScopeId: scopeUuid,
|
||||||
hlcDatetime: field.hlcDatetime,
|
hlcDatetime: field.hlcDatetime,
|
||||||
hlcCounter: field.hlcCounter,
|
hlcCounter: field.hlcCounter,
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
|
|
@ -564,13 +715,13 @@ class CrdtSync {
|
||||||
|
|
||||||
Stream<CrdtMergeDelete> _streamDeletes(
|
Stream<CrdtMergeDelete> _streamDeletes(
|
||||||
DatabaseSession session,
|
DatabaseSession session,
|
||||||
CrdtScope crdtUser,
|
Map<int, UuidValue> scopeUuidById,
|
||||||
List<Hlc> nodeCheckpoints,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
DomainRowOwnerCache ownerCache,
|
DomainRowOwnerCache ownerCache,
|
||||||
) async* {
|
) async* {
|
||||||
final tombstones = await CrdtDataDeleted.db.find(
|
final tombstones = await CrdtDataDeleted.db.find(
|
||||||
session,
|
session,
|
||||||
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, nodeCheckpoints),
|
where: (t) => _tombstoneHlcAfterFilter(t, checkpointsByScopeId),
|
||||||
include: CrdtDataDeleted.include(
|
include: CrdtDataDeleted.include(
|
||||||
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
|
||||||
node: CrdtNode.include(),
|
node: CrdtNode.include(),
|
||||||
|
|
@ -582,13 +733,16 @@ class CrdtSync {
|
||||||
|
|
||||||
final tableName = tombstone.row!.tbl!.name;
|
final tableName = tombstone.row!.tbl!.name;
|
||||||
if (!_syncTablesByName.containsKey(tableName)) continue;
|
if (!_syncTablesByName.containsKey(tableName)) continue;
|
||||||
|
|
||||||
|
final scopeId = tombstone.row!.scopeId;
|
||||||
|
final scopeUuid = scopeUuidById[scopeId]!;
|
||||||
final owner = await _readDomainRowOwner(
|
final owner = await _readDomainRowOwner(
|
||||||
session,
|
session,
|
||||||
tableName,
|
tableName,
|
||||||
tombstone.row!.uuidRowId,
|
tombstone.row!.uuidRowId,
|
||||||
ownerCache,
|
ownerCache,
|
||||||
);
|
);
|
||||||
if (owner.exists && owner.scopeId != crdtUser.id) {
|
if (owner.exists && owner.scopeId != scopeId) {
|
||||||
_throwPendingIntegrityViolation(
|
_throwPendingIntegrityViolation(
|
||||||
crdtDataRowId: tombstone.row!.id,
|
crdtDataRowId: tombstone.row!.id,
|
||||||
type: CrdtSyncViolationType.ownershipCollision,
|
type: CrdtSyncViolationType.ownershipCollision,
|
||||||
|
|
@ -596,13 +750,14 @@ class CrdtSync {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
rowId: tombstone.row!.uuidRowId,
|
rowId: tombstone.row!.uuidRowId,
|
||||||
ownerScopeId: owner.scopeId,
|
ownerScopeId: owner.scopeId,
|
||||||
incomingScopeUuid: crdtUser.uuidScopeId,
|
incomingScopeUuid: scopeUuid,
|
||||||
uuidNodeId: tombstone.node!.uuidNodeId,
|
uuidNodeId: tombstone.node!.uuidNodeId,
|
||||||
hlc: tombstone.hlc,
|
hlc: tombstone.hlc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield CrdtMergeDelete(
|
yield CrdtMergeDelete(
|
||||||
|
uuidScopeId: scopeUuid,
|
||||||
hlcDatetime: tombstone.hlcDatetime,
|
hlcDatetime: tombstone.hlcDatetime,
|
||||||
hlcCounter: tombstone.hlcCounter,
|
hlcCounter: tombstone.hlcCounter,
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
|
|
@ -616,41 +771,41 @@ class CrdtSync {
|
||||||
|
|
||||||
Expression _rowHlcAfterFilter(
|
Expression _rowHlcAfterFilter(
|
||||||
CrdtDataRowTable t,
|
CrdtDataRowTable t,
|
||||||
CrdtScope crdtUser,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
List<Hlc> nodeCheckpoints,
|
|
||||||
) =>
|
) =>
|
||||||
t.scopeId.equals(crdtUser.id) &
|
t.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||||
t.node.afterAnyCheckpointFilter(
|
_afterAnyScopeCheckpointFilter(
|
||||||
|
t.scopeId,
|
||||||
t.node.uuidNodeId,
|
t.node.uuidNodeId,
|
||||||
t.hlcDatetime,
|
t.hlcDatetime,
|
||||||
t.hlcCounter,
|
t.hlcCounter,
|
||||||
nodeCheckpoints,
|
checkpointsByScopeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
Expression _fieldHlcAfterFilter(
|
Expression _fieldHlcAfterFilter(
|
||||||
CrdtDataFieldTable t,
|
CrdtDataFieldTable t,
|
||||||
CrdtScope crdtUser,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
List<Hlc> nodeCheckpoints,
|
|
||||||
) =>
|
) =>
|
||||||
t.row.scopeId.equals(crdtUser.id) &
|
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||||
t.node.afterAnyCheckpointFilter(
|
_afterAnyScopeCheckpointFilter(
|
||||||
|
t.row.scopeId,
|
||||||
t.node.uuidNodeId,
|
t.node.uuidNodeId,
|
||||||
t.hlcDatetime,
|
t.hlcDatetime,
|
||||||
t.hlcCounter,
|
t.hlcCounter,
|
||||||
nodeCheckpoints,
|
checkpointsByScopeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
Expression _tombstoneHlcAfterFilter(
|
Expression _tombstoneHlcAfterFilter(
|
||||||
CrdtDataDeletedTable t,
|
CrdtDataDeletedTable t,
|
||||||
CrdtScope crdtUser,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
List<Hlc> nodeCheckpoints,
|
|
||||||
) =>
|
) =>
|
||||||
t.row.scopeId.equals(crdtUser.id) &
|
t.row.scopeId.inSet(checkpointsByScopeId.keys.toSet()) &
|
||||||
t.node.afterAnyCheckpointFilter(
|
_afterAnyScopeCheckpointFilter(
|
||||||
|
t.row.scopeId,
|
||||||
t.node.uuidNodeId,
|
t.node.uuidNodeId,
|
||||||
t.hlcDatetime,
|
t.hlcDatetime,
|
||||||
t.hlcCounter,
|
t.hlcCounter,
|
||||||
nodeCheckpoints,
|
checkpointsByScopeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Loads a domain row for outbound insert sync.
|
/// Loads a domain row for outbound insert sync.
|
||||||
|
|
@ -959,27 +1114,31 @@ class CrdtSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on CrdtNodeTable {
|
Expression _afterAnyScopeCheckpointFilter(
|
||||||
Expression afterAnyCheckpointFilter(
|
ColumnInt scopeId,
|
||||||
ColumnUuid uuidNodeId,
|
ColumnUuid uuidNodeId,
|
||||||
ColumnDateTime hlcDatetime,
|
ColumnDateTime hlcDatetime,
|
||||||
ColumnInt hlcCounter,
|
ColumnInt hlcCounter,
|
||||||
List<Hlc> nodeCheckpoints,
|
Map<int, List<Hlc>> checkpointsByScopeId,
|
||||||
) {
|
) {
|
||||||
if (nodeCheckpoints.isEmpty) return Constant.bool(true);
|
final caseExpression = Case();
|
||||||
|
var hasCheckpoint = false;
|
||||||
final caseExpression = Case();
|
for (final MapEntry(key: normalizedScopeId, value: checkpoints)
|
||||||
for (final checkpoint in nodeCheckpoints) {
|
in checkpointsByScopeId.entries) {
|
||||||
|
for (final checkpoint in checkpoints) {
|
||||||
|
hasCheckpoint = true;
|
||||||
caseExpression.when(
|
caseExpression.when(
|
||||||
uuidNodeId.equals(checkpoint.nodeId),
|
scopeId.equals(normalizedScopeId) & uuidNodeId.equals(checkpoint.nodeId),
|
||||||
then:
|
then:
|
||||||
(hlcDatetime > checkpoint.datetime) |
|
(hlcDatetime > checkpoint.datetime) |
|
||||||
(hlcDatetime.equals(checkpoint.datetime) &
|
(hlcDatetime.equals(checkpoint.datetime) &
|
||||||
(hlcCounter > checkpoint.counter)),
|
(hlcCounter > checkpoint.counter)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return caseExpression.orElse(Constant.bool(true));
|
|
||||||
}
|
}
|
||||||
|
return hasCheckpoint
|
||||||
|
? caseExpression.orElse(Constant.bool(true))
|
||||||
|
: Constant.bool(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on Map<String, dynamic> {
|
extension on Map<String, dynamic> {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
// flag internal Serverpod APIs used by generated code.
|
// flag internal Serverpod APIs used by generated code.
|
||||||
// ignore_for_file: invalid_use_of_internal_member
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:serverpod_database/serverpod_database.dart';
|
import 'package:serverpod_database/serverpod_database.dart';
|
||||||
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../crdt/exceptions.dart';
|
import '../crdt/exceptions.dart';
|
||||||
|
import '../crdt/extensions.dart';
|
||||||
import '../crdt/integrity_violation.dart';
|
import '../crdt/integrity_violation.dart';
|
||||||
import '../crdt/merge.dart';
|
import '../crdt/merge.dart';
|
||||||
|
import '../crdt/scope_membership.dart';
|
||||||
import '../crdt/sync.dart';
|
import '../crdt/sync.dart';
|
||||||
import '../protocol/protocol.dart';
|
import '../protocol/protocol.dart';
|
||||||
import 'recorder.dart';
|
import 'recorder.dart';
|
||||||
|
|
@ -20,6 +24,9 @@ part 'scope.dart';
|
||||||
/// Map of transaction hashes to the scope they are associated with.
|
/// Map of transaction hashes to the scope they are associated with.
|
||||||
final scopeForTransaction = <Transaction, CrdtScope>{};
|
final scopeForTransaction = <Transaction, CrdtScope>{};
|
||||||
|
|
||||||
|
/// Map of transaction hashes to the authenticated user associated with them.
|
||||||
|
final userForTransaction = <Transaction, UuidValue>{};
|
||||||
|
|
||||||
/// Database proxy that runs insert/update/delete ORM operations inside a
|
/// Database proxy that runs insert/update/delete ORM operations inside a
|
||||||
/// transaction to record each change in the CRDT tables.
|
/// transaction to record each change in the CRDT tables.
|
||||||
class CrdtDatabase implements Database {
|
class CrdtDatabase implements Database {
|
||||||
|
|
@ -111,6 +118,7 @@ class CrdtDatabase implements Database {
|
||||||
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
|
/// Runs a symmetric CRDT sync session over a bidirectional event stream.
|
||||||
Stream<CrdtSyncStreamEvent> sync({
|
Stream<CrdtSyncStreamEvent> sync({
|
||||||
required Stream<CrdtSyncStreamEvent> inbound,
|
required Stream<CrdtSyncStreamEvent> inbound,
|
||||||
|
required CrdtSyncPeerMode mode,
|
||||||
UuidValue? userId,
|
UuidValue? userId,
|
||||||
bool once = false,
|
bool once = false,
|
||||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||||
|
|
@ -123,6 +131,7 @@ class CrdtDatabase implements Database {
|
||||||
inbound: inbound,
|
inbound: inbound,
|
||||||
once: once,
|
once: once,
|
||||||
onMergeSuccess: onMergeSuccess,
|
onMergeSuccess: onMergeSuccess,
|
||||||
|
mode: mode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,20 +193,26 @@ class CrdtDatabase implements Database {
|
||||||
_delegate.serializationManager;
|
_delegate.serializationManager;
|
||||||
|
|
||||||
/// Merges [where] with the CRDT visibility predicates scoped to the queried
|
/// Merges [where] with the CRDT visibility predicates scoped to the queried
|
||||||
/// tables and the user associated with [transaction] (or the persistent
|
/// tables and the user associated with [transaction] (or the persistent user).
|
||||||
/// user). Without a user scope (e.g. server-side admin reads), a row is
|
///
|
||||||
/// only hidden when every user tracking it has it hidden.
|
/// Read filters are membership-wide. Write filters stay pinned to the acting
|
||||||
Expression? _whereVisibleWithTombstone<T extends TableRow>(
|
/// scope so mutations cannot cross scope boundaries.
|
||||||
|
Future<Expression?> _whereVisibleWithTombstone<T extends TableRow>(
|
||||||
Expression? where,
|
Expression? where,
|
||||||
Include? include,
|
Include? include,
|
||||||
Transaction? transaction,
|
Transaction? transaction, {
|
||||||
) {
|
required bool membershipWide,
|
||||||
|
}) async {
|
||||||
|
final scopeIds = await _scopeIdsForQueries(
|
||||||
|
transaction,
|
||||||
|
membershipWide: membershipWide,
|
||||||
|
);
|
||||||
return mergeWhereWithTombstone<T>(
|
return mergeWhereWithTombstone<T>(
|
||||||
serializationManager,
|
serializationManager,
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
tableIdForName: _recorder.tableIdForName,
|
tableIdForName: _recorder.tableIdForName,
|
||||||
scopeId: () => _recorder.scopeForQueries(transaction)?.id,
|
scopeIds: () => scopeIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,7 +231,12 @@ class CrdtDatabase implements Database {
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
final result = await _delegate.find<T>(
|
final result = await _delegate.find<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
where: await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
transaction,
|
||||||
|
membershipWide: true,
|
||||||
|
),
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: orderBy,
|
orderBy: orderBy,
|
||||||
|
|
@ -244,7 +264,12 @@ class CrdtDatabase implements Database {
|
||||||
final table = serializationManager.getTableForType(T);
|
final table = serializationManager.getTableForType(T);
|
||||||
final where = table?.id.equals(id);
|
final where = table?.id.equals(id);
|
||||||
final result = await _delegate.findFirstRow<T>(
|
final result = await _delegate.findFirstRow<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
where: await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
transaction,
|
||||||
|
membershipWide: true,
|
||||||
|
),
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
include: include,
|
include: include,
|
||||||
lockMode: lockMode,
|
lockMode: lockMode,
|
||||||
|
|
@ -268,7 +293,12 @@ class CrdtDatabase implements Database {
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
final result = await _delegate.findFirstRow<T>(
|
final result = await _delegate.findFirstRow<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, include, transaction),
|
where: await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
transaction,
|
||||||
|
membershipWide: true,
|
||||||
|
),
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: orderBy,
|
orderBy: orderBy,
|
||||||
orderByList: orderByList,
|
orderByList: orderByList,
|
||||||
|
|
@ -289,6 +319,7 @@ class CrdtDatabase implements Database {
|
||||||
List<T> rows, {
|
List<T> rows, {
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
bool ignoreConflicts = false,
|
bool ignoreConflicts = false,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (rows.isEmpty) return [];
|
if (rows.isEmpty) return [];
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
|
|
@ -348,6 +379,7 @@ class CrdtDatabase implements Database {
|
||||||
List<Column>? updateColumns,
|
List<Column>? updateColumns,
|
||||||
Expression? updateWhere,
|
Expression? updateWhere,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
// TODO: Implement CRDT upsert.
|
// TODO: Implement CRDT upsert.
|
||||||
|
|
@ -381,6 +413,7 @@ class CrdtDatabase implements Database {
|
||||||
List<T> rows, {
|
List<T> rows, {
|
||||||
List<Column>? columns,
|
List<Column>? columns,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (rows.isEmpty) return [];
|
if (rows.isEmpty) return [];
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
|
|
@ -444,7 +477,12 @@ class CrdtDatabase implements Database {
|
||||||
final where = row.table.id.equals(row.id);
|
final where = row.table.id.equals(row.id);
|
||||||
final updatedRows = await _delegate.updateWhere<T>(
|
final updatedRows = await _delegate.updateWhere<T>(
|
||||||
columnValues: columnValues,
|
columnValues: columnValues,
|
||||||
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
|
where: (await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
null,
|
||||||
|
transaction,
|
||||||
|
membershipWide: false,
|
||||||
|
))!,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -489,6 +527,7 @@ class CrdtDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
if (_recorder.isCrdtTracked<T>()) {
|
if (_recorder.isCrdtTracked<T>()) {
|
||||||
|
|
@ -501,7 +540,12 @@ class CrdtDatabase implements Database {
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final result = await _delegate.updateWhere<T>(
|
final result = await _delegate.updateWhere<T>(
|
||||||
columnValues: columnValues,
|
columnValues: columnValues,
|
||||||
where: _whereVisibleWithTombstone<T>(where, null, tx)!,
|
where: (await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
null,
|
||||||
|
tx,
|
||||||
|
membershipWide: false,
|
||||||
|
))!,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: orderBy,
|
orderBy: orderBy,
|
||||||
|
|
@ -529,6 +573,7 @@ class CrdtDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (rows.isEmpty) return [];
|
if (rows.isEmpty) return [];
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
|
|
@ -572,6 +617,7 @@ class CrdtDatabase implements Database {
|
||||||
List<Column>? orderByList,
|
List<Column>? orderByList,
|
||||||
bool orderDescending = false,
|
bool orderDescending = false,
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
if (!_recorder.isCrdtTracked<T>()) {
|
if (!_recorder.isCrdtTracked<T>()) {
|
||||||
|
|
@ -591,7 +637,12 @@ class CrdtDatabase implements Database {
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final rows = await _delegate.find<T>(
|
final rows = await _delegate.find<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, null, tx),
|
where: await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
null,
|
||||||
|
tx,
|
||||||
|
membershipWide: false,
|
||||||
|
),
|
||||||
orderBy: orderBy,
|
orderBy: orderBy,
|
||||||
orderByList: orderByList,
|
orderByList: orderByList,
|
||||||
// Remove this once the deprecated member is removed.
|
// Remove this once the deprecated member is removed.
|
||||||
|
|
@ -615,7 +666,12 @@ class CrdtDatabase implements Database {
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
return _delegate.count<T>(
|
return _delegate.count<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, null, transaction),
|
where: await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
null,
|
||||||
|
transaction,
|
||||||
|
membershipWide: true,
|
||||||
|
),
|
||||||
limit: limit,
|
limit: limit,
|
||||||
useCache: useCache,
|
useCache: useCache,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
|
@ -631,7 +687,12 @@ class CrdtDatabase implements Database {
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
return _delegate.lockRows<T>(
|
return _delegate.lockRows<T>(
|
||||||
where: _whereVisibleWithTombstone<T>(where, null, transaction)!,
|
where: (await _whereVisibleWithTombstone<T>(
|
||||||
|
where,
|
||||||
|
null,
|
||||||
|
transaction,
|
||||||
|
membershipWide: false,
|
||||||
|
))!,
|
||||||
lockMode: lockMode,
|
lockMode: lockMode,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
lockBehavior: lockBehavior,
|
lockBehavior: lockBehavior,
|
||||||
|
|
@ -654,31 +715,100 @@ class CrdtDatabase implements Database {
|
||||||
|
|
||||||
/// Executes the [transactionFunction] in a transaction with the provided [userId].
|
/// Executes the [transactionFunction] in a transaction with the provided [userId].
|
||||||
///
|
///
|
||||||
/// The [userId] is used to record the CRDT operations in the database. This method
|
/// Without [scopeId], writes act in the user's personal scope. With [scopeId],
|
||||||
/// must be used instead of [transaction] when performing operations on the server,
|
/// [userId] stays the authenticated identity and [scopeId] is the scope being
|
||||||
/// where the database knows multiple users.
|
/// acted in; the pair is checked before the transaction starts.
|
||||||
Future<R> transactionForUser<R>(
|
Future<R> transactionForUser<R>(
|
||||||
UuidValue userId,
|
UuidValue userId,
|
||||||
TransactionFunction<R> transactionFunction, {
|
TransactionFunction<R> transactionFunction, {
|
||||||
|
UuidValue? scopeId,
|
||||||
TransactionSettings? settings,
|
TransactionSettings? settings,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
// Ensure that the user exists with a node before starting the transaction.
|
final effectiveScopeId = scopeId ?? userId;
|
||||||
final user = await _recorder.getOrCreateScope(userId);
|
await _assertCanActInScope(userId, effectiveScopeId);
|
||||||
|
|
||||||
|
// Ensure that the scope exists with a node before starting the transaction.
|
||||||
|
final scope = await _recorder.getOrCreateScope(effectiveScopeId);
|
||||||
|
|
||||||
return transaction<R>(
|
return transaction<R>(
|
||||||
(tx) async {
|
(tx) async {
|
||||||
try {
|
try {
|
||||||
scopeForTransaction[tx] = user;
|
scopeForTransaction[tx] = scope;
|
||||||
|
userForTransaction[tx] = userId;
|
||||||
return await transactionFunction(tx);
|
return await transactionFunction(tx);
|
||||||
} finally {
|
} finally {
|
||||||
scopeForTransaction.remove(tx);
|
scopeForTransaction.remove(tx);
|
||||||
|
userForTransaction.remove(tx);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings: settings,
|
settings: settings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _assertCanActInScope(UuidValue userId, UuidValue scopeId) async {
|
||||||
|
if (userId == scopeId) return;
|
||||||
|
|
||||||
|
// Authoritative membership: the source of truth on the server, the
|
||||||
|
// read-only cache on a follower (empty until populated). A null role means
|
||||||
|
// no membership row at all; a non-writable role means membership without
|
||||||
|
// write access.
|
||||||
|
final role = await CrdtScopeMembership.roleOf(
|
||||||
|
_delegate.session,
|
||||||
|
userUuid: userId,
|
||||||
|
scopeUuid: scopeId,
|
||||||
|
);
|
||||||
|
if (role == null) {
|
||||||
|
throw CrdtScopeMembershipException(userId: userId, scopeId: scopeId);
|
||||||
|
}
|
||||||
|
if (!role.canWrite) {
|
||||||
|
throw CrdtScopeRoleException(userId: userId, scopeId: scopeId, role: role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>?> _scopeIdsForQueries(
|
||||||
|
Transaction? transaction, {
|
||||||
|
required bool membershipWide,
|
||||||
|
}) async {
|
||||||
|
if (!membershipWide) {
|
||||||
|
return _actingScopeIdsForQueries(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
final userId = _userIdForQueries(transaction);
|
||||||
|
if (userId == null) return null;
|
||||||
|
|
||||||
|
// On the server this is authoritative membership; on a persistent client it
|
||||||
|
// is the server-projected membership cache.
|
||||||
|
return _scopeIdsForUuids(
|
||||||
|
await CrdtScopeMembership.memberScopes(_delegate.session, userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int>? _actingScopeIdsForQueries(Transaction? transaction) {
|
||||||
|
final scopeId = _recorder.scopeForQueries(transaction)?.id;
|
||||||
|
return scopeId == null ? null : [scopeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
UuidValue? _userIdForQueries(Transaction? transaction) {
|
||||||
|
if (transaction != null) {
|
||||||
|
final userId = userForTransaction[transaction];
|
||||||
|
if (userId != null) return userId;
|
||||||
|
}
|
||||||
|
return _recorder.persistentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> _scopeIdsForUuids(List<UuidValue> scopeUuids) async {
|
||||||
|
if (scopeUuids.isEmpty) return [];
|
||||||
|
final scopes = await CrdtScope.db.find(
|
||||||
|
_delegate.session,
|
||||||
|
where: (t) => t.uuidScopeId.inSet(scopeUuids.toSet()),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
for (final scope in scopes)
|
||||||
|
if (scope.id != null) scope.id!,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Future<UuidValue> _requireUserId(UuidValue? userId) async {
|
Future<UuidValue> _requireUserId(UuidValue? userId) async {
|
||||||
return userId ??
|
return userId ??
|
||||||
_recorder.persistentUserId ??
|
_recorder.persistentUserId ??
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ typedef _MergeContext = ({
|
||||||
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
Map<_MergeRowKey, CrdtDataDeleted> tombstones,
|
||||||
Map<_MergeRowKey, _DomainOwner> domainOwners,
|
Map<_MergeRowKey, _DomainOwner> domainOwners,
|
||||||
});
|
});
|
||||||
|
typedef _MergeNodes = ({
|
||||||
|
Map<UuidValue, CrdtNode> nodesByUuid,
|
||||||
|
Map<UuidValue, CrdtScopeNode> scopeNodesByUuid,
|
||||||
|
});
|
||||||
|
|
||||||
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
||||||
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
|
|
@ -47,6 +51,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
{for (final change in operations) change.uuidNodeId},
|
{for (final change in operations) change.uuidNodeId},
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
|
final nodesByUuid = remoteNodes.nodesByUuid;
|
||||||
final context = await _loadMergeContext(mergeSet, transaction);
|
final context = await _loadMergeContext(mergeSet, transaction);
|
||||||
|
|
||||||
for (final operation in operations) {
|
for (final operation in operations) {
|
||||||
|
|
@ -58,21 +63,21 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
case final CrdtMergeInsert insert:
|
case final CrdtMergeInsert insert:
|
||||||
await _applyMergeInsert(
|
await _applyMergeInsert(
|
||||||
insert,
|
insert,
|
||||||
remoteNodes,
|
nodesByUuid,
|
||||||
context,
|
context,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
case final CrdtMergeUpdate update:
|
case final CrdtMergeUpdate update:
|
||||||
await _applyMergeUpdate(
|
await _applyMergeUpdate(
|
||||||
update,
|
update,
|
||||||
remoteNodes,
|
nodesByUuid,
|
||||||
context,
|
context,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
case final CrdtMergeDelete delete:
|
case final CrdtMergeDelete delete:
|
||||||
await _applyMergeDelete(
|
await _applyMergeDelete(
|
||||||
delete,
|
delete,
|
||||||
remoteNodes,
|
nodesByUuid,
|
||||||
context,
|
context,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
|
|
@ -91,14 +96,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
|
|
||||||
Future<void> _updateHlcFromIncomingOperations(
|
Future<void> _updateHlcFromIncomingOperations(
|
||||||
List<CrdtMergeChange> operations,
|
List<CrdtMergeChange> operations,
|
||||||
Map<UuidValue, CrdtNode> remoteNodes,
|
_MergeNodes remoteNodes,
|
||||||
Transaction transaction,
|
Transaction transaction,
|
||||||
) async {
|
) async {
|
||||||
final maxIncomingHlc = operations.fold<Hlc?>(
|
final maxIncomingHlc = operations.fold<Hlc?>(
|
||||||
null,
|
null,
|
||||||
(current, change) => change.hlc.maxBetween(current),
|
(current, change) => change.hlc.maxBetween(current),
|
||||||
);
|
);
|
||||||
final nodesToUpdate = <CrdtNode>[];
|
final scopeNodesToUpdate = <CrdtScopeNode>[];
|
||||||
|
|
||||||
if (maxIncomingHlc != null) {
|
if (maxIncomingHlc != null) {
|
||||||
final hlcManager = _getHlcManager(transaction);
|
final hlcManager = _getHlcManager(transaction);
|
||||||
|
|
@ -110,7 +115,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
hlcManager.merge(maxIncomingHlc);
|
hlcManager.merge(maxIncomingHlc);
|
||||||
}
|
}
|
||||||
|
|
||||||
nodesToUpdate.add(hlcManager.getNode());
|
await _persistCurrentNodeHlc(hlcManager, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
|
final maxIncomingHlcByNode = <UuidValue, Hlc>{};
|
||||||
|
|
@ -122,66 +127,59 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
|
|
||||||
for (final MapEntry(key: nodeId, value: incomingHlc)
|
for (final MapEntry(key: nodeId, value: incomingHlc)
|
||||||
in maxIncomingHlcByNode.entries) {
|
in maxIncomingHlcByNode.entries) {
|
||||||
final remoteNode = remoteNodes[nodeId];
|
final remoteNode = remoteNodes.scopeNodesByUuid[nodeId];
|
||||||
if (remoteNode == null) continue;
|
if (remoteNode == null) continue;
|
||||||
final updatedNode = remoteNode.copyWith(
|
final updatedScopeNode = remoteNode.copyWith(
|
||||||
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
|
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
|
||||||
);
|
);
|
||||||
if (updatedNode.lastReceivedHlc == remoteNode.lastReceivedHlc) continue;
|
if (updatedScopeNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
nodesToUpdate.add(updatedNode);
|
scopeNodesToUpdate.add(updatedScopeNode);
|
||||||
remoteNodes[nodeId] = updatedNode;
|
remoteNodes.scopeNodesByUuid[nodeId] = updatedScopeNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodesToUpdate.isNotEmpty) {
|
if (scopeNodesToUpdate.isNotEmpty) {
|
||||||
await CrdtNode.db.update(
|
await CrdtScopeNode.db.update(
|
||||||
_session,
|
_session,
|
||||||
nodesToUpdate,
|
scopeNodesToUpdate,
|
||||||
columns: (t) => [t.lastReceivedHlc],
|
columns: (t) => [t.lastReceivedHlc],
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<UuidValue, CrdtNode>> _findOrCreateNodesForMerge(
|
Future<_MergeNodes> _findOrCreateNodesForMerge(
|
||||||
int userId,
|
int userId,
|
||||||
Set<UuidValue> nodeIds,
|
Set<UuidValue> nodeIds,
|
||||||
Transaction transaction,
|
Transaction transaction,
|
||||||
) async {
|
) async {
|
||||||
if (nodeIds.isEmpty) return {};
|
if (nodeIds.isEmpty) {
|
||||||
|
return (
|
||||||
var nodes = await CrdtNode.db.find(
|
nodesByUuid: const <UuidValue, CrdtNode>{},
|
||||||
_session,
|
scopeNodesByUuid: const <UuidValue, CrdtScopeNode>{},
|
||||||
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
|
|
||||||
transaction: transaction,
|
|
||||||
);
|
|
||||||
final existingNodeIds = nodes.map((node) => node.uuidNodeId).toSet();
|
|
||||||
final missingNodeIds = nodeIds.difference(existingNodeIds);
|
|
||||||
|
|
||||||
if (missingNodeIds.isNotEmpty) {
|
|
||||||
await CrdtNode.db.insert(
|
|
||||||
_session,
|
|
||||||
[
|
|
||||||
for (final uuidNodeId in missingNodeIds)
|
|
||||||
CrdtNode(
|
|
||||||
scopeId: userId,
|
|
||||||
uuidNodeId: uuidNodeId,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
transaction: transaction,
|
|
||||||
ignoreConflicts: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
nodes = await CrdtNode.db.find(
|
|
||||||
_session,
|
|
||||||
where: (t) => t.scopeId.equals(userId) & t.uuidNodeId.inSet(nodeIds),
|
|
||||||
transaction: transaction,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
final nodesByUuid = {
|
||||||
for (final node in nodes) node.uuidNodeId: node,
|
for (final uuidNodeId in nodeIds)
|
||||||
|
uuidNodeId: await _findOrCreateNode(uuidNodeId, transaction),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final scopeNodesByUuid = {
|
||||||
|
for (final entry in nodesByUuid.entries)
|
||||||
|
entry.key: await _findOrCreateScopeNode(
|
||||||
|
userId,
|
||||||
|
entry.value.id!,
|
||||||
|
transaction,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
nodesByUuid: nodesByUuid,
|
||||||
|
scopeNodesByUuid: scopeNodesByUuid,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_MergeContext> _loadMergeContext(
|
Future<_MergeContext> _loadMergeContext(
|
||||||
|
|
|
||||||
|
|
@ -357,28 +357,23 @@ class CrdtMutationRecorder {
|
||||||
UuidValue otherNodeId,
|
UuidValue otherNodeId,
|
||||||
Hlc syncedHlc,
|
Hlc syncedHlc,
|
||||||
) async {
|
) async {
|
||||||
final user = await _scopeManager.getOrCreate(userId);
|
final scope = await _scopeManager.getOrCreate(userId);
|
||||||
await _db.transaction((transaction) async {
|
await _db.transaction((transaction) async {
|
||||||
var node = await CrdtNode.db.findFirstRow(
|
final node = await _findOrCreateNode(otherNodeId, transaction);
|
||||||
_session,
|
final scopeNode = await _findOrCreateScopeNode(
|
||||||
where: (t) => t.scopeId.equals(user.id) & t.uuidNodeId.equals(otherNodeId),
|
scope.id!,
|
||||||
transaction: transaction,
|
node.id!,
|
||||||
|
transaction,
|
||||||
);
|
);
|
||||||
|
|
||||||
node ??= await CrdtNode.db.insertRow(
|
final currentSyncHlc = scopeNode.lastReceivedHlc;
|
||||||
_session,
|
|
||||||
CrdtNode(scopeId: user.id!, uuidNodeId: otherNodeId),
|
|
||||||
transaction: transaction,
|
|
||||||
);
|
|
||||||
|
|
||||||
final currentSyncHlc = node.lastReceivedHlc;
|
|
||||||
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
|
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CrdtNode.db.updateRow(
|
await CrdtScopeNode.db.updateRow(
|
||||||
_session,
|
_session,
|
||||||
node.copyWith(lastReceivedHlc: syncedHlc),
|
scopeNode.copyWith(lastReceivedHlc: syncedHlc),
|
||||||
columns: (t) => [t.lastReceivedHlc],
|
columns: (t) => [t.lastReceivedHlc],
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
|
|
@ -607,11 +602,13 @@ WHERE c."id" IN ($whereRowIds)
|
||||||
if (rows.isEmpty) return;
|
if (rows.isEmpty) return;
|
||||||
if (!isCrdtTracked<T>(rows.first.table)) return;
|
if (!isCrdtTracked<T>(rows.first.table)) return;
|
||||||
|
|
||||||
|
final hlcManager = _getHlcManager(transaction);
|
||||||
await record(
|
await record(
|
||||||
rows.first.table.tableName,
|
rows.first.table.tableName,
|
||||||
rows.uuidRowIds,
|
rows.uuidRowIds,
|
||||||
_getHlcManager(transaction),
|
hlcManager,
|
||||||
);
|
);
|
||||||
|
await _persistCurrentNodeHlc(hlcManager, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
CrdtDataRow _newCrdtDataRow(
|
CrdtDataRow _newCrdtDataRow(
|
||||||
|
|
@ -1331,6 +1328,72 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistCurrentNodeHlc(
|
||||||
|
HlcManager hlcManager,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
await CrdtNode.db.updateRow(
|
||||||
|
_session,
|
||||||
|
hlcManager.getNode(),
|
||||||
|
columns: (t) => [t.lastHlc],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtNode> _findOrCreateNode(
|
||||||
|
UuidValue uuidNodeId,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
var node = await CrdtNode.db.findFirstRow(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.uuidNodeId.equals(uuidNodeId),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
if (node != null) return node;
|
||||||
|
|
||||||
|
await CrdtNode.db.insert(
|
||||||
|
_session,
|
||||||
|
[CrdtNode(uuidNodeId: uuidNodeId)],
|
||||||
|
transaction: transaction,
|
||||||
|
ignoreConflicts: true,
|
||||||
|
);
|
||||||
|
node = await CrdtNode.db.findFirstRow(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.uuidNodeId.equals(uuidNodeId),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
return node ?? (throw StateError('Could not create CRDT node "$uuidNodeId".'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CrdtScopeNode> _findOrCreateScopeNode(
|
||||||
|
int scopeId,
|
||||||
|
int nodeId,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
var scopeNode = await CrdtScopeNode.db.findFirstRow(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
if (scopeNode != null) return scopeNode;
|
||||||
|
|
||||||
|
await CrdtScopeNode.db.insert(
|
||||||
|
_session,
|
||||||
|
[CrdtScopeNode(scopeId: scopeId, nodeId: nodeId)],
|
||||||
|
transaction: transaction,
|
||||||
|
ignoreConflicts: true,
|
||||||
|
);
|
||||||
|
scopeNode = await CrdtScopeNode.db.findFirstRow(
|
||||||
|
_session,
|
||||||
|
where: (t) => t.scopeId.equals(scopeId) & t.nodeId.equals(nodeId),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
return scopeNode ??
|
||||||
|
(throw StateError(
|
||||||
|
'Could not create CRDT scope-node row for scope $scopeId and node $nodeId.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
CrdtScope _getEffectiveScope(Transaction transaction) {
|
CrdtScope _getEffectiveScope(Transaction transaction) {
|
||||||
return scopeForQueries(transaction) ??
|
return scopeForQueries(transaction) ??
|
||||||
(throw StateError(
|
(throw StateError(
|
||||||
|
|
|
||||||
|
|
@ -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