serverpod-offline-sync/docs/shared-spaces.md
2026-10-08 20:32:24 -03:00

643 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Shared spaces
Status: implemented. This records the shared-space 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 space is the unit of replication and
ownership. Each user has a personal space and may also belong to any number of
shared spaces. The load-bearing
requirement is:
> **A single `sync` call syncs every space the authenticated user has access
> to.** One `syncOnce` cycles through all accessible spaces exactly once; a
> continuous session keeps cycling forever. There is no separate stream or
> separate call per space.
The row-ownership work keys storage, locking, HLC chains, visibility, and
ownership by **space**, not user, so sharing is an indirection in front of
`offline_sync_spaces`, not a re-architecture. The implemented pieces are a server-side
membership relation and a sync protocol that iterates spaces.
> **Merges serialize one space at a time.** A merge runs in exactly one space's
> transaction and lock, so spaces always serialize on the merge step regardless
> of framing. A cycle's outbound changes are instead collected in a single pass
> over the active spaces and carried in one combined batch, each change tagged
> with its `uuidSpaceId`; the receiver regroups by space and merges each group
> in its own transaction. The wire stays a near-verbatim repetition of today's
> frames, now space-tagged.
> **The server is the authority on membership; the client follows.** The set of
> spaces a session syncs is computed server-side from `offline_sync_space_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 spaces 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 spaces is a separate revocation
> policy, not part of the sync loop.
> **Row ownership is unchanged.** Every row still has exactly one owning
> `spaceId`, `(table, uuidRowId)` stays globally identified, and every
> enforcement rule in `row-ownership.md` survives untouched. Sharing adds a
> membership layer in front of spaces; it does not touch row ownership.
## Goals and constraints
1. **One call, every space.** The public client API (`syncOnce`,
`syncContinuously`) does not change shape: callers pass a session, never a
space. A device with only a personal space behaves exactly as today.
2. **Smallest protocol delta.** The exchanged frames are today's, plus a
per-cycle space-set exchange and a space tag on each frame. `collectNextBatch`
demultiplexes a cycle's combined batch, `collectPendingChanges` collects every
active space in one pass, and `mergeInboundBatch` applies each inbound space
group while atomically persisting received progress for every author.
3. **Chunked streaming.** Outbound changes are collected in a single pass over
the active spaces and streamed in `syncBatchSize` chunks, so the wire payload
stays bounded regardless of space count. The collection step itself
materializes a cycle's pending rows across the active spaces (one
`spaceId IN (…)` query per change kind), so peak memory tracks a cycle's
pending rows rather than being held strictly one space at a time. See
[outbound and rebuild behavior](projection-model.md#outbound-and-rebuild-behavior)
for snapshot guarantees and capture costs.
4. **Membership cannot be forged.** The server computes its own space set from
`offline_sync_space_members` (plus the implicit personal space) and never widens it
from anything the client sends. A space the user is not a member of is never
synced, even if the client names it.
5. **One space per transaction.** Reads are membership-wide; writes stay pinned
to exactly one space. This is deliberate honesty about CRDT semantics: two
spaces' chains replicate independently and a remote replica can never
observe a cross-space write atomically.
6. **Roles are closed CRDT access roles.** The package stores and projects
non-null `OfflineSyncSpaceRole` values in space grants and shared memberships:
`readWrite` allows CRDT writes and `readOnly` blocks them. The implicit
personal space is announced as a `readWrite` grant, so single-tenant servers
and multi-account devices remain the same code path with different space
counts.
## Membership
### The `offline_sync_space_members` table
```yaml
class: OfflineSyncSpaceMember
table: offline_sync_space_members
database: all
fields:
space: OfflineSyncSpace?, relation(onDelete=Cascade)
userUuid: UuidValue
role: OfflineSyncSpaceRole
indexes:
offline_sync_space_member_unique_idx:
fields: userUuid, spaceId
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 through `session.offlineSync.spaces` (`OfflineSyncSpaces`). Invitations,
acceptance, and authorization policy remain app domain.
- **Personal-space membership is implicit.** By convention a user's personal
space has the user's own UUID (already true: the space UUID keys the chain),
so no `offline_sync_space_members` row is stored for it. Shared spaces are
`offline_sync_spaces` 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-space writes.
### Members vs. nodes
`offline_sync_space_members` and the `nodes` list on a space are orthogonal layers and
both stay:
- A **member** is an authorization fact: a user identity allowed to access a
space. It answers *who may sync, read, and write*.
- A **node** (`crdt_nodes`) is a stable CRDT replica identity. A space's
`nodes` relation points at `offline_sync_space_nodes`, which records that replica's
participation and checkpoint state in that space. Together they answer
*whose changes, up to which HLC* and are what per-`(space, 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
space it syncs but is not a member row. A freshly invited member has **zero
space-node rows** until their first device syncs. Nodes and space-node rows are
created implicitly by space initialization and inbound merge; membership
rows are managed through `session.offlineSync.spaces` from application endpoints.
Removing the `nodes` list would break checkpointing and causal filtering — it is
the chain topology, not the access list.
### The client membership table
`offline_sync_space_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 `offline_sync_space_members` rows plus the implicit personal
space are its membership view for *membership gating*: a membership-wide read
filters over projected membership, and a local `offline_sync_spaces` row by itself
does not grant access. The members table also carries **roles**, which the
client consults before local writes.
- The authoritative `OfflineSyncSpaceSet` carries `OfflineSyncSpaceGrant`s — `(uuidSpaceId,
role)` — not bare UUIDs. Every grant has an explicit role; the personal space
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 space is skipped),
delete rows whose space 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 space from a
cycle's `SpaceSet`: the client stops cycling it and discards in-memory
checkpoint state for it. The space 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 OfflineSyncSpaceRole {
readOnly,
readWrite,
}
```
Only `readWrite` allows writes. `readOnly` denies writes. The implicit personal
space 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 space group, it resolves the authenticated user's
role from `offline_sync_space_members`; if the role is not `readWrite`, the server
records `OfflineSyncIntegrityViolation(type: unauthorizedWrite)` and fails the
sync session before opening the merge transaction for that space. 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,
spaceId: sharedSpace)` resolves the projected role and throws
`OfflineSyncSpaceRoleException` before any local write when the role cannot write. A
follower also omits non-writable shared spaces from outbound pending-change
collection while still receiving inbound changes for those spaces. This avoids
repeatedly streaming local read-only changes; the server enforcement remains the
security boundary for stale, misconfigured, or hostile clients.
### Space enumeration (server is the authority)
At the start of each sync cycle the server resolves the authoritative set:
```
memberGrants(userUuid) =
{ personal space = userUuid, role = readWrite }
∪ { (s.uuidSpaceId, m.role)
: offline_sync_space_members m
join offline_sync_spaces s on s.id = m.spaceId
where m.userUuid = <auth user> }
```
This grant set bounds everything the cycle may sync and carries the role used by
followers for outbound filtering. The client never expands it; it only learns of
additions through what the server announces.
## Sync: one call syncs every accessible space
### One `sync`, two cadences: bounded `once`, idle-silent `continuous`
Merge is inherently one space at a time: a merge runs in exactly one space's
transaction and lock, so the spaces 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
`SpaceSet`. 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
space announcement (only when this peer's grants changed), a `SinceHlc` for
each newly active space, and merge chunks whose changes carry their space,
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
space count.
`once` stays in the data loop until every active space 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
spaces in the first cycle and absorbs membership changes as they are announced
(a newly granted space 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 space;
`continuous`: only on change, inside one combined batch). Frame changes
(regenerated models + a migration on both client and server schemas):
- `OfflineSyncConnect { syncTablesHash, localNodeId }` — sent once by each peer. The
schema hash is validated once, before any space work. `localNodeId` identifies
this peer's CRDT node for the whole session. The space list is dynamic and
exchanged per cycle.
- `OfflineSyncSpaceSet { spaces: List<OfflineSyncSpaceGrant> }` (each grant a
`uuidSpaceId` + `role`) — the server's content is authoritative (resolved from
`offline_sync_space_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.
- `OfflineSyncSinceHlc { uuidSpaceId, nodeCheckpoints }` — per space. Carries that
space's per-node checkpoints. Exchanged when a space first becomes active
(and again if a dropped space is later re-adopted);
checkpoints then live in memory for the rest of the session.
- `OfflineSyncMergeChunk { changes }` — each change carries `uuidSpaceId`, letting
a combined batch carry every space's changes and regroup them on receive.
- `OfflineSyncEndOfBatch`, `OfflineSyncClose`, `OfflineSyncIdleTimeout` — 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 `{ spaceSet?, 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 SpaceSet(myGrants); read peer SpaceSet → adopt → activeSpaces
follower: materialize activeSpaces, project membership
data loop (both cadences, one combined batch per cycle):
loop:
resolve activeSpaces (authoritative: my set; follower: adopted peerGrants)
discard state for spaces no longer active
# send phase — emit only what changed; track whether any frame was sent
if myGrants != announcedGrants: send SpaceSet(myGrants)
for space in activeSpaces not yet sinceHlcSent: send SinceHlc(space)
if peer checkpoints exist: send MergeChunk(changes)* # follower skips non-writable spaces
if anything was sent (or once): send EndOfBatch
# receive phase
batch = collectNextBatch(allowCloseBeforeBatch: !once) # {spaceSet?, sinceHlcs, changes} | idle | closed
if closed: return
adopt batch.spaceSet (follower: materialize + project); store sinceHlcs;
group changes by uuidSpaceId; mergeInboundBatch each group
if once: send Close; read Close; drain; return
delay continuousSyncInterval
```
The shared establishment exchanges the space set so a follower never acts before
it knows the agreed set. Both cadences handshake initial and newly added spaces
through the loop's own `SinceHlc` exchange.
`collectPendingChanges`, `mergeInboundBatch`, and the
ownership-violation internals (`_streamInserts`/`Updates`/`Deletes`,
`_fetchDomainRow`, the durable-violation path) are space-parameterized and merge
each inbound space group in its own `transactionForUser`/lock, including its
per-author checkpoints; any space'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 space's data until both peers have exchanged its
`SinceHlc`, a newly active space 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 spaces 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 space-aware: `Function(UuidValue spaceUuid, Hlc hlc)`.
This touches three test call sites and the example app's `demo_controller`.
### Space-set reconciliation and lockstep
Both peers must cycle the **same ordered space 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 space 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**: `orderedSpaces = sort(memberGrants(userUuid).uuidSpaceId)`,
computed from `offline_sync_space_members` plus the implicit personal grant. It never
reads the client's `SpaceSet` for authority (constraint 4).
- **Client**: takes the server's `SpaceSet` as the set to cycle —
`getOrCreate` (`OfflineSyncSpaceManager`) any space it lacks, ignore any local space
the server omitted for this cycle — then `orderedSpaces = sort(peer spaceIds)`.
This asymmetry is expressed by a **`OfflineSyncPeerMode` 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 `offline_sync_space_members` table (via
`OfflineSyncSpaceMembership`); 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
`OfflineSyncSpaceMembership.roleOf(...).canWrite` against the authoritative table.
In `once`, the client adopts straight from the cycle's `SpaceSet` *before* it
iterates, so a newly granted space syncs in that same round. In `continuous`,
adoption happens when the announcement arrives and the space's data follows over
the next cycles once its `SinceHlc` round-trips. Sorting by space UUID makes the
order deterministic on both sides with no extra negotiation. Frames carry
`uuidSpaceId` so a receiver groups each chunk into the right space and fails loud
on an unauthorized tag rather than merging a batch into the wrong space.
Re-resolving `memberGrants` every cycle is an indexed `offline_sync_space_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 `(space, node)` in `offline_sync_space_nodes`, while
`crdt_nodes` stores the stable replica identity. The same local replica node can
participate in multiple spaces, with separate `lastReceivedHlc` values per
space. `SinceHlc` is exchanged once per space on first visit; later cycles reuse
the in-memory
`nodeCheckpoints[space]`, advanced as chunks are sent and as inbound batches
merge — the multi-space analogue of today's single-space continuous loop, which
already keeps checkpoints in memory across rounds.
The continuous interval delay moves to **after each full cycle**, not after each
space, to keep per-space latency low while still preventing a busy loop when
idle.
### Access changes during a session
Because the authoritative space 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 `SpaceSet` that includes the new space;
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 `SpaceSet` that omits the space; 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 space'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 space 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 space:
```dart
db.transactionForUser(userId, fn); // acts in the personal space
db.transactionForUser(userId, fn, spaceId: listId); // acts in a shared space
```
- Without `spaceId`, the space resolves to the user's personal space —
`userId` itself by convention — which is exactly today's behavior.
- With `spaceId`, the package resolves the member role with
`OfflineSyncSpaceMembership.roleOf` — no injected validator. On the server that table
is authoritative; on a persistent client it is the server-projected membership
cache. A missing role throws `OfflineSyncSpaceMembershipException`; anything other
than `readWrite` throws `OfflineSyncSpaceRoleException` before the transaction starts.
- A transaction acts in **exactly one** space (constraint 5). The write path —
stamp-or-assert, the space-scoped `WHERE`, `spaceId` immutability — is unchanged
beyond which space is resolved.
## Read path: membership-wide
A user-space-scoped read in a sharing world means "rows in any space I am a member
of". The single-space resolver becomes a set:
- `mergeWhereWithTombstone` isolates space-scoped reads with `spaceId IN (<member
space ids>)` through `OfflineSyncSpaceIdsResolver`. For writes, the resolver supplies
the single acting space. For reads, both server sessions and persistent
clients resolve the set from `OfflineSyncSpaceMembership.memberSpaces`
(authoritative on the server, projected on the client); unscoped admin reads
still pass no space filter.
- The visibility probe is the row-keyed form for the multi-space case, so
sharing simplifies the predicate matrix rather than growing it: reads differ
only in their membership filter (one space, a user's spaces, or none for
admin).
- This is the shape of a Postgres row-level-security policy
(`spaceId IN (SELECT spaceId FROM offline_sync_space_members WHERE userUuid = …)`); the
package's application-level filter and RLS become two enforcements of one
predicate.
Writes stay space-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
space; merge-insert ownership checks compare spaces; unique conflict groups form
within a space; ordinary global unique indexes stay rejected; stamp-or-assert
applies with the transaction's resolved space; the durable
`offline_sync_integrity_violations` contract now also records unauthorized role
writes. The merge, FK, and unique engines already key on the space 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 `offline_sync_space_members`
(`database: all`, unsynced) and its migration. Added the shared
`OfflineSyncSpaceMembership` helpers (`memberGrants`, `memberSpaces`, `roleOf`,
`isMember`, and the follower projection path). No behavior change yet: a
personal-space-only set reproduces today's single-space sync exactly.
- **Phase 2 — sequential multi-space sync (the requirement).** Implemented
protocol model
changes (`Connect` with session `localNodeId`, new per-cycle
`OfflineSyncSpaceSet`, per-space `SinceHlc`, `MergeChunk.uuidSpaceId`) plus
generate/migrate. Outer cycle loop in `OfflineSyncEngine.sync` with per-cycle
`SpaceSet` exchange with role-carrying `OfflineSyncSpaceGrant`s, per-space
handshake-on-first-visit, in-memory per-space checkpoints, and the
once/continuous cadence. Reconciliation via a
`OfflineSyncPeerMode` enum: the server endpoint passes `authoritative` (cycles
`OfflineSyncSpaceMembership.memberGrants`); the client driver passes `follower`
(adopts the server's set). Space-aware `onMergeSuccess`. A personal-space-only
device behaves identically to today.
- **Phase 3 — transaction API.** Implemented
`transactionForUser(userId, fn, {spaceId})` with the membership assertion;
one space per transaction.
- **Phase 4 — membership-wide reads.** Implemented the space-scoped read filter as
`spaceId IN (…)` over the user's member spaces.
- **Phase 5 — lifecycle and docs.** Documented client adoption of newly
announced shares; reconcile with the space-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 space-scoped-change batch
terminated by a single `EndOfBatch`, with `SpaceSet`/`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 space
count (Phase 2's loop re-announced the set and an `EndOfBatch` per space every
tick). `once` runs one data cycle then closes; mid-session continuous spaces are
deferred until their `SinceHlc` round-trips, and inbound frames are honored
only for authorized spaces.
- **Phase 7 — roles within a space.** Implemented the closed
`OfflineSyncSpaceRole` enum (`readOnly`, `readWrite`) on server and client.
`readWrite` is the only shared-space write grant. Added authoritative inbound
enforcement that records
`unauthorizedWrite` and fails before merge, client-side
`OfflineSyncSpaceRoleException` early rejection, and follower outbound skipping for
non-writable shared spaces while reads remain membership-wide.
- **Phase 8 — space management service.** Implemented the server-side
`session.offlineSync.spaces` service (`create`, `createFor`, `grant`, `grantAll`,
`revoke`, `members`) with transaction/savepoint threading. Single grants reuse
the bulk-grant path; unknown-space grants throw `OfflineSyncSpaceNotFoundException`.
Invitations, acceptance, and authorization policy stay in app endpoints.
## Test plan
Because no code branches on space count or deployment role, mechanisms are
tested once, not per combination.
- **Regression.** Every existing single-space suite passes unchanged on the
personal-space-only path.
- **Protocol.** `Connect`/`SinceHlc` round-trip a multi-space list; merge chunks
route to the correct space by `uuidSpaceId`; per-space checkpoints advance
independently across cycles.
- **One call, every space.** A single `syncOnce` converges N spaces in one pass;
a continuous session keeps cycling. Outbound changes for the active spaces are
collected in one pass and streamed in `syncBatchSize` chunks; the receive
groups a cycle's inbound chunks by space 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 space set nor sends an
`EndOfBatch` while idle.
- **Membership.** The server syncs personal + shared spaces; a client-announced
non-member space is refused and never streamed; a newly granted space 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 spaces while still receiving inbound changes, and a stale or
hostile client write is rejected and recorded by the server as
`unauthorizedWrite`. Personal spaces remain writable through their implicit
`readWrite` grant.
- **Access changes mid-session.** During a continuous session, a grant added to
`offline_sync_space_members` appears and syncs on the next cycle; a revoke stops
cycling that space without dropping the session.
- **Isolation under sharing.** Two users sharing space X both converge on X
while their personal spaces stay isolated; an ownership collision in one space
records a durable violation and fails the session without corrupting another
space's merge.
- **Smoke.** One `withServerpod` round-trip with one personal plus one shared
space, proving the end-to-end wiring (mirrors `sync_flow_test.dart`).
## Resolved during review
- **Combined sync batch:** merges serialize regardless, so combining spaces is
not a throughput optimization. Both cadences use the same combined batch shape:
each change carries its `uuidSpaceId`, and receive groups changes by space
before merging each space group. Continuous mode omits idle frames so idle
latency is constant in space count.
- **Membership authority:** the server, from `offline_sync_space_members`. The
reconciliation is directional — server dictates, client conforms — and is
neither union (insecure) nor intersection (cannot adopt). It is a
**`OfflineSyncPeerMode` enum** (`authoritative` / `follower`): membership still
resolves through `OfflineSyncSpaceMembership`, so the only residual variation is
cycle direction — a closed enum, not a callback. The enum is not the security
boundary; server-side
`OfflineSyncSpaceMembership.roleOf(...).canWrite` gates every applied write
regardless of the client's declared mode.
- **Space set is dynamic, not pinned at connect:** the authoritative peer
re-resolves its set every cycle and re-announces a `SpaceSet` whenever it
changed, so grants and revokes take effect mid-session without reconnecting.
In `syncOnce` the client adopts from the initial `SpaceSet`, exchanges
`SinceHlc`, then runs the sendable data cycle; in `syncContinuously` a newly
announced space 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 `OfflineSyncSpaceSet` carries
`OfflineSyncSpaceGrant`s (`uuidSpaceId` + `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-space
membership gating and roles for UI and offline role-gated writes; local
`offline_sync_spaces` 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 space UUIDs, so both peers stay in lockstep
without negotiation. Each merge change carries its space UUID, so receive can
regroup a combined batch deterministically.
- **One space per transaction:** kept. Cross-space writes need separate
transactions; remote replicas cannot observe a cross-space 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 removed:** continuous sync has one combined data loop per
cycle. When neither peer has grants, handshakes, or changes to send, it emits
no `SpaceSet`, `SinceHlc`, `MergeChunk`, or `EndOfBatch`; `collectNextBatch`
resolves the idle cycle from the timeout once per session cycle, not once per
space.
- **Initial-sync ordering is not a current concern:** a device joining many
spaces cycles them one at a time on first sync. Prioritizing recently active
spaces in the iteration order is a compatible refinement if cold-start latency
over large space 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
space from the next cycle's `SpaceSet` and both sides stop syncing it
promptly. The device may still store the space'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.)