@thomasfosterau/legation 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,2054 @@
1
+ # @thomasfosterau/legation
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2a1f63c: Add `Aggregate`: serialisable aggregate descriptors (`Count`, `Sum`, `Min`,
8
+ `Max`, `Avg`) over a subset's rows, as plain data plus an Effect `Schema` (so a
9
+ future server tier can carry them). `compute` / `computeGrouped` evaluate them
10
+ purely over already-filtered/sorted rows — `Sum` of an empty set is `0`, `Avg`
11
+ of an empty set is `undefined`, `Min`/`Max` are `undefined` when there are no
12
+ numeric values, and non-numeric field values are skipped.
13
+
14
+ The `ClientEngine` grows `queryAggregate` / `queryAggregateGrouped` (one-shot
15
+ over the current local view) and `observeAggregate` / `observeAggregateGrouped`
16
+ (live, re-emitting only when the value changes), with a matching `aggregate` /
17
+ `aggregateGrouped` atom surface in `EngineAtoms` and an `aggregate` /
18
+ `aggregateGrouped` store in `@thomasfosterau/legation-svelte`.
19
+
20
+ These are local-tier aggregates: they fold the rows a client has already
21
+ synced. A server-evaluated tier that folds the whole collection is explicit
22
+ future work.
23
+
24
+ - ef59fa7: A durable pull now writes once, and an interrupted pull is never recorded. The
25
+ key-value seam gains an optional `AtomicKeyValue` service on
26
+ `@thomasfosterau/legation/internal`: an all-or-nothing `apply` over
27
+ `set` and `remove` operations. When a host supplies it, `ClientStore` commits a
28
+ pull's rows, cookie, journal trim and unpulled-tail release as one batch, with
29
+ no release record, and a push acknowledgement's park and trim as one batch. A
30
+ store without the service keeps the release-record path from #387, unchanged,
31
+ and an open still finishes a leftover record either way.
32
+
33
+ `@thomasfosterau/legation-browser` supplies the service over IndexedDB: one
34
+ `readwrite` transaction on the database and object store the platform
35
+ `KeyValueStore` already uses, so existing databases need no migration. The
36
+ database is opened without a version, so one a later version created still
37
+ opens; the browser store's `KeyValueStore` opens it the same way, in place of
38
+ the platform's, which pins version 1 and fails on a newer database.
39
+
40
+ If another connection upgrades that database, or deletes it, the store's
41
+ connections close, since they would otherwise block the change. From then on
42
+ every call fails with "the IndexedDB database was upgraded by another
43
+ connection; reload to continue", and the store does not reopen itself. A
44
+ database without the `entries` store fails its calls with a message that names
45
+ that store, and a sealed store's message now carries the reason from its cause.
46
+
47
+ - d09dc65: `BackendContract.verifyBackendContract` checks that `getRows` reflects a backend's own writes to rows a listing has cached (rule 12, #444). The rule lists a collection so it caches a row, applies an update through the backend, then checks that `getRows` returns the post-write value without a re-list. It then applies a delete and checks that `getRows` omits the row. A backend that caches listed rows and does not bring its cache into line with its own writes now fails `verifyBackendContract`, where it passed before. `MemoryBackend`, `SqlBackend` (SQLite and PostgreSQL) and the atomic `JsonApiBackend` pass the rule unchanged. The rule uses the existing `putRow` and `delRow` contract mutators and the contract's evaluation collection, so a backend registers nothing new. A third-party backend that caches rows must update or evict its cache when it applies its own writes.
48
+ - db2366a: `BackendContract.verifyBackendContract` gains two optional options, `restoredLedger` and `restoredPeerLedger`, for a run over a backend restarted on a ledger that already acknowledges the contract's client. They carry the ledger ids the restart expects for the contract client and for rule 11's peer group. The contract first checks that the backend reports exactly the restored id, and fails with a rule that names a forgotten or differing ledger when it does not. It then numbers every rule's mutations from that id. A backend that forgets its ledger on restart now fails the contract when the run passes `restoredLedger`.
49
+
50
+ Without the options the run expects a fresh client group, as before: every rule and its expectations are unchanged. Two checks are new on that path: the contract client must report 0 before the first rule runs, and rule 11's peer group must report 0 before it parks its handler. A backend that reports a non-zero ledger for either group on a fresh group now fails with the matching rule.
51
+
52
+ - 97db489: Backend guarantee 1 now states that mutations from different client groups are serialisable (#353): a handler's reads see a state equivalent to some serial order of the committed mutations, so no committed write is lost to another group's concurrent commit of a row it read.
53
+
54
+ `MemoryBackend` meets this. Every row a handler reads from committed state is recorded with its version, and the commit re-checks those versions inside the same state update that applies the writes. If another group changed a row since the read, nothing is written and the handler runs again on the new state. Overlapping calls for one client group behave as before (#346, #355). A handler can therefore run more than once, so its effects outside its writer must be safe to repeat.
55
+
56
+ **Action required for custom `Backend` implementations:** `applyMutations` must meet guarantee 1, by a store-wide lock, a commit that re-checks each row the handler read and runs the handler again when one changed, or serialisable isolation on a database. A backend that does not meet it does not conform. The `BackendHandler` contract also now allows a handler to run more than once, so handler effects outside the writer must be safe to repeat. A handler still stale after 16 runs fails with a transient `BackendError` and writes nothing, so the client's retry takes over.
57
+
58
+ - cc67999: Add `Backend.transaction(steps)`. It builds a backend's `applyMutations` from its storage steps (`begin`, `open`, `run`, `commit`, `release`, `finish`, `atomically` — `Backend.withoutTransaction` for a store without one — and `maxAttempts`), and owns the mutation-id ledger rules: in-order application, idempotent skip, gap rejection, the bump travelling with each outcome (a permanent rejection commits no effects), and reporting a replay's committed rejection. `MemoryBackend` and `SqlBackend` now apply mutations through it, with no change in behaviour. A hand-assembled backend still type-checks, and `verifyBackendContract` still checks every backend (ADR-0009's #177 amendment).
59
+ - a94df19: Bound every in-process message hub so a stalled or slow consumer can no longer
60
+ grow a queue without limit (#44). Because pull is the correctness path, each hub
61
+ sheds load under pressure without threatening convergence:
62
+
63
+ - **Poke hub** (`Transport.makePokeHub`): now `sliding(1)` per client group with
64
+ replay 1. A poke storm collapses to one queued poke per group — an outstanding
65
+ poke absorbs the rest — and a late/reconnecting subscriber still sees the most
66
+ recent poke.
67
+ - **Invalidation bus** (`InvalidationBus.makeInProcess` and the memory broker):
68
+ now bounded with a `dropping` strategy at a configurable capacity. A dropped
69
+ hint only delays the next pull (ADR-0009); drops are counted in
70
+ `legation_hints_dropped_total`. New `makeInProcessWith` / `layerInProcessWith`
71
+ / `makeMemoryBrokerWith` take a `capacity` option.
72
+ - **Engine change hub** (`ClientEngine`): now bounded `dropping` at a
73
+ configurable capacity (`ClientEngineOptions.changeHubCapacity`, default 1024).
74
+ Each view-change carries a monotonic sequence number; a consumer that sees a
75
+ seq gap collapses it into a full `Rebuild` that subsumes the dropped `Patch`es,
76
+ so the view stays correct under pressure and behaves identically under none.
77
+ Collapses are counted in `legation_change_collapses_total`.
78
+ - **Mirror change hub** (`TabMirror`): now bounded `dropping` at a configurable
79
+ capacity (`TabMirrorOptions.changeHubCapacity`, default 1024). A seq gap makes
80
+ the observer re-read its subset from the mirror's `Ref` (a full refresh,
81
+ lossless because the `Ref` holds the latest rows), counted in
82
+ `legation_mirror_refreshes_total`.
83
+
84
+ A `Patch` is never silently dropped: it is only ever shed behind a seq gap that
85
+ forces a `Rebuild` before the view is next read.
86
+
87
+ - 6f53fb1: Change data capture for out-of-band writes (ADR-0019). Writes that bypass the
88
+ mutators — a cron job, an admin tool, another service, `psql` — can now poke
89
+ subscribed clients through a new `HintSource` seam.
90
+
91
+ `@thomasfosterau/legation` gains `HintSource`: a small, dependency-free seam that
92
+ publishes `ChangeHint`s to the `InvalidationBus` on a scoped fibre alongside the
93
+ SyncServer. `HintSource.fromStream` lifts a stream of hint batches and
94
+ `HintSource.run` forks a source for the caller's scope. A source that publishes
95
+ value-less hints is matched at the collection level by the existing fan-out
96
+ matcher — sound, because over-matching a poke only costs an empty pull.
97
+
98
+ `@thomasfosterau/legation/sql` gains `PollingHintSource`: a tier-2 polling source over the
99
+ existing `SqlExecutor` seam (no new dependency). It polls each table on an
100
+ interval for a cheap change-detector — `COUNT(*)` plus `MAX(versionColumn)` when
101
+ a version column is mapped — and pokes a collection whose detector moved.
102
+ Bounded staleness equals the interval; without a monotonic version column it
103
+ detects only row-count changes, so map a `versionColumn` (or `updated_at`) for
104
+ full coverage.
105
+
106
+ Tiers shipped: tier 1 (app publishes `InvalidationBus.publish`) documented as
107
+ the baseline, tier 2 (polling) shipped here. Tier 3 (Postgres `LISTEN`/`NOTIFY`)
108
+ ships as a documented recipe — bring your own pg client — and tier 4 (logical
109
+ replication) is rejected (ADR-0019, citing ADR-0009). See
110
+ `docs/change-data-capture.md`.
111
+
112
+ - d675d2e: A push never takes the acknowledged-but-unpulled tail past its bound, and no overlay entry is released early. A push that carries more than half the bound goes out in chunks of half the bound, with a pull before each chunk after the first, and each chunk takes only the room the tail leaves. Overlapping pushes share that room, so the bound holds across them. A push whose tail has no room pulls first, and fails if that pull fails or releases no room. The cost is extra round trips for a large flush, and a push that waits on the pulls between chunks. A pull between chunks that fails or is refused fails the push with a transient `TransportError`, and a chunk that fails fails the push with its own error; the unsent mutations stay journaled for the next push.
113
+
114
+ This removes the overflow release, and with it the optional `ClientStore.commitTailRelease` member and its release-record path. Pulls still release the tail through `releaseUnpulled` and the pull's release record. Delete the member: a literal typed as `ClientStore` that still sets it fails to compile (excess property), and one that spreads it has it ignored. That is a breaking change for the interface, so this is a minor bump before 1.0.
115
+
116
+ - 1c56914: The Client facade (ADR-0022, program step 4): the one-per-thread object
117
+ that runs many Query reads and mutations, with where a read is answered
118
+ being configuration the UI never sees. `Client.make` owns the engine
119
+ (store + transport from context; the sync loop starts automatically —
120
+ the forgotten-runSync bug class ends here); `Client.connect` joins an
121
+ engine hosted elsewhere (service worker, Electron main, a daemon) over a
122
+ `MirrorChannel`. Observing subscribes: live reads register their windows
123
+ for the stream's lifetime through a per-window ledger (one voice per
124
+ window, however many observers), `subscribe` pins a read beyond
125
+ observers, and includes resolve through the view's auto-subscribing
126
+ relational read with the plan's edge windows applied. `Client.fromView`
127
+ builds the facade over any `ClientView`. Additive; the
128
+ server-fallthrough tier is a later step.
129
+ - 615e792: `Client.make` gains sensible defaults with full extensibility. Zero wiring works: `Client.make({ nodes, mutations })` is a purely local Client — in-memory store, offline transport (reads and optimistic writes work, the journal retains every intent), sync loop off. `transport: { url }` builds the standard remote setup (sync RPCs over WebSocket with HTTP fallback, via the new `RpcTransport.fromOrigin`); `store`/`transport` accept full implementations; a `ClientStore`/`ClientTransport` in context is still honoured (options win over context, context over defaults), so existing layer-composed apps are unchanged. `clientId`/`clientGroupId` default to random ids — pass stable ones alongside a persistent store. Also adds `Transport.offline`, and `Client.make` no longer _requires_ the store/transport services in its environment type.
130
+ - c8d9afc: Breaking: `Client.make` now returns `{ client, engine, store }` (`Client.Made`) instead of the Client alone (#142). The engine and store are the ones the Client runs over, so an app can attach devtools (`Devtools.make(engine, store, …)`) without building a `ClientEngine` by hand. Migrate with `const { client } = yield* Client.make(…)`. `Client.connect` is unchanged: a Client joined to a hosted engine owns neither. The package is 0.x, so this is a minor.
131
+ - 4709f41: One client surface over engine and mirror (ADR-0021): core gains
132
+ `ClientView` — the narrow surface component bindings consume (query/observe,
133
+ subscribe/unsubscribe, mutate/mutateWithAck with awaited-outcome semantics) —
134
+ with `fromEngine` and a `layer` deriving it from the in-context engine.
135
+ `TabMirror` satisfies it structurally, aggregates derive over any view's
136
+ `observe` (`Aggregate.observe`/`observeGrouped`), and `Related`'s read side
137
+ takes a `ClientView.Reader`, so joins and aggregates work over mirrors
138
+ unchanged.
139
+
140
+ For `@thomasfosterau/legation-svelte`, the seam is what lets the same components run over a
141
+ main-thread engine and a service-worker-hosted mirror with zero component
142
+ changes — the Client the hooks read from context is built over either.
143
+
144
+ - f0348b4: `BackendContract.verifyBackendContract` probes cross-group lost updates (guarantee 1, #353). A new rule 11 parks a handler in one client group after it reads a counter, lets another group increment the same row and commit, then resumes the parked handler. Both increments must land, so the counter must read 2. A backend whose handler read is not validated at commit writes the stale count over the other group's commit and fails the rule. A backend that serialises the two (a lock, or serialisable isolation) passes, whether it orders them or refuses one with a transient `BackendError`, which the harness retries. A backend whose peer call waits behind the parked one passes too: the rule releases the parked handler after a bounded number of scheduler turns, so it does not deadlock.
145
+
146
+ The probe runs through a new contract mutator, `BackendContract.countUp` (`contract/count-up`), with args `{ collection, id, gate }`. Its handler reads the counter, calls `BackendContract.park(gate)` between the read and the write, then writes the counter plus one. `park` is a no-op unless the harness has registered `gate`, so a registered handler that never calls it fails rule 11: the probe then has nothing to test. `park` is exported from `@thomasfosterau/legation/internal`, and the gate is named by a string in the args, so the mutation stays serialisable.
147
+
148
+ **Action required:** backend authors who run `verifyBackendContract` must register `BackendContract.countUp`, with a handler that reads, calls `park(gate)`, then writes. An unregistered mutator fails rule 11. `BackendContract.memoryHandlers` already includes it. A non-transactional executor (D1-style) does not meet guarantee 1 (#342), so it is not run through this contract.
149
+
150
+ - a053736: `BackendContract.verifyBackendContract` probes write-then-`BackendError` (#343).
151
+ A new contract mutator, `BackendContract.writeThenFail` (`contract/write-then-fail`),
152
+ writes a row and then fails transiently. The new rule 10 checks that the row keeps
153
+ its value and version, no new row appears, and `lastMutationId` is not bumped, so
154
+ the client's retry applies the mutation again. Guarantee 1 (`Backend.ts`) requires
155
+ this, and `failAlways` does not probe it because it writes nothing first.
156
+
157
+ Visibility before the bump is documented in `BackendContract` as a per-backend
158
+ obligation, not a generic rule: the harness cannot observe a backend mid-mutation.
159
+
160
+ The unknown-mutator `BackendError` that `MemoryBackend`, `SqlBackend` and
161
+ `JsonApiBackend` raise now carries a typed cause, built by `UnknownMutator.make`
162
+ (`@thomasfosterau/legation/internal`). Rule 10 and rule 4 (`contract/fail`, #382)
163
+ recognise an unregistered mutator by that cause, so a backend that words the message
164
+ differently is still caught. The exact message `Unknown mutator: <name>` stays as a
165
+ fallback for a third-party backend that does not use the helper yet. Rule 4 now also
166
+ needs the probe to fail with a `BackendError`: a `failAlways` that dies with a defect
167
+ fails the rule instead of passing it.
168
+
169
+ **Action required:** backend authors who run `verifyBackendContract` (imported from
170
+ `@thomasfosterau/legation/internal`) must register `BackendContract.failAlways`
171
+ and `BackendContract.writeThenFail`. The second is a handler that writes a row and then
172
+ fails with `BackendError`, called with args `{ collection, id }`. An unregistered mutator
173
+ fails rule 4 or rule 10. `BackendContract.memoryHandlers` already includes both. A
174
+ non-transactional executor (D1-style) fails rule 10 by design, since each statement
175
+ commits on its own (#342), so it is not run through this contract.
176
+
177
+ - 8a8adfc: Add `BusBrokers.makeBroadcastChannelBroker`: a concrete `BusBroker` over a
178
+ `BroadcastChannel` for same-machine cross-context fan-out (browser tabs, Node
179
+ worker threads), joining the existing Redis Streams and WebSocket/Durable-Object
180
+ brokers. It honors the bus contract's self-delivery (a `BroadcastChannel` omits
181
+ the sender, so `publish` also offers to this broker's own subscribers).
182
+ - 87fa7a5: Add `LiveView`: a modular, incrementally-maintained materialized view — a
183
+ subset's result (filtered, sorted, windowed) kept current by row deltas instead
184
+ of re-scanning the collection. `put`/`del` place or remove a single row at its
185
+ sorted position and the visible page is sliced from the maintained order, so a
186
+ change outside the page never re-sorts it and pagination boundaries are handled
187
+ (an insert pushes a row out of the page; a delete pulls the next one in).
188
+
189
+ `ClientEngine.observe` now maintains a `LiveView` per query: on the read path
190
+ (empty journal) it applies the pull's per-row patch deltas incrementally and
191
+ drops re-emits when the page didn't move; it falls back to a full rebuild only
192
+ when the optimistic overlay shifts. `Subset` gains reusable `comparator`,
193
+ `sortFiltered`, and `window` helpers (with `apply` composed from them).
194
+
195
+ - 822001c: Add **named queries** (`Query`): a read defined once and resolved twice, like a
196
+ mutator. The client resolves a name + args to a `Subset` it renders locally; the
197
+ server resolves the same name + args to the _authoritative_ subset it syncs,
198
+ with the request's identity in hand — so query shaping and permission move to
199
+ the server (it can narrow, scope to a tenant, or add a filter the client can't
200
+ override) while the client still renders from the rows it was sent.
201
+
202
+ - `Query.make` + `Query.registry` (client subset factories) + `Query.resolver` /
203
+ `Query.QueryResolver` service (server, context-aware, args-validated).
204
+ - `PullRequest` gains an optional `namedSubscriptions` field; `SyncServer`
205
+ resolves each via the `QueryResolver`, then authorizes and evaluates it
206
+ identically to a client-authored subscription.
207
+ - `ClientEngine` gains `subscribeQuery` / `unsubscribeQuery` / `queryNamed` /
208
+ `observeNamed` and an optional `queries` registry. Backward compatible —
209
+ engines without named queries are unchanged.
210
+
211
+ - 4c59860: Add `observeChanges(subset, options?)`: a public row-level change feed
212
+ (`RowChange`, #115) on `ClientEngine`, `ClientView`, and `Mirror` alike
213
+ (ADR-0021), for keyed-list bindings, side-effect subscriptions, and delta
214
+ pipelines that today have to diff `observe()`'s pages by hand. Batched per
215
+ view-step:
216
+
217
+ - `enter`/`exit` are anchored to the subset's windowed result — a predicate
218
+ or limit-boundary crossing (an insert pushing the last visible row out, a
219
+ delete pulling the next one in) produces the pair a consumer diffing two
220
+ pages would derive; `update` marks a same-membership value change.
221
+ - A `Rebuild` (the optimistic overlay shifted) or a seq-gap collapse (a
222
+ bounded change hub dropped one or more changes) emits a `reset` marker
223
+ followed by fresh `enter`s for the current window, rather than a diff
224
+ against unknown prior state.
225
+ - `{ includeInitial: true }` emits the current window as `enter`s in the
226
+ first batch, with the same subscribe-before-snapshot guard `observe`
227
+ already uses (#84) — a late subscriber sees exactly one batch, no gap or
228
+ duplicate row.
229
+
230
+ `ClientEngine` derives batches from the same incrementally-maintained
231
+ `LiveView` window `observe` already keeps (surfaced, not recomputed);
232
+ `Mirror` — which has no local `LiveView` — diffs the whole-page updates its
233
+ host already broadcasts, so both hosts emit identical batches for identical
234
+ page transitions. `@thomasfosterau/legation-browser`'s `ElectedHost` switches an observer
235
+ onto whichever `ClientView` (mirror or owned engine) is currently active, so
236
+ `observeChanges` works unchanged across a leader-election transition too.
237
+
238
+ A confirmed-row pull and a plain local mutation (#109's delta-maintained
239
+ overlay tracks a mutator's local handler directly and publishes exactly what
240
+ it did) both diff precisely; `Rebuild` is reserved for cases the overlay
241
+ genuinely can't diff — a pull racing a still-pending mutation, an unknown
242
+ mutator, a reset (`Clear`) patch — or a seq-gap collapse.
243
+
244
+ Move events are not yet part of the contract: `LiveView` rows carry a stable
245
+ position (#112) at runtime, but a row repositioned by a neighbor's
246
+ insert/delete without its own value changing isn't distinguished from
247
+ "unchanged" here — a consumer that needs order re-derives it from a paired
248
+ `observe()` or the position of `enter`s within a batch, pending that
249
+ follow-up. See `docs/observing-row-changes.md` for the framework-binding
250
+ guide (a Svelte 5 keyed-list example) and the `runOnChanges`-style
251
+ side-effect pattern.
252
+
253
+ - 961e604: Delta-maintain the optimistic overlay instead of rebuilding it on every
254
+ mutate. `mutate` now runs the mutator's local handler through a tracked
255
+ writer that patches the cached overlay in place and publishes exactly the
256
+ `put`/`del` ops it performed, so observers fold the delta the same way
257
+ `LiveView` already folds confirmed-row patches — no journal replay, and no
258
+ full re-materialization, per mutation. A pull with pending mutations no
259
+ longer forces a global `Rebuild`: rebase now scopes to the union of the
260
+ patch's collections and whatever the pending-before-this-pull mutations
261
+ touch, falling back to a full rebuild only on a reset (`Clear`) or an
262
+ unrecognized mutator. This is the write-path follow-through anticipated by
263
+ the read-path selective re-evaluation work: per-mutation cost is now
264
+ proportional to the rows a mutation touches, not collection size × journal
265
+ length.
266
+ - 5937efe: Add the relational `Predicate.exists` node: a correlated relationship test
267
+ (this row matches if a row exists in another collection whose `where` holds and
268
+ whose `foreignField` equals this row's `localField`). Backends resolve it to an
269
+ allow-set with `Predicate.resolveExists` before evaluation — MemoryBackend over
270
+ its rows, the SQL backend via a `SELECT DISTINCT` subquery (the `Sql` translator
271
+ also compiles `Exists` to a correlated `IN (SELECT …)`). This closes the deeper
272
+ parts of the relational-query and relationship-scoped-auth gaps: server-side
273
+ joins and "visible if you're a member of the project" auth **without
274
+ denormalizing** the join key onto the row. The JSON:API backend can't resolve
275
+ correlated joins over REST and fails closed on `Exists`.
276
+ - 08ac9cd: Relationship-scoped read authorization. Add an `IdentityResolver` seam: an
277
+ optional service `(clientGroupId) => Effect<identity>` that `SyncServer` uses to
278
+ populate `SyncContext.identity` on every push and pull, so auth filters and
279
+ middleware see a real, server-resolved principal (completing the auth-token
280
+ story). With identity in hand, relationship-scoped visibility ("an issue is
281
+ visible if you're a member of its project") is expressed as an allow-set
282
+ predicate: the auth filter resolves the principal's permitted keys and returns
283
+ `Predicate.isIn(key, allowed)` over a denormalized key on the row. ADR-0015
284
+ documents the pattern and the deferred join-without-denormalization seam.
285
+ - 1fc3725: Add relationship declarations to collections and a protocol-agnostic `Related`
286
+ read over the mirror. A collection can now declare `one`/`many` relationships by
287
+ field mapping (`localField`/`foreignField`); `Collection.registry` rejects a
288
+ duplicate relationship name within a collection and a relationship pointing at an
289
+ unknown collection at build time. The new core `Related` module resolves and
290
+ incrementally maintains those joins over any `ClientEngine` mirror —
291
+ `query`/`observe` (a write to a related row re-hydrates without re-resolving the
292
+ base) and `related` (auto-subscribes the narrowed `foreignField IN (...)` foreign
293
+ subsets and tears them down with the scope). The incremental-join plumbing is
294
+ exposed as a strategy seam (`queryWith`/`observeWith`/`relatedWith` plus
295
+ `fieldMappingStrategy`), and the JSON:API `Relationships` module now delegates to
296
+ it with a linkage-based strategy — no behaviour change for existing users.
297
+ `Related.resolveExistsLocally` renders `Predicate.exists` on the client by
298
+ rewriting each node to an `In` over the synced foreign collection.
299
+ - 8995115: `RowChange` (#115) gains `move` and a `position` field on `enter`/`update`
300
+ (#133 item 2, #112's follow-through): `LiveView`'s stable fractional-index
301
+ position, present whenever the source can compute one. A row whose value is
302
+ unchanged but whose neighbors shifted enough to move it — a sibling
303
+ entering/leaving the window re-ranks it — now emits `move` (`position` +
304
+ `previousPosition`) instead of being silently dropped as "unchanged," so a
305
+ keyed-list consumer can place `enter`s and re-sort on `move` rather than
306
+ only appending.
307
+
308
+ `ClientEngine.observeChanges` (backed by `LiveView`'s incrementally-
309
+ maintained window) emits `position`/`move`; `Mirror.observeChanges` does
310
+ not — a mirror has no maintained window of its own, only whole-page
311
+ broadcasts to diff, so it can't derive a stable position from a snapshot
312
+ alone. Documented, not silent (`docs/observing-row-changes.md`), which also
313
+ gains an updated Svelte 5 keyed-list example ordering by `position`.
314
+
315
+ Purely additive: existing `enter`/`update`/`exit`/`reset` consumers that
316
+ don't read `position` are unaffected.
317
+
318
+ - 8a8adfc: Live queries re-evaluate selectively: a change event now names the collections
319
+ it touched, so an observer re-runs only when its collection changed, instead of
320
+ on every local change. Pulls publish collection-scoped changes when the journal
321
+ is empty; overlay-shifting events (a mutation, push, or pull with pending
322
+ mutations) still re-evaluate everywhere. A first step toward incremental view
323
+ maintenance — full per-row delta maintenance remains a follow-up.
324
+ - 025fabb: Add **`SpecExport`**: emit OpenAPI 3.1 and AsyncAPI 3.0 descriptions of an app's
325
+ sync surface so bespoke clients can be generated in any language (ADR-0017). The
326
+ schema-defined protocol (push/pull/poke) is value-agnostic by design, so a
327
+ generic spec would describe an opaque envelope; `SpecExport` instead
328
+ **specializes** it from the app's own definitions — a `Mutation` variant per
329
+ mutator (its `args` schema), a patch `Put` per collection (its row schema), a
330
+ named subscription per query — so a generated client gets concrete, per-name
331
+ types instead of `any`.
332
+
333
+ The pipeline is two steps so new formats are cheap: `build(api)` produces a
334
+ format-neutral `SyncSpec` (a shared JSON-Schema component pool plus each
335
+ message's component name), and `toOpenApi` / `toAsyncApi` (or your own `toX`)
336
+ project it. `openApi(api)` / `asyncApi(api)` do both in one call. OpenAPI emits
337
+ push/pull as the logical `POST {path}/push` and `POST {path}/pull`; AsyncAPI adds
338
+ the streamed poke channel. The emitted schemas are the encoded (wire) form, and
339
+ the documents are plain JSON-serializable values. Needs nothing beyond `effect`,
340
+ so it lives in core (ADR-0006).
341
+
342
+ - 0b8fccb: Add `Subscriptions`: subscription lifecycle on top of `ClientEngine` —
343
+ reference-counted `acquire`/`release`, a TTL grace period before an unused
344
+ subscription is dropped (default 5 minutes), and `preload`/`preloadQuery` to
345
+ pin a subscription for the session. Releasing keeps the rows for the grace
346
+ window so a quick re-subscribe reuses them; if the grace elapses the engine
347
+ unsubscribes and the next pull drops the rows. This is the read-side lifecycle
348
+ Zero expresses as query `ttl` and `preload()`, built on named queries or raw
349
+ subsets.
350
+ - e7369e1: Add Effect-native observability (`Telemetry`): `legation_*` metrics and
351
+ `legation.*` spans wired into the engine's hot paths — client push/pull,
352
+ server push/pull, and fan-out. Counters (pulls, pushes, mutations pushed,
353
+ server pulls, pokes) and a patch-size histogram surface through whatever
354
+ `MetricRegistry`/`Tracer` exporter the host installs (OpenTelemetry,
355
+ Prometheus, …) with no extra dependency. Closes the observability gap from the
356
+ Zero feature-parity audit.
357
+ - 8995115: Engine-level reactive window retargeting (#133 item 3): `ClientEngine.
358
+ observeWindow(subset)` returns a `WindowObservation` (`rows` + `retarget`)
359
+ whose `retarget({ limit?, cursor? })` widens or advances the SAME open
360
+ observation's window in place — reusing `LiveView`'s maintained order
361
+ (`LiveView.retarget`, #112) instead of tearing the subscription down and
362
+ rebuilding it from scratch — and keeps the engine's tracked subscription in
363
+ step, so the next `pull` fetches the wider window from the server too.
364
+ `ClientView.observeWindow` exposes it over the `ClientView` seam for an
365
+ engine-backed view (present only there — a `Mirror` has no maintained
366
+ window of its own to retarget). `Subscriptions.acquireWindow` composes the
367
+ usual refcounted subscribe/release with it for callers that want lifecycle
368
+ management alongside retargeting.
369
+
370
+ `@thomasfosterau/legation-svelte`'s `useConnection` (`hooks.svelte.ts`) now retargets
371
+ through this seam for the common case (no `include`d edges, an engine-backed
372
+ view): `loadMore`/`reset` widen/shrink the same open observation instead of
373
+ letting `pages` force a resubscribe. A joined read, or a mirror-backed
374
+ connection, falls back to the original resubscribe-per-page behavior,
375
+ unchanged. No public API changes — `useConnection`'s signature and observed
376
+ behavior (growing window, `hasMore`, `reset`) are the same; only the
377
+ underlying re-registration is gone.
378
+
379
+ - c856d66: A covered read is judged and answered from one snapshot of its root rows, so a
380
+ pull between the coverage judgment and the answer can no longer serve a root row
381
+ whose edge windows are not pulled yet (#426). `Client.query` and the façade read
382
+ the root once. `Client.coverageOf` takes an optional `{ snapshot }` (the root rows
383
+ already read, with their subset) that the verdict is judged over for that subset;
384
+ `Query.runRows` answers a read from rows already read. A mirror that predates
385
+ `PulledRegistrations` still judges coverage from its registrations alone, so the
386
+ #422 race persists for it until it is upgraded.
387
+ - 01a8c64: Coverage by conjunct subsumption with strict windows (ADR-0029 section 3, issue #162). `Client.query` no longer requires an exact subset-hash match to answer from the replica: a read is served locally when some subscription in the Client's ledger covers it. A subscription covers a read when it is on the same collection; its window is absent (neither limit nor cursor), or the read asks for a prefix of the identical window (same order, same cursor, and a limit at most the subscription's when it has one); and, canonicalised to sets of top-level conjuncts, the subscription's set is a subset of the read's (an unwindowed subscription) or the same set (a windowed one, which holds only a window of its own predicate's rows). Every `include`d edge must be among the subscription's edge selects, with a paginated edge's `first` at most the subscription's. `Or` and `Not` subtrees compare structurally as whole conjuncts; nothing is inferred through them, and full implication is not built.
388
+
389
+ Behaviour change for the mirror and the owned engine alike: more reads are answered locally. A `Client.query` for a filtered page under an unwindowed subscription of the collection now stays local instead of falling through; under a windowed subscription (the commerce shell's `{ collection: "products", order: ["id"], first: 100 }`) a narrower read still falls through, because the strict window never covers a different predicate, a different sort, a different cursor or an unwindowed read. An explicit pin registers only its root window, so a read with `include` is not covered by it until #159 registers a pin's edge windows. Explicit pins are now counted, so two pins of one root release one at a time. The rule lives in `Coverage` (`@thomasfosterau/legation/internal`), and `Client.coverageOf(read)` reports which subscription covers a read, for devtools.
390
+
391
+ - 3362c37: Add `CrudMutation`: the graph-derived CRUD mutations (ADR-0030 §5). For every
392
+ node, `{type}/create`, `{type}/update` and `{type}/delete` with args in the
393
+ graph row form (`{ type, id, attributes }`, `one` edges as their `via` keys)
394
+ and the client handler that writes them; `update` is a no-op when the row is
395
+ absent, so a patch behind a rejected create leaves no phantom row.
396
+ `CrudMutation.entries(nodes, { writes })` spreads into `Mutation.registry`.
397
+ - 62dcb1c: `Hydration.dehydrate` now reads the store as one state. It used to read the
398
+ confirmed rows, the cookie, the journal and the unpulled tail in four separate
399
+ calls, so a pull or a push acknowledgement that landed between them could leave
400
+ a snapshot whose rows lacked an acknowledged mutation's effect, while that
401
+ mutation sat in neither the journal nor the tail. `ClientStore` gains an
402
+ optional `snapshot` member that reads all four together: under the store's lock
403
+ for the memory and key-value stores, and in one read transaction on IndexedDB.
404
+ `dehydrate` uses it when the store offers it and reads member by member
405
+ otherwise, so a third-party store behaves as before.
406
+
407
+ The `AtomicKeyValue` seam on `@thomasfosterau/legation/internal` gains an
408
+ optional `readAll`, which the browser's IndexedDB binding supplies as one
409
+ `readonly` transaction over the entries store. A key-value store with that seam
410
+ decodes its snapshot from that single read.
411
+
412
+ - be4be06: Add `Derive`: client-local **derived live queries** as bare, composable
413
+ combinators over observed sources — `filter` (arbitrary JS predicate),
414
+ `map`/`select`, `join` (arbitrary key equality, inner + left), `groupBy`
415
+ returning grouped rows, `distinct`, and `orderedWindow` (orderBy + limit). This
416
+ is the client-side expressiveness tier (#114): the arbitrary joins, DISTINCT,
417
+ row-level GROUP BY, and computed/cross-field predicates the serializable
418
+ `Subset`/`Predicate` wire language deliberately does not carry, done in memory
419
+ over already-synced rows.
420
+
421
+ Each combinator takes any source satisfying the `ClientView.observe` shape — an
422
+ owned engine, a mirror, or **another combinator**, so queries compose from
423
+ queries — and is maintained **incrementally** from keyed row deltas by pure
424
+ engines (`Incremental`), never refolded per change. Per ADR-0027 these are
425
+ #114's combinator engines, not a separate operator core: each mirrors a proven
426
+ seam's technique — `orderedWindow` the `LiveView`/`OrderedTree` order, `join`
427
+ `Related.makeJoinEngine`'s keyed buckets + reverse index, `groupBy`
428
+ `Aggregate`'s running-fold-plus-`byId`. Each carries a delta-in/delta-out
429
+ property test against a naive from-scratch recompute.
430
+
431
+ Results are an Effect `Stream` and, via `Derive.atom`, an Atom on the standard
432
+ reactivity surface, so framework bindings need nothing new. Leaf subscriptions
433
+ compose through the refcounted `Subscriptions` manager (`Derive.observed`) and
434
+ release on scope close, tearing the whole chain down together.
435
+
436
+ **The boundary rule (ADR-0027): derived combinators never widen what syncs.**
437
+ They see only the rows a client already holds; the serializable Query/Subset
438
+ tier alone decides what the server ships. Both `Incremental` and `Derive` live
439
+ behind `@thomasfosterau/legation/internal` (read-layer machinery, no new public noun).
440
+ ADR-0027 (client-local IVM delta model) records the two-tier read model and the
441
+ never-widens-sync rule these consume.
442
+
443
+ - be0a44d: Make the acknowledged-but-unpulled tail durable in `ClientStore` (#336). A push acknowledgement parks the applied mutations in the store, out of the pending journal, so they are never pushed again. Their effects stay overlaid until a pull reports them, and now also across a restart, a hydration snapshot and the BackgroundSync push-only flush. A restart between a push acknowledgement and the next pull no longer shows the confirmed rows without the mutation.
444
+
445
+ - **Pull release.** A pull records its whole release in the store before it writes its rows: the watermark, the cookie and the patch. An open that finds the record finishes the pull (it re-applies the patch, which is idempotent, writes the cookie, trims the journal and releases the tail), so a crash at any point shows the pull's effects, never the rows without them. A release that fails part-way is finished in the same process, including one whose record write reports failure after it landed. If that roll-forward also fails after its read of the record, the store seals itself until it is reopened; a failed read alone fails that call and the next write or pull finishes the record.
446
+ - **Row writes during a release.** While a release is outstanding, `putRow`, `delRow` and `clearRows` finish it first, under the same store lock, and fail if they cannot. A failed read of the record fails only that write and seals nothing. Any failure after that read seals the store until it is reopened, and the next open finishes the release. That includes a failed journal trim with no record outstanding: a parked copy left in both the journal and the tail would be double-counted by reads and pushed again. The engine's `seed`, its `clearRows` and its identity reset hold the overlay lock for their row writes. A hydration snapshot finishes an outstanding release before it writes.
447
+ - **Journal and tail.** `appendMutation` refuses an entry the unpulled tail already holds, with a `StoreError` and nothing written: a journal copy of a parked entry would be double-counted and pushed again. `Hydration.hydrate` skips the snapshot's journal entries the store's tail holds, so a hydration never makes that copy. `beginRelease` finishes an outstanding release before it records its own, so a pull is not refused by a release a failed roll-forward left behind.
448
+ - **Identity reset.** The tail is cleared with the rows it was overlaid on, in the store's open-time reset and in the engine's runtime reset. The store drops any release record with it.
449
+ - **Trim.** A push whose journal trim fails leaves the parked entries out of the journal in the same process, as an open does, so they are not pushed again or replayed twice.
450
+ - **Hydration.** `Hydration.Snapshot` gains an optional `unpulled` field. `dehydrate` writes it when the tail is not empty; `hydrate` parks it. A snapshot without the field loads with an empty tail.
451
+ - **Engine option.** `ClientEngine.make` takes an internal `unpulledLimit` (default 1024): the most acknowledged-but-unpulled mutations the overlay keeps (#286); a push never takes the tail past it (#429). Tests lower it to reach the bound quickly.
452
+ - **IndexedDB.** The tail uses the existing key-value layout, so there is no new object store or index and the database version does not change.
453
+
454
+ Action required: a custom `ClientStore` must implement six new members: `unpulled`, `parkUnpulled`, `beginRelease`, `releaseUnpulled`, `finishRelease` and `clearUnpulled`. `ClientStore.makeMemory`, `ClientStore.makeKeyValue` and `IndexedDbClientStore` already do. `beginRelease` finishes an outstanding release first, whichever engine on the store began it, and fails when that finish or its read of the record fails. `finishRelease` rolls an outstanding release forward from the record `beginRelease` stored, and a store that cannot do so must fail its later calls, as a sealed store does. `beginRelease` may be a no-op only for a store that cannot survive a restart. In a store that does survive a restart, a no-op `beginRelease` double-counts after a crash mid-release, rather than reverting. Implementing the tail members as no-ops in such a store silently reverts the overlay after a restart: the tail is lost and the confirmed rows lack the acknowledged mutations until the next pull.
455
+
456
+ - e818dc4: Server-side cache eviction from committed writes (#206). `InvalidationSink` (on `@thomasfosterau/legation/internal`) subscribes a sink to the `InvalidationBus` and passes it the collections and rows each hint batch touched, on a fibre of its own, so an eviction can neither fail nor delay the write. `EdgeInvalidation` in `@thomasfosterau/legation-alchemy-cloudflare` provides the sinks: KV deletes of `{collection}:{id}`, Workers cache purges by tag, and `forward` / `receive` to carry an invalidation from the API Worker to the front-door Worker that holds the cache over a service binding's RPC method. Failures, including a purge that resolves `{ success: false }`, are logged. The `collection:{name}` / `collection:{name}:{id}` tag spelling moves to `HttpCache` (`tagFor`, `fineTagFor`, `evictionTags`) in the core; `HttpCacheInvalidation.defaultTagFor`, `defaultFineTagFor` and `Touched` in `@thomasfosterau/legation-browser` now refer to it, unchanged.
457
+ - d1b01a4: Tested against `effect` 4.0.0 (and the matching `@effect/*` companions); the
458
+ peer floor is unchanged at `effect >=4.0.0-rc.118`.
459
+
460
+ `@thomasfosterau/legation` adapts to 4.0.0's stricter, type-only `Schema.brand`:
461
+
462
+ - `Node.Id` keeps its signature and runtime behaviour; its internal brand
463
+ identifier is cast to satisfy the new concrete-identifier check.
464
+ - `Node.AnyNode`'s `Id` property is now
465
+ `Schema.Codec<string & Brand.Brand<string>, string>` instead of `Id<string>`.
466
+ 4.0.0's `brand<String, B>` is type-only and no longer assignable to a wider
467
+ ``brand<String, `${string}/id`>``, so no concrete `Node<"todos", …>` was
468
+ assignable to `AnyNode`. Every concrete node is assignable again, and `Id`
469
+ is still a string-encoded branded id schema; only code that reads `.Id` off
470
+ an `AnyNode`-typed value sees the wider brand.
471
+
472
+ Checked the 4.0.0 behavioural changes against the packages and found no impact:
473
+ no use of `partition`/`separate`/`Option.partitionMap`, `Queue.State`,
474
+ `MessageStorage` or `RpcMessage.ExitEncoded`. The RPC client's missed-pong
475
+ handling now fails in-flight WebSocket calls rather than reporting a transient
476
+ error; `RpcTransport.fromOrigin` already treats a failed WebSocket call as a
477
+ `TransportError` and falls back to HTTP. The RPC server's WebSocket route now
478
+ closes with 1000/1001/1011, which nothing in the packages inspects.
479
+
480
+ - 5496ddf: Bump the pinned Effect version from `4.0.0-beta.84` to `4.0.0-beta.107` across
481
+ all packages (catalog and exact `peerDependencies`), and move
482
+ `@thomasfosterau/legation-jsonapi` and the commerce example from
483
+ `@thomasfosterau/effect-jsonapi@^0.6.0` to `^0.12.0` (#152).
484
+
485
+ **Why beta.107.** effect-jsonapi 0.12 requires `effect >= 4.0.0-beta.104` and
486
+ develops against `4.0.0-beta.107` exactly, so that is the version its typings
487
+ are known to line up with. The later `4.0.0-rc.*` releases were not attempted
488
+ in this change: the exact-pin policy (ADR-0003) prefers the version the
489
+ adopted library is built against, and every `effect/unstable/*` module this
490
+ repo uses (`httpapi`, `http`, `rpc`, `reactivity`, `persistence`, `sql`)
491
+ compiles and tests clean on beta.107.
492
+
493
+ **Effect API drift absorbed (no behavioural change):**
494
+
495
+ - `Schedule.either` / `Schedule.both` are gone; the default retry policy is now
496
+ `Schedule.max([Schedule.min([exponential, spaced]), recurs(5)])`, which is
497
+ the same policy (`min` recurs while any schedule recurs at the shortest
498
+ delay, `max` recurs while all recur at the longest delay).
499
+ - `Schema.TaggedErrorClass` is now `Schema.TaggedError` (the wire
500
+ `SyncRpcError` is unchanged on the wire).
501
+ - `SchemaRepresentation.fromASTs` is now `SchemaRepresentation.toRepresentations`
502
+ (spec export output is unchanged).
503
+ - `HttpApiGroup.Any` is gone and `HttpApi`'s group parameter is now
504
+ invariant, so `JsonApiSync.layer`, `JsonApiSync.inspect` and
505
+ `Convention.reflectResources` are generic over the concrete `HttpApi`
506
+ instead of accepting a widened alias. The exported `JsonApiSync.JsonApi`
507
+ alias is now `HttpApi.Constraint` (identifier and groups erased); pass your
508
+ concrete api to the functions rather than annotating it with the alias.
509
+
510
+ **effect-jsonapi 0.6 → 0.12 at the ADR-0016 boundary.** `Document`, `Query`,
511
+ `Resource`, `Relationship` and `Endpoint` lost no exports; `Query` gained
512
+ `IncludeOption`/`IncludePathsOf`/`OffsetPageStruct`/`bracketPageKeys` and
513
+ `Resource` gained `IncludeDepth`/`IncludePathsTo`. `schema.ts`, `types.ts` and
514
+ `CollectionLoader.ts` compile unchanged. One typing regression was noticed in
515
+ 0.12: endpoints with `:id` params (`Endpoint.get`/`delete`/`update`) declare
516
+ their params as `Schema.Struct<Schema.Struct.Fields & { id }>`, which leaks
517
+ `unknown` into the requirements of the corresponding `HttpApiClient` calls.
518
+ The commerce example narrows this with a small `run` helper in
519
+ `server/client.ts`; nothing in the published packages is affected.
520
+
521
+ The bump is `minor` rather than `patch` because the exact `effect` peer pin
522
+ means every consumer must move `effect` in the same change, and the
523
+ `@thomasfosterau/legation-jsonapi` type signatures above changed shape.
524
+
525
+ - 19eb4b0: Bump the pinned Effect version from `4.0.0-beta.65` to `4.0.0-beta.75` across all packages.
526
+
527
+ `@thomasfosterau/legation-browser` now builds its IndexedDB cache store on
528
+ `@effect/platform-browser` (`BrowserKeyValueStore.layerIndexedDb` + core's
529
+ `KeyValueStoreAdapter`) instead of a hand-rolled IndexedDB wrapper.
530
+ `@effect/platform-browser` is a new peer dependency of the browser package, and
531
+ `IndexedDbCacheStore.make` is now a scoped resource (requires `Scope`); the
532
+ `storeName` option was removed (the underlying object store is managed by
533
+ platform-browser).
534
+
535
+ - 38c3147: Effect-idiomatic API refactor:
536
+
537
+ - Protocol messages (`ClientMessage`, `ServerMessage`, `PushResult`, `WireKeyFilter`), cache
538
+ events (`CacheEvent`), and outbox events (`OutboxEvent`) are now `Data.TaggedEnum`s with
539
+ generated constructors, `$is` guards, and exhaustive `$match` matchers
540
+ (e.g. `Protocol.ClientMessage.Subscribe({...})`). The types are structurally unchanged, so
541
+ existing object-literal construction still compiles.
542
+ - `SyncCacheDefaults` `Context.Reference` for ambient cache timing defaults
543
+ (staleTime / timeToLive / staleWhileRevalidate).
544
+ - The cache's in-flight dedupe map uses `MutableHashMap`; background stale-while-revalidate
545
+ refresh failures are now logged instead of silently swallowed.
546
+ - The WebSocket transports validate incoming frames with `ProtocolSchema` codecs instead of
547
+ unchecked `JSON.parse` casts, and bridge socket events into Effect without `Effect.runFork`
548
+ inside callbacks.
549
+ - All tests use `@effect/vitest` (`it.effect` / `it.live` / `it.layer`, `TestClock`).
550
+
551
+ - fed4634: Peer the Effect v4 release-candidate line through an open floor rather than
552
+ an exact pin (#222), so an app can install `@thomasfosterau/legation-*` beside
553
+ `@thomasfosterau/effect-auth` and `@thomasfosterau/effect-jsonapi` and resolve
554
+ a single copy of `effect`. The published floor is `>=4.0.0-rc.118` (see
555
+ `effect-rc118-floor.md`), on `effect` and on the `@effect/platform-browser` /
556
+ `@effect/platform-node` peers.
557
+
558
+ **`@thomasfosterau/legation-jsonapi`**: a repeated shorthand filter key
559
+ (`?filter[name]=a&filter[name]=b`) is read as the comma list it spells
560
+ (`filter[name]=a,b`, an `In` predicate) rather than rejected with a 400,
561
+ because `@thomasfosterau/effect-jsonapi` accepts the repeated-key spelling at
562
+ every list-valued position.
563
+
564
+ The packages run in browsers and workers and deliberately declare no `engines`
565
+ (ADR-0003).
566
+
567
+ - fcc52eb: Target Effect `4.0.0-rc.118`: every package peers `effect >=4.0.0-rc.118`
568
+ (#261), and the `@effect/platform-browser` / `@effect/platform-node` peers use
569
+ the same floor. Install `effect` and every `@effect/*` companion at
570
+ `4.0.0-rc.118` or later together.
571
+
572
+ The packages import Effect's modules at their rc.118 paths (`effect/rpc`,
573
+ `effect/http`, `effect/http-api`, …); rc.118 removed the `effect/unstable/*`
574
+ export paths, so the packages don't resolve beside an earlier `effect`.
575
+
576
+ **`@thomasfosterau/legation-jsonapi`** depends on `@thomasfosterau/effect-jsonapi@^0.16.1`,
577
+ the first release that peers `effect >=4.0.0-rc.118`.
578
+
579
+ **`@thomasfosterau/legation-svelte`** depends on `@thomasfosterau/effect-svelte@^0.4.0`, for
580
+ the same reason.
581
+
582
+ - 52c614f: Batch a pull's subsets (#505). `Backend` gains an optional `evaluateMany(context, subsets)`,
583
+ one entry list per subset, each equal to what `evaluate` gives for it. The sync server's pull
584
+ evaluates its subsets through the new `Backend.evaluateAll`, which calls `evaluateMany` when the
585
+ backend has it and `evaluate` per subset otherwise, so `MemoryBackend`, `JsonApiBackend` and any
586
+ backend of your own work unchanged. `BackendContract` gains rule 14: a backend's `evaluateMany`
587
+ must equal its per-subset `evaluate` over the contract's subsets, the generated corpus included.
588
+
589
+ `SqlBackend` implements `evaluateMany` as one `UNION ALL` statement per pull, plus one per `exists`
590
+ node. `Sql.Dialect` gains the optional `unionRows` member (`Sql.UnionRows`) saying how each arm
591
+ carries its row: `Sql.sqlite` carries the relation's own columns by position (looked up once per read
592
+ relation), so values reach the backend exactly as `evaluate` reads them; `Sql.postgres` carries the
593
+ row as JSON (`row_to_json`), and a pull whose rows hold an integer past 2^53 falls back to
594
+ `evaluate` per subset. A hand-written dialect without `unionRows` keeps one statement per subset.
595
+ See the README for the PostgreSQL column types JSON does not carry as the driver reads them.
596
+
597
+ - 94d6327: Server-fallthrough reads (ADR-0025, ADR-0022 program step 6): a third protocol operation, `read` — a point-in-time batch subset evaluation under the same deny-by-default auth filters as pull, with no CVR involvement. `Client.query` now answers from the replica when the read's root window is in the Client's ledger and falls through to the server otherwise (degrading back to the local answer when the transport offers no `read` or the server is unreachable); results never enter the replica. Includes resolve through the fallthrough in at most two round trips with the same plan and windows as local reads. The hosted tier forwards reads over the mirror protocol (`Read`/`ReadResult`). `ServerHandler.handleRead` is required; `ClientTransport.read` and the new `SyncMiddleware.onRead` hook are optional.
598
+ - 91f07db: ADR-0023's topic-derived fan-out lands as a core seam (internal tier): `FanoutTopics` (the pure topic algebra — coarse `collection` topics plus fine `collection/field=value` topics restricted to the node's derivable set, with write derivation over old and new values), `FanoutBroker` (topic pub/sub with leased registrations; in-process reference implementation), and `FanoutBus` (an `InvalidationBus` over a broker, so `Server.make({ bus })` gets topic-precise cross-room fan-out unchanged). `ChangeHint` gains an additive `oldValues` field — the memory backend supplies it, so an update that moves a row between parents pokes both edge windows and a delete derives from the old row.
599
+ - 6c2c802: Close the sync tier's filter surface with a per-node **filterable set** (ADR-0028
600
+ rule d, #154).
601
+
602
+ - `Node.make` gains `filterable` (per attribute: the operators a client may use
603
+ — `eq ne lt lte gt gte in nin isnull` — optionally with an explicit literal
604
+ kind) and `sortable`. The resolved declaration is on `Node.filterable` /
605
+ `Node.sortable` and travels onto the derived `Collection` like `indexes`, so
606
+ the server enforces it without Node definitions.
607
+ - Defaults: `id` and every `one`-edge `via` field are filterable by `eq` / `in`
608
+ (kind `text`; a declaration naming a key field widens it, never replaces
609
+ it); `sortable` always includes `id`, and `Node.registry` adds every
610
+ paginated edge's `order` fields to its target's `sortable`, since the
611
+ per-parent edge windows sort by them. The literal kind of a declared
612
+ attribute is derived from its schema's encoded form (`String` → `text`,
613
+ `Number` → `double`, `Schema.Int` → `integer`, `Boolean` → `boolean`); an
614
+ `instant` must be declared explicitly. A declaration naming a field the row
615
+ does not carry, or an attribute whose kind cannot be derived, throws a
616
+ `FilterDeclarationError` at definition time.
617
+ - `Authorization.policy(filters, { collections })` carries the declarations;
618
+ `authorize` checks every client-authored `where` / `orderBy` against them
619
+ **before** the auth filter is AND-ed in and before `Backend.evaluate` runs:
620
+ an undeclared field, a declared field with an undeclared operator, a literal
621
+ of the wrong kind, a client-supplied `Exists`, or an unsortable `orderBy`
622
+ fails the pull or read with an `AuthorizationError` naming the collection,
623
+ field and operator. Today's wire forms `Not(In)` and a null-literal
624
+ `Compare` are checked as `nin` and `isnull`. The check runs on the request
625
+ as the client sent it: the auth filter's own predicate, what a
626
+ `SyncMiddleware` rewrites in, and named-query resolver output are
627
+ server-authored and exempt. A policy built without `collections` keeps the
628
+ previous open behaviour.
629
+ - `NodeBridge.fromResource` / `registry` accept `filterable` / `sortable` per
630
+ resource until effect-jsonapi carries the per-attribute annotation.
631
+
632
+ - ebcc6aa: Carry the enhanced form submit across the mirror protocol (#536). `Client.beginFormMutation` (and `/kit`'s `enhanceFormMutation`) now works over `Client.connect`: the mirror asks its host to begin the form mutation, and settles or abandons it on the receipt. The host's engine holds the in-flight entry, so its overlay and unpulled tail serve every mirror, and the effect is still released by the pull that covers the form client, not by the action's response. A mirror that disconnects or is swept abandons the entries it held.
633
+
634
+ `ClientEngine` gains `beginFormMutationEncoded`, the form submit's counterpart of `mutateEncoded`: the host decodes the args the mirror encoded rather than encoding them again, so a Mutation whose args schema transforms (such as `Schema.NumberFromString`) works over a mirror. When the host's store fails to park an acknowledged entry, the engine now still rebuilds its overlay and pulls, and the failure reaches the caller's `settle` as a `StoreError`.
635
+
636
+ Wire format: the begin travels as a `Mutate` naming the reserved mutator `MirrorProtocol.FORM_MUTATION_MUTATOR`, and the new `EndFormMutation` (mirror → host) and `FormMutationBegun` / `FormMutationFailed` / `FormMutationEnded` (host → mirror) messages carry the rest. The form replies name the mirror they answer, since host messages reach every mirror and each numbers its requests from zero. A host that predates this answers the begin as an unknown mutator, and the mirror reports that as a `TransportError` saying the host must be upgraded, instead of waiting for an answer that never comes. No app mutator may use the reserved name.
637
+
638
+ - c105fbf: Drive HTTP-cache invalidation from collection changes (#49).
639
+
640
+ - **`PullCompleted` enrichment** (`@thomasfosterau/legation`): the `PullCompleted`
641
+ `EngineEvent` now carries `collections` — the distinct collections the pulled
642
+ patch touched (a `Clear` stands for every collection the client currently
643
+ holds, since it resets the whole view). The field is always present. This lets
644
+ a collection-scoped reaction fire even on a client with an empty journal (the
645
+ common case, where a pull emits a `Patch` change and no `ViewRebuilt`).
646
+
647
+ - **`HttpCacheInvalidation`** (`@thomasfosterau/legation-browser`): `invalidateOnChanges` forks
648
+ a scoped fibre that evicts every cached response tagged for a changed
649
+ collection via the store's `invalidateTags`; a store error is logged and
650
+ swallowed, never fatal. `collectionChanges` derives the change feed from an
651
+ engine's `engineEvents` (mapping `PullCompleted.collections` and scoped
652
+ `ViewRebuilt.collections`, dropping empties). Wire it in a service worker by
653
+ forking `invalidateOnChanges` alongside `ServiceWorkerHost.run` (recipe in the
654
+ host's doc comment).
655
+
656
+ The tag convention is `collection:{name}` (the default `tagFor`): a response
657
+ tagged `collection:todos` is evicted whenever `todos` changes. This is a
658
+ collection-level granularity ceiling — every response tagged for a changed
659
+ collection is evicted; a per-row `collection:{name}:{id}` convention could
660
+ follow once patch-op row ids are plumbed through to the change feed (not
661
+ implemented here).
662
+
663
+ - e1a028a: Per-row HTTP-cache tags (#207): row-granular cache invalidation, closing the
664
+ collection-level ceiling noted in `.changeset/http-cache-invalidation.md`.
665
+
666
+ - **Change feed carries row ids** (`@thomasfosterau/legation`): the `PullCompleted`
667
+ `EngineEvent` now also carries `rows` — the `(collection, id)` pairs the
668
+ pulled patch's `Put`/`Del` ops touched (a `Clear` contributes to
669
+ `collections` only, since it has no finite row list). Always present,
670
+ possibly empty.
671
+
672
+ - **Fine tag convention** (`@thomasfosterau/legation-browser`): `HttpCacheInvalidation` gains
673
+ `defaultFineTagFor` — `collection:{name}:{id}`, one level finer than the
674
+ existing `collection:{name}` (`defaultTagFor`). `collectionChanges` now
675
+ yields `Touched` (`{ collections, rows }`) and `invalidateOnChanges` evicts
676
+ the union of both tag sets on every change. The coarse eviction is kept
677
+ deliberately even though every touched row's collection is already implied:
678
+ an aggregate document can be staled by a row it does not yet contain, which
679
+ has no fine tag to match.
680
+
681
+ - **Response tagging + budget** (`@thomasfosterau/legation-browser`): `tagsFor(collection,
682
+ rowIds)` builds a response's tags — coarse plus one fine tag per row, up to
683
+ `FINE_TAG_ROW_BUDGET` (43, derived in a comment against the real ceiling on
684
+ the `x-legation-cache-meta` header these tags ride on). Beyond the budget, a
685
+ response is tagged coarse-only with `budgetExceeded: true` on the result.
686
+ `tagsForObserved` pairs the same computation with a logged warning and the
687
+ new `legation_cache_tag_budget_exceeded_total` counter
688
+ (`Telemetry.cacheTagBudgetExceededTotal`), so the fallback is observable
689
+ rather than silent.
690
+
691
+ Both granularities share one tag spelling across the client-side
692
+ invalidation path and response tagging, so the edge/KV invalidation sink
693
+ (#206) can reuse it unchanged.
694
+
695
+ - 9260efb: Carry each client's mutation id counter in a hydration snapshot (#271). Once
696
+ acknowledgements had drained the journal, a `Hydration.Snapshot` held no record
697
+ of the ids the backend had already applied, so a store hydrated from it and used
698
+ under the same `clientId` issued those ids again; the backend skipped them as
699
+ duplicates and the client acknowledged them, losing the writes silently.
700
+
701
+ `Hydration.dehydrate` now writes an optional `counters` field (each client's
702
+ highest issued id, left out when there are none), and `Hydration.hydrate` raises
703
+ the store's counters to it, never lowering one, so a reused `clientId` continues
704
+ above its acknowledged ids. A snapshot without `counters` still loads as before.
705
+
706
+ `ClientStore` (on `./internal`) grows two required members, `mutationCounters`
707
+ and `raiseMutationCounters`, and `ReplicaState` a `counters` field; the memory
708
+ and key-value stores implement them. A custom `ClientStore` must add both.
709
+
710
+ - 0644af5: SSR hydration, engine seeding, and subset prefetch (#41). A client's durable
711
+ state can now cross a serialisation boundary, and the local view can be warmed
712
+ before the first network round-trip.
713
+
714
+ - `Hydration.dehydrate` reads a `ClientStore` into a plain `Snapshot` (confirmed
715
+ rows flattened with their collection, the pull cookie, and the journal);
716
+ `Hydration.hydrate` writes one back, validating it through `Snapshot` on
717
+ the way in so a `dehydrate → JSON → hydrate` round-trip is schema-checked and a
718
+ malformed snapshot fails as a `StoreError`. `SnapshotFromJson` folds the JSON
719
+ step into the decode. The module doc walks the SSR flow (server pulls once,
720
+ `dehydrate`s; client `hydrate`s and continues from the same cookie — no
721
+ refetch) and the seeded-versions caveat.
722
+ - `ClientEngine.seed` writes confirmed rows and reveals them with a single rebuild
723
+ scoped to the seeded collections, so observers render synchronously before any
724
+ transport call. Versions must be genuine backend versions for CVR diffing to
725
+ converge on the next pull; self-invented versions are safe but degrade to the
726
+ reset path.
727
+ - `ClientEngine.ensureSubset` prefetches a subset: it subscribes, and if the
728
+ subset yields no local rows, pulls once. Concurrent calls for the same subset
729
+ dedupe on `Subset.hash`, so a burst of prefetches triggers at most one pull.
730
+ - `ClientStore` gains `listCollections` (the enumeration `dehydrate` walks),
731
+ implemented for both the in-memory and KeyValueStore-backed stores.
732
+
733
+ - 4b05d55: SSR primitives in `Hydration` (ADR-0020): `snapshot` produces a page snapshot
734
+ over a request-scoped engine (in-memory store + in-process transport to the
735
+ in-context `ServerHandler`, subscribe → pull → dehydrate) under the browser's
736
+ client group id, so the snapshot cookie continues that group's CVR
737
+ conversation on the client's first pull; `isEmpty` / `hydrateIfEmpty` gate
738
+ client boot so a stale snapshot never regresses a durable store's cookie or
739
+ journalled offline mutations; `rowsFor` projects a snapshot onto a subset
740
+ with `Subset.apply` — the synchronous initial value for component hooks
741
+ during SSR and first client render.
742
+ - Scope the durable replica to a resolved identity (#212), closing the gap where
743
+ adopting legation would make a multi-tenant app on a shared device _less_ safe
744
+ than what it replaced. The invariant, now in `CONTEXT.md` beside the HTTP-cache
745
+ section: **a durable replica belongs to exactly one resolved identity, and an
746
+ identity change is a reset, not a rebase.**
747
+
748
+ Identity is resolved server-side per pull, which is what makes the auth filter
749
+ trustworthy — but the replica the authorised rows land in outlives the session
750
+ that authorised them. A signs out, B signs in, and B's Client boots onto A's
751
+ rows because they are the freshest thing on the device and nothing recorded
752
+ which identity authorised them. A's unpushed journal made it worse: durable,
753
+ causally ordered, and about to go out under B's cookies.
754
+
755
+ - **`Identity` (`@thomasfosterau/legation`, new)**: `Fingerprint`, the opaque
756
+ app-supplied token a replica is stamped with — a session-subject hash, **never
757
+ PII and never a credential**, as the type's own docs say. `anonymous` is the
758
+ signed-out principal; `unstamped` is a replica that has never been scoped, and
759
+ equals nothing. `transitionOf` is the whole rule, in one place so boot and
760
+ runtime cannot disagree: `Unchanged`, `Upgrade` (anonymous → signed in, the
761
+ one benign transition), or `Reset`.
762
+
763
+ - **`ClientStore` identity boundary**: a new `identity` option stamps the
764
+ fingerprint and compares it on open. A mismatch quarantines the unpushed
765
+ journal under the **superseded** fingerprint, then drops confirmed rows and
766
+ the pull cookie. New surface: `getIdentity` / `setIdentity`,
767
+ `quarantinePending` / `quarantinedMutations` / `quarantines` /
768
+ `releaseQuarantine` / `discardQuarantine`, and `clearCookie`. A boundary that
769
+ cannot be established **seals** the store — every operation fails loudly,
770
+ nothing is served, nothing is destroyed, and the next open retries — rather
771
+ than reporting a boundary that isn't there.
772
+
773
+ - **Quarantine is its own keyspace**, which is the security property rather than
774
+ an implementation detail: `pendingMutations` reads the journal and `push`
775
+ reads `pendingMutations`, so a quarantined entry is structurally unreachable
776
+ from any push path — a background flush included — under any identity but the
777
+ one it is keyed by. Mutation ids are preserved rather than renumbered, so an
778
+ entry that was in flight at reset is at worst acknowledged and skipped by the
779
+ backend's exactly-once ledger, never applied twice. Only
780
+ `discardQuarantine` destroys one: no reset and no boot does.
781
+
782
+ - **`Client.reset(fingerprint)`** applies the same rule at runtime, dropping the
783
+ Client's window ledger along with the rows so coverage cannot answer the new
784
+ principal from the old replica. `Client.make` takes an `identity` and adopts
785
+ it before the sync loop starts. `identity`, `quarantines`,
786
+ `quarantinedMutations` and `discardQuarantine` round out the surface; a
787
+ Client built with `connect` says the host owns the replica rather than faking
788
+ a reset.
789
+
790
+ - **The sync client id is scoped to the principal** while the boundary is on
791
+ (derived from the configured `clientId` plus the fingerprint). Mutation ids
792
+ are a per-client sequence the backend requires to be contiguous, so a second
793
+ principal continuing the first's numbering would push a gap and restarting it
794
+ would collide with ids already applied; scoping gives each principal its own
795
+ sequence, and gives a returning one back exactly the sequence they left.
796
+
797
+ - **`IndexedDbClientStore` (`@thomasfosterau/legation-browser`)** takes an `identity`, and
798
+ `layerNamespaced` offers the per-identity replica as the alternative to
799
+ resetting — namespace for a device with a few regular users who switch back
800
+ and forth, reset where sign-ins are one-off or unbounded.
801
+
802
+ Opt-in: with no `identity` configured, nothing is stamped, nothing is compared,
803
+ and behaviour is unchanged. Turning it on resets an unstamped replica once,
804
+ which is the safe direction — an unstamped replica is exactly the state this
805
+ boundary exists to distrust.
806
+
807
+ - 82911cc: `observeAggregate` / `observeAggregateGrouped` now maintain their fold
808
+ incrementally from row-level deltas instead of re-scanning the subset on
809
+ every emission (#110). `Count`/`Sum`/`Avg` update in O(1) per row delta
810
+ (`Avg` as a running `{sum, count}`); `Min`/`Max` update in O(1) except when
811
+ the row removed or updated away held the current extremum, in which case
812
+ only the surviving values are refolded. Grouped aggregates touch only a
813
+ changed row's old and new group — every other group's value is left
814
+ untouched, both in cost and by reference.
815
+
816
+ This applies to unwindowed subsets (no `limit`/`cursor` — the common case:
817
+ aggregating over everything a client has synced) whenever a `Patch` view
818
+ change is available — a confirmed pull's per-row deltas, or (now that #109
819
+ has landed) a `mutate`'s optimistic overlay deltas. A windowed subset, or a
820
+ change too large to diff safely (`Rebuild` — resets, an unrecognized
821
+ mutator, seq-gap collapses), still refolds from scratch, as before. Public
822
+ API and dedup guarantees ("streams do not re-emit when a change leaves the
823
+ aggregate unchanged") are unchanged.
824
+
825
+ A benchmark (`bun run bench`) exercises three live aggregates over a 10k-row
826
+ collection under single-row churn.
827
+
828
+ - 7b21d11: Keep a secondary index in step with the confirmed-row writes that bypass a pull.
829
+
830
+ - `reset` (the identity `Reset`) now drops the built indexes along with the rows it clears. An indexed query after a reset could previously consult an index that still named the dropped rows.
831
+ - `Hydration.hydrate` into a store a running `ClientEngine` is built over now tells that engine to re-read: it drops its built indexes and its materialized view, and reveals the result. The re-read runs even when a write fails partway, and it reaches an engine attached while the hydrate was writing. It reaches only engines built over that exact store object. Before, an indexed query could return a row whose indexed field the hydrate had changed, while the row stayed present.
832
+ - A new `ClientEngine.clearRows` (on the `/internal` seam) drops every confirmed row, then the view and the built indexes with them. Use it on a running engine instead of `ClientStore.clearRows`, which left the engine showing the cleared rows.
833
+ - The re-read behind `hydrate` is `ClientEngine.resyncAttachedEngines` on the `/internal` seam. It is internal: app code calls `Hydration.hydrate`, not this.
834
+
835
+ - 6f54de8: Add engine and server introspection (#45).
836
+
837
+ - **Unified `EngineEvent` stream**: `ClientEngine.engineEvents` emits the whole
838
+ sync lifecycle from a single internal hub — `MutationJournaled` /
839
+ `MutationAcknowledged` / `MutationRejected` (the #36 events), `PushStarted` /
840
+ `PushCompleted`, `PullStarted` / `PullCompleted`, `PokeReceived`, and
841
+ `ViewRebuilt` (carrying the scoped collections). `mutationEvents` (#36) is now
842
+ a filtered projection of this one hub, so there is a single source of truth;
843
+ its behaviour is unchanged. The hub stays unbounded for the reason #44 kept
844
+ the mutation hub unbounded — lifecycle events are low-volume, consumer-driven,
845
+ and lossless (observability, not reconstructable via pull).
846
+
847
+ - **`EngineIntrospection`** (`Introspection.ts`): a read-only view over a
848
+ `ClientEngine` and its `ClientStore` — `subscriptions` (active subsets,
849
+ including resolved named subscriptions), `journal`, `cookie`, `collections`,
850
+ and `events`. Built over the engine's public read accessors (new
851
+ `activeSubscriptions` / `engineEvents` members) and the store's public reads,
852
+ so it never touches private state. Every member is a plain `Effect`/`Stream`,
853
+ so it drives cleanly through `PromiseFacade`.
854
+
855
+ - **Server introspection** (`SyncServer.makeWithIntrospection`): a read-only
856
+ `ServerIntrospection` over the live fan-out state — active client groups with
857
+ their authorised subsets and CVR sizes, plus poke counters. Fan-out behaviour
858
+ is unchanged.
859
+
860
+ - **New `legation_*` metrics**: `legation_journal_depth` (gauge, sampled on
861
+ append/acknowledge), `legation_rebase_duration_ms` (histogram, around the
862
+ overlay rebuild), `legation_fanout_latency_ms` (histogram, the server fan-out
863
+ leg), `legation_pull_patch_ops` and `legation_push_mutations` (histograms).
864
+
865
+ - 792d8f5: Sampling the journal-depth gauge no longer reads the whole journal (#260).
866
+ `ClientStore` gains `pendingCount`, the number of pending journal entries
867
+ without reading them: the memory store takes its journal's length, and the
868
+ key-value store (and so `@thomasfosterau/legation-browser`'s IndexedDB store) counts its
869
+ journal index, reading no mutation bodies. The engine samples
870
+ `legation_journal_depth` from it on every append, acknowledgement and paced
871
+ compaction, where it used to load every pending body to take
872
+ `pendingMutations.length`. A `mutate` also stops reading the journal when the
873
+ overlay cache is warm: the pending snapshot it took is needed only to rebuild
874
+ a cold cache. Custom `ClientStore` implementations must provide
875
+ `pendingCount`, and should count without loading the entries.
876
+ - a3b2ee4: The JSON:API façade (ADR-0029, issue #166): a fetch handler that answers a
877
+ covered JSON:API `GET` from the replica and stays out of the way otherwise.
878
+
879
+ - `JsonApiFacade.fetchHandler({ nodes, api, view, coverageOf, cookie?, resources?, baseUrl?, prefix?, online? })`
880
+ in `@thomasfosterau/legation-jsonapi` builds the façade from the app's `HttpApi`
881
+ (`Convention.reflectResources` gives the `list` / `getOne` routes it
882
+ recognises; those whose type is no node are left to the server), the
883
+ node graph, the replica's view and a coverage source. It presumes the
884
+ api's read endpoints are declared with the graph's codec
885
+ (`JsonApiQuery.querySchema` / `singleRowSchema`) and served by the
886
+ derived handlers, which is what lets a parse failure be answered locally.
887
+ `matches(request)` says whether a request is its business — a `GET` whose
888
+ `Accept` names `application/vnd.api+json` on a reflected route under
889
+ `baseUrl` — and `handle(request)` answers: a covered request gets `200`
890
+ with the document the one writer produces (byte-equal in content to the
891
+ derived REST handler's for the same declarations, the optimistic overlay
892
+ visible as `Client.query` sees it), `Cache-Control: no-store` and
893
+ `x-legation-served-at` set to the replica's CVR cookie (the store's,
894
+ empty until the first pull); a URL the grammar rejects gets the codec's
895
+ `400` document (`RestHandlers.queryErrorDocument`), except an `include`
896
+ the graph does not declare, which the server rules on; an uncovered
897
+ request while offline gets the `503` `offline` error document of
898
+ ADR-0029 §4 (`handler.offline`, with the cookie, also for a forward that
899
+ rejected at the transport). Everything else is `undefined` for the caller
900
+ to forward unchanged: a non-candidate, an uncovered request while online,
901
+ a collection no bound resource declares, and a single-resource request
902
+ whose row the replica does not hold (the 404 is the endpoint's own).
903
+ Coverage is every read `ReplicaDocuments.toCover` names, each judged by
904
+ `coverageOf` — `client.coverageOf`, or the host's registrations below.
905
+ The module lives in `@thomasfosterau/legation-jsonapi` by ADR-0006: it needs this
906
+ package's codec, writer and route reflection plus the standard
907
+ `Request` / `Response` globals, and `navigator.onLine` is an option with a
908
+ guarded default, so `@thomasfosterau/legation-browser` stays free of effect-jsonapi.
909
+ - `EngineHost.run` returns a `Host` whose `registrations` lists what the
910
+ host holds on its mirrors' behalf — root windows, relational registrations
911
+ with their edge selects, both halves of a `SubscribeUrl` with its canonical
912
+ URL while a party holds it by URL — as `Coverage.Subscription`s: the
913
+ ledger a façade's coverage check scans in a worker, where no `Client`'s
914
+ own ledger sees the tabs' pins. A registration is listed once its engine
915
+ registration exists (a relational one once its reconcile loop has
916
+ started; one naming an edge the registry does not declare registers
917
+ nothing) and dropped when the last mirror releases it or is swept.
918
+ `Coverage.verdictOf` and
919
+ `Coverage.coverageOf(nodes, subscriptions)` give the same `Verdict` as
920
+ `Client.coverageOf` over any such ledger; `Client` uses the former.
921
+ - `ServiceWorkerHost.start` serves exactly as `run` does and returns the
922
+ `Host` — the engine and its registrations — for the worker to build the
923
+ façade from; `run` is `start` then serve forever.
924
+ - The commerce worker composes façade, then its unchanged HTTP cache, then
925
+ the network (`examples/commerce/src/service-worker.ts`,
926
+ `src/lib/facade.ts`, `src/lib/host.ts`); the api's shape moves to
927
+ `server/api.ts` so the worker reflects the routes the server serves; and
928
+ the replica test runs the façade over the same seed: every URL the
929
+ shell's pins cover is answered with the served document's bytes, and the
930
+ rest go to the network. `Convention.segmentsOf` and `Convention.isParam`
931
+ are exported.
932
+
933
+ - c0ab703: Keyed reactive mutation-state surface (#116) — TanStack Query's
934
+ `useMutationState` analogue. `ClientEngine.mutationStates` maintains a map
935
+ keyed by `mutationId`, seeded from the journal at boot (a reload shows
936
+ pending intents) and folded forward as mutations are journaled, acknowledged,
937
+ or permanently rejected — regardless of whether they went through `mutate`,
938
+ `mutateEncoded`, or `mutateWithAck` (previously only `mutateWithAck`-registered
939
+ mutations ever settled on `mutationEvents`/`engineEvents`; this fixes that
940
+ gap so the whole journal is observable). Terminal (acknowledged/rejected)
941
+ entries are retained only up to a configurable `mutationStateRetention.maxTerminal`
942
+ bound (default 200), evicted oldest-settled-first, so a mutation storm cannot
943
+ grow the map without limit — the journal stays the durable source of truth
944
+ for pending mutations.
945
+
946
+ Exposed at every layer:
947
+
948
+ - `ClientEngine.mutationStates: Stream<MutationStateMap>` and `MutationState.ts`'s
949
+ pure fold (`seed`/`journaled`/`acknowledged`/`rejected`).
950
+ - `ClientView`/`Client` gain `mutationStates`, so it works identically over an
951
+ owned engine and a connected mirror (ADR-0021) — a minimal mirror-protocol
952
+ extension (`SubscribeMutationStates`/`UnsubscribeMutationStates` mirror→host,
953
+ `MutationStatesUpdate` host→mirrors) broadcasts the whole map, refcounted
954
+ like a subset subscription.
955
+ - `EngineAtoms.mutationStates` (the whole map) and `EngineAtoms.mutationsFor(mutator)`
956
+ (a derived per-mutator selector).
957
+ - React `useMutationState(mutator?)` — always a plain array, oldest-journaled
958
+ first, no `AsyncResult` handling needed.
959
+ - Svelte `useMutationState(mutator?)` rune (`@thomasfosterau/legation-svelte`), reactive
960
+ to a `mutator` thunk like `useQuery`'s `read`.
961
+
962
+ The todo example's journal panel is written over this surface,
963
+ replacing its bespoke `Introspection`-based wiring, and demonstrates bounded
964
+ terminal retention.
965
+
966
+ - 8c65627: Form actions run named Mutations (ADR-0030, amendment #215).
967
+
968
+ `@thomasfosterau/legation-svelte/kit` adds:
969
+
970
+ - `mintFormClient` for the page that renders a form (a fresh form client id
971
+ with mutation id `1`, rendered in the `FORM_CLIENT_FIELD` and
972
+ `FORM_MUTATION_FIELD` hidden fields);
973
+ - `formMutationTarget`, which reads the client group from its cookie (never
974
+ minted there) and the form client from the posted fields;
975
+ - `runFormMutation`, which hands the form layer's decoded args to
976
+ `server.mutate` and answers the receipt the action returns under
977
+ `RECEIPT_KEY`;
978
+ - `enhanceFormMutation`, the enhanced submit for `use:enhance`. Each submit
979
+ mints a fresh form client, overlays the Mutation's effect, and settles on
980
+ the receipt. SvelteKit's post-action `load` re-run is off by default.
981
+
982
+ An action without the client-group cookie stays an out-of-band writer. The
983
+ client-group cookie's default name is now exported as `CLIENT_GROUP_COOKIE`.
984
+
985
+ `@thomasfosterau/legation` adds the enhanced submit's client half:
986
+
987
+ - `ClientEngine.beginFormMutation` and `Client.beginFormMutation` apply a
988
+ Mutation's local handler to the overlay under a form client's id without
989
+ journaling it, and return a submission to settle on the action's receipt.
990
+ - An `acknowledged` receipt moves the entry to the unpulled tail. The pull
991
+ whose `lastMutationIds` covers the form client releases it, as it does a
992
+ pushed mutation.
993
+ - A `rejected` receipt drops the entry, and `abandon` drops it when the action
994
+ did not answer. Either way a pull is signalled.
995
+
996
+ The entry holds a place in the tail's bound from the start, and only in
997
+ memory. Over a connected host (a mirror) `Client.beginFormMutation` fails with
998
+ a `TransportError`.
999
+
1000
+ - d7cafd2: Add the `@thomasfosterau/legation/sql` entry point: the driver-agnostic SQL reference backend (`Sql`, `SqlBackend`, `PgExecutor`, `PollingHintSource`), merged in from the former `@thomasfosterau/legation-sql` package, which was never published (ADR-0033). It needs only `effect`, and the root and `./internal` entries don't load it. Import these modules from `@thomasfosterau/legation/sql`.
1001
+
1002
+ `@thomasfosterau/legation-node` is gone (also never published): no consumer runs a Node server. Its `HttpSyncServer` layers were `effect/rpc`'s `RpcServer.layer(RpcTransport.SyncRpcs)` over `RpcTransport.layerHandlers` and an HTTP or WebSocket protocol with NDJSON framing; an app serving the sync RPCs from Node composes those itself (`examples/commerce/server/carriers.ts`).
1003
+
1004
+ - c47786c: `@legation/sveltekit` is merged into `@thomasfosterau/legation-svelte` as the
1005
+ `@thomasfosterau/legation-svelte/kit` subpath, and `Hydration` moves to the public
1006
+ `@thomasfosterau/legation` surface.
1007
+
1008
+ The split was never a dependency boundary: the SvelteKit package contained no
1009
+ Svelte code, and its whole `@sveltejs/kit` coupling was two `import type
1010
+ { Cookies }` statements — type-only, erased at build. `@sveltejs/kit` is now an
1011
+ **optional** peer dependency of `@thomasfosterau/legation-svelte`, so a non-Kit Svelte app
1012
+ pulls nothing extra, and only `@thomasfosterau/legation-svelte/kit` needs it.
1013
+
1014
+ The bindings shrink to one surface, on one entry point. The `Readable`-store
1015
+ tier (`Query.query` / `namedQuery` / `aggregate` / `related`,
1016
+ `Mutation.getMutate`, `undoControls`, and `Runtime.setEngineRuntime`) is
1017
+ removed: it duplicated the rune hooks, and the package's own `svelte >= 5.40`
1018
+ peer already ruled out the Svelte 4 audience it existed for. The hooks move
1019
+ from the `/Runes` subpath to the package root — they are the API, so they are
1020
+ not something to reach for separately — and gain **`useUndo(manager)`**, the
1021
+ rune equivalent of the store-based `undoControls`, so nothing is lost. The
1022
+ root is now a `.svelte.js` module with a `svelte` export condition; the
1023
+ namespaces are gone, so `setClientRuntime` and the hooks are flat named
1024
+ exports.
1025
+
1026
+ Hydration is now a core concept rather than a SvelteKit one. `Hydration` is
1027
+ exported from `@thomasfosterau/legation` (it was behind `/internal`) and speaks
1028
+ `Query.Read` instead of `Subset`: `snapshot({ nodes, reads })` and
1029
+ `rowsFor(snapshot, nodes, read)`. `@thomasfosterau/legation-svelte/kit` keeps only the
1030
+ framework's share — the client-group cookie, a `load`-shaped wrapper, and
1031
+ `clientRuntime`, which replaces `engineRuntime` and returns the `Client`
1032
+ runtime `setClientRuntime` expects (built on `Client.make`, so it starts the
1033
+ sync loop itself).
1034
+
1035
+ Migration: `@thomasfosterau/legation-svelte/Runes` → `@thomasfosterau/legation-svelte`;
1036
+ `Runtime.setClientRuntime` → `setClientRuntime`;
1037
+ `@legation/sveltekit` → `@thomasfosterau/legation-svelte/kit`;
1038
+ `engineRuntime({ engine: {...} })` → `clientRuntime({ nodes, mutations, ... })`;
1039
+ `setEngineRuntime` → `setClientRuntime`; `query(subset)` → `useQuery(() => read)`;
1040
+ `getMutate()` → `useMutation()`; `undoControls(m)` → `useUndo(m)`;
1041
+ `Hydration.rowsFor(snapshot, subset)` → `Hydration.rowsFor(snapshot, nodes, read)`.
1042
+
1043
+ - 501b12c: The mirror seam sheds its browser-tab coupling: the party served by
1044
+ `EngineHost` is any client context — a browser tab, an Electron window,
1045
+ an extension, a CLI instance — so the vocabulary now says what the
1046
+ contract means. `TabMirror` is `Mirror` (module, namespace, and type),
1047
+ `tabId` is `mirrorId` (wire field included), `TabMessage` is
1048
+ `MirrorMessage` (`decodeTabMessage` → `decodeMirrorMessage`), the host's
1049
+ liveness option `tabTimeout` is `mirrorTimeout`, and
1050
+ `MirrorChannel.makeLoopbackPair` returns `{ host, mirror }`. Clean
1051
+ break, no aliases; `@thomasfosterau/legation-browser` re-exports the new names.
1052
+ - 7f1efac: Named queries and auto-subscribing relational reads cross the mirror
1053
+ protocol (ADR-0021 follow-up). `ClientView` gains
1054
+ `subscribeQuery`/`unsubscribeQuery`/`queryNamed`/`observeNamed` and
1055
+ `related` — both hosts satisfy the widened seam (`ClientViewError` now
1056
+ includes `QueryError`). The tab↔host protocol adds
1057
+ `SubscribeNamed`/`UnsubscribeNamed` (the host engine owns the server
1058
+ round-trip) and `SubscribeRelated`/`UnsubscribeRelated`, where the host runs
1059
+ the reconcile loop AND the join and the tab receives resolved rows
1060
+ (`RelatedUpdate`) — data work stays off the main thread. `EngineHost` takes
1061
+ an optional `registry` for relational resolution and refcounts all
1062
+ registration kinds per tab. A new `HostReady` announcement makes
1063
+ registrations survive host restarts: a starting host broadcasts it and
1064
+ every mirror replays the registrations it holds — covering killed service
1065
+ workers and tabs that raced the worker's boot. On a mirror, observing IS
1066
+ subscribing: `TabMirror.observe`/`observeNamed`/`related` auto-register
1067
+ their subject with the host for the stream's lifetime through a per-key
1068
+ interest ledger (a mirror only carries keys some tab registered, and the
1069
+ host refcounts per tab, so the ledger posts `Subscribe` on first interest
1070
+ and `Unsubscribe` only when the last observer ends and no explicit
1071
+ `subscribe` pins the key). In `@thomasfosterau/legation-svelte`, `namedQuery`,
1072
+ `useNamedQuery`, and `useRelated({ subscribe: true })` now work over
1073
+ mirror-only runtimes (the internal engine-lookup fallback is removed).
1074
+ - cf0b26c: The mirror seam moves to core, where ADR-0006's dependency rule places it:
1075
+ `MirrorProtocol`, the `MirrorChannel` interface + loopback pair,
1076
+ `EngineHost`, and `TabMirror` are exported from `@thomasfosterau/legation` — none
1077
+ of them needs a browser global, so Electron windows, extensions, and CLI
1078
+ processes can host or consume mirrors without `@thomasfosterau/legation-browser`. The
1079
+ browser package keeps what is actually browser-coupled — the new
1080
+ `BroadcastMirrorChannel.make(name)` binding (formerly
1081
+ `MirrorChannel.makeBroadcastChannel`) and `ServiceWorkerHost` — and
1082
+ re-exports the moved modules for compatibility.
1083
+ - ddb1fda: Restructure the repository into a monorepo of sync-engine packages, with package boundaries drawn by dependency footprint:
1084
+
1085
+ - `@thomasfosterau/legation` — the Effect-native sync engine. Everything that needs only a dependency on `effect`: sync keys, cache stores, the write-through cache engine (read-through, dedupe, stale-while-revalidate, invalidation, events), queries, pagination, mutations with optimistic updates, a durable offline outbox, the client/server sync protocol, transports, conflict resolution, the TanStack-Query-style client (queries/mutations/subscriptions/offline sync), the service-worker/edge HTTP cache engine with KeyValueStore persistence, the server-side sync hub with fan-out, and promise facades for non-Effect consumers.
1086
+ - `@thomasfosterau/legation-jsonapi` — the previous `@thomasfosterau/effect-chancery` JSON:API helpers, migrated into the monorepo as a protocol adapter with Effect tagged errors and query/mutation descriptor builders that plug into the core engine.
1087
+
1088
+ - f54f568: Per-mutation outcome surface (#36). Callers can now await a mutation's
1089
+ authoritative backend outcome — applied, or permanently rejected with a reason
1090
+ (#35) — rather than only its journal ack.
1091
+
1092
+ - `ClientEngine.mutateWithAck` returns a `MutationHandle` whose `acknowledged`
1093
+ effect resolves with the `MutationOutcome` once the backend settles the
1094
+ mutation via a push or pull acknowledgement. `ClientEngine.mutationEvents`
1095
+ streams the lifecycle (`journaled`, then `acknowledged` or `rejected`). Plain
1096
+ `mutate`/`mutateEncoded` are unchanged and register no deferred; pending
1097
+ acknowledgements are interrupted on engine scope close, so they never hang.
1098
+ - Svelte gains `useMutationWithAck` (over an additive
1099
+ `EngineAtoms.mutationWithAck` atom), returning a promise of the outcome.
1100
+ - The tab mirror forwards the outcome: `MirrorProtocol` gains an additive
1101
+ `MutateSettled` host→tab message, `EngineHost` awaits the backend verdict on a
1102
+ scoped fibre and posts it, and `TabMirror.mutateWithAck` awaits it (the
1103
+ existing journal-ack `mutate` is unchanged).
1104
+
1105
+ - fc880dc: Fix a gap in `ClientEngine.mutationStates` (#116): a mutation compacted out of
1106
+ the journal by a paced strategy (`Paced.ts`'s debounce/throttle/coalesce, via
1107
+ `dropIfPending`) never transitioned off `"pending"` — the entry stayed stuck
1108
+ showing an in-flight mutation forever, even though the journal no longer had
1109
+ it. `MutationPhase` gains a `"dropped"` phase (and a matching
1110
+ `MutationDropped` `EngineEvent`/`MutationEvent`), and `dropIfPending` now folds
1111
+ every compacted id to it, so a mutation-timeline consumer (a devtools journal
1112
+ panel, #120) never shows a phantom pending entry for a compacted mutation.
1113
+ - 9cbd160: The Node/Edge resource model lands (ADR-0022, program step 2): `Node.make`
1114
+ defines a typed resource — attributes plus edges (`Edge.one` with its
1115
+ foreign-key `via`, `Edge.many`/`Edge.paginated` naming their `inverse`,
1116
+ order as part of a paginated edge's identity) — with per-type branded ids
1117
+ (one-edge fields carry the target's brand) and declared `hintKeys`.
1118
+ `Node.registry` closes and validates the graph (targets exist, inverses
1119
+ resolve to a matching one-edge) and derives the engine's
1120
+ `CollectionRegistry`, so existing engines, `Related` joins, and auth
1121
+ filters consume Node definitions today; `Node.derivableSet` exposes the
1122
+ ADR-0023 fan-out fields. Additive — nothing existing changes.
1123
+ - 0bebedf: `LiveView` (the incrementally-maintained live-query result behind paginated
1124
+ queries) no longer rebuilds its ordered set as a copied array on every change
1125
+ (issue #112). The maintained order and its id index are now persistent
1126
+ weight-balanced trees (`OrderedTree`, a new internal module): `put`/`del` cost
1127
+ O(log n) instead of O(n), and reslicing a window is O(log n + window) — a
1128
+ 100k-row maintained set with steady single-row churn no longer pays for a
1129
+ full array copy on every unrelated-row update (see the new
1130
+ `live-view-churn.bench.ts`).
1131
+
1132
+ In-window rows now carry a stable `FractionalIndex` `position`:
1133
+ assigned when a row enters the window, dropped when it leaves, and left
1134
+ untouched when the row didn't move relative to its neighbors — the primitive
1135
+ a future row-level delta feed needs to express a reorder as a move instead of
1136
+ a page diff. `LiveView.retarget` widens a `limit` or advances a `cursor` over
1137
+ the _same_ maintained order, with no re-materialization — the pure half of
1138
+ the "load more widens one window" story (`useConnection`, ADR-0024); wiring
1139
+ this into an open `ClientEngine` subscription so the client never
1140
+ re-registers on "load more" is flagged as future work in `LiveView.ts`.
1141
+
1142
+ `Subset.comparator` now tie-breaks on row id, making it a genuine total order
1143
+ even with an empty `orderBy` (previously every row tied, and `LiveView`'s
1144
+ delta-location degraded to a full scan). Because every backend
1145
+ (`MemoryBackend`, `@thomasfosterau/legation/sql`'s `SqlBackend`,
1146
+ `@thomasfosterau/legation-jsonapi`'s `JsonApiBackend`) already finalizes order/limit/cursor
1147
+ locally via `Subset.apply`, this same comparator is the sole ordering
1148
+ authority for both client and server — a subset with no declared `orderBy`
1149
+ now sorts by id ascending everywhere instead of an unspecified order. The
1150
+ collation decision (why this rules out a client-local collation option, and
1151
+ what a real one would require) is recorded in
1152
+ `docs/adr/0026-subset-ordering-and-collation.md`.
1153
+
1154
+ - b4d2b80: Paced mutations (#117): `Paced.debounce`/`Paced.throttle`/`Paced.coalesce`
1155
+ (`@thomasfosterau/legation/internal`) pace rapid mutation intents — a text field
1156
+ autosaving per keystroke, a slider, a cart-quantity stepper — into a handful
1157
+ of journal entries instead of one per call, built entirely on
1158
+ `ClientEngine.mutateWithAck` and Effect's own timing primitives (no new
1159
+ dependency, no general-purpose pacer module).
1160
+
1161
+ Design: every call always journals immediately through the ordinary
1162
+ `mutateWithAck` path (full durability, the usual optimistic overlay render),
1163
+ then a compaction step drops the caller's own previous still-pending entry
1164
+ for the same key via the new `ClientEngine.dropIfPending`. `debounce`/
1165
+ `throttle` additionally buffer intermediate calls in memory between a leading
1166
+ and a trailing/periodic flush, so a 50-call burst produces at most a couple of
1167
+ journal writes — the tradeoff being that the buffered args between flushes
1168
+ are lost on a crash inside the window (`coalesce` has no such window: every
1169
+ call is durable, at the cost of short-lived extra entries before the next
1170
+ call's compaction).
1171
+
1172
+ `dropIfPending` never touches an entry a `push` may already have sent:
1173
+ `ClientStore.compactPending` (a new store primitive, symmetric to
1174
+ `acknowledge` but by explicit id set) only ever removes ids the engine has confirmed are
1175
+ still in its pending set with no push currently in flight — the ADR-0008 hard
1176
+ boundary (never mutate or reorder a pushed journal entry) is enforced inside
1177
+ the engine, not trusted to callers.
1178
+
1179
+ - 2201b4c: Paginated-edge sync windows (ADR-0024, ADR-0022 program step 5): an included paginated edge's `first` now registers one windowed subset per parent row — `(foreignField = parent, edge order, limit)` — so the replica syncs the window instead of the whole dependent collection. `Collection.Relationship` gains the `"paginated"` kind with its declared order (derived from `Edge.paginated` by `Node.registry`), relational reads select edges as `{ name, first? }` (bare names still accepted), and the mirror protocol's related registrations carry the window (`SubscribeRelated.edges` replaces `names`). Windows follow parents entering and leaving the root window; displaced rows leave the replica through ordinary CVR union diffs.
1180
+ - 0cdefe7: Per-row and per-mutation durable storage for the KeyValueStore-backed `ClientStore`. Confirmed rows now persist one key each at `row/{collection}/{id}` behind a small `rowIndex/{collection}` id-array, and pending mutations one key each at `journal/{clientId}/{mutationId}` behind a compact ordered `journalIndex`. A `putRow`/`appendMutation` rewrites a single value plus a tiny index instead of the whole collection or journal, and `acknowledge` drops acked mutation bodies without rewriting the survivors.
1181
+
1182
+ `ClientStore` gains a batched `applyRowOps`, through which the engine's pull path now routes: a patch is grouped by collection so each collection's id-index is rewritten at most once per patch and each row value written once — patch-apply value I/O scales with patch size, not collection size (a checked-in `bun run bench` counts the KVS writes to demonstrate it).
1183
+
1184
+ A store opened at the previous whole-blob layout is migrated forward transparently on first open (rows, journal, counters, cookie split losslessly), stamped with a `__layout` version key; a corrupt legacy blob falls back to clearing rows and the cookie so the client re-pulls. A new `journalHighWater` option (KVS-only) makes `appendMutation` fail with a clear `StoreError` rather than ever silently dropping a mutation intent; the failure surfaces through `mutate`.
1185
+
1186
+ - 66f3b5b: Add `PgExecutor`, the `SqlExecutor` for PostgreSQL (#203). `PgExecutor.make(sql)`
1187
+ runs `SqlBackend` over any PostgreSQL `SqlClient` from `effect/sql` (provided by
1188
+ a driver such as `@effect/sql-pg`), with `dialect: Sql.postgres`. Each push is one
1189
+ `withTransaction` at SERIALIZABLE, so a mutation's id claim commits with its
1190
+ handler's writes; statements run unprepared, so nothing depends on a named
1191
+ prepared statement surviving behind a transaction-mode pooler; and a failure is a
1192
+ `BackendError` carrying the driver's `SqlError`. The package gains no dependency:
1193
+ `effect/sql` ships in the `effect` peer. The README's new PostgreSQL section
1194
+ covers the executor's round trips and what it needs from a pooler such as
1195
+ Hyperdrive.
1196
+ - 7e11e61: Permanent mutation rejection semantics (#35). A mutator handler can now fail
1197
+ with the new `MutationRejected` error to mark a failure permanent (failed
1198
+ validation, revoked permission, violated business rule). The backend records
1199
+ the mutation as processed — bumps `lastMutationId` atomically **without**
1200
+ applying effects — so a poison mutation is acknowledged and skipped rather than
1201
+ wedging the queue: mutations behind it still apply, and the client drops the
1202
+ optimistic effect on the next rebase with no app code. A `BackendError` keeps
1203
+ today's transient behaviour (abort the batch, client retries).
1204
+
1205
+ - `Backend.ApplyResult` gains an optional `rejected` list; `PushResponse`
1206
+ gains an additive optional `rejected` field (older clients ignore it, and the
1207
+ per-mutation outcome surface #36 reads it).
1208
+ - `MemoryBackend` rolls a rejected mutation's staged writes back (snapshot),
1209
+ `SqlBackend` via a per-mutation `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`, and
1210
+ `JsonApiBackend` classifies HTTP 4xx (except 408/429) as permanent rejections.
1211
+ - `BackendContract.verifyBackendContract` gains a rule covering both failure
1212
+ classes.
1213
+ - `CONTEXT.md` documents the "transient failure" vs "permanent rejection"
1214
+ glossary entries.
1215
+
1216
+ - 6175e38: Predicate-aware fan-out matching. A committed write now pokes a client group
1217
+ only when some hinted change plausibly affects one of its subscriptions,
1218
+ rather than every group whose subscriptions touch the hinted collection.
1219
+
1220
+ Change hints gain an optional, wire-compatible `values` field carrying the
1221
+ current row values of the PUTs in a change (`MemoryBackend`, `SqlBackend` and
1222
+ `JsonApiBackend` populate it best-effort; a backend that can't cheaply produce
1223
+ a value omits it). An old, value-less hint decodes and behaves exactly as
1224
+ before. The `SyncServer` fan-out loop matches a value-carrying PUT against the
1225
+ subscription's predicate, a delete against the group's last-synced CVR, and
1226
+ falls back to the collection-level match for value-less hints and `Exists`
1227
+ predicates (soundness first — over-matching is safe, under-matching would
1228
+ delay convergence). A new `legation_pokes_suppressed_total` metric counts the
1229
+ pokes predicate matching avoids.
1230
+
1231
+ - caf4761: `Predicate` evaluates under Postgres's three-valued semantics (ADR-0028,
1232
+ #155). This is a behaviour change for rows whose filtered field is `null` or
1233
+ absent.
1234
+
1235
+ - `Predicate.evaluate(predicate, row)` returns `Option<boolean>`: `Some(true)`,
1236
+ `Some(false)`, or `None` for unknown, with Kleene `And`/`Or`/`Not`. An
1237
+ absent attribute is NULL. `Predicate.test` is now
1238
+ `Option.getOrElse(evaluate(...), () => false)`, so every caller keeps its
1239
+ boolean signature but a row matches only when the result is definitely
1240
+ true. Rows with a NULL or absent field therefore stop matching `ne`,
1241
+ `Not(eq)`, a `Not(In)` miss and `NotIn`, exactly as they do in SQL. An app
1242
+ that relied on `ne` returning NULL rows must add an explicit `isNull`
1243
+ disjunct.
1244
+ - Two wire-additive nodes: `IsNull { field, negated? }` and
1245
+ `NotIn { field, values }`, with constructors `Predicate.isNull`,
1246
+ `Predicate.isNotNull` and `Predicate.notIn` (also on the typed
1247
+ `Subset.for` builder). `Predicate.eq(f, null)` desugars to `isNull(f)` and
1248
+ `Predicate.ne(f, null)` to `isNotNull(f)`, so existing call sites keep
1249
+ their meaning — and `eq(f, null)` now also selects rows where the field is
1250
+ absent. A wire `Compare` or `In` carrying a `null` literal evaluates to
1251
+ unknown, as in Postgres; that form is deprecated.
1252
+ - Text compares by Unicode code point (`Predicate.compareText`), the order of
1253
+ Postgres `COLLATE "C"` and SQLite `BINARY`, instead of UTF-16 code unit;
1254
+ the two differ only where astral characters meet U+E000–U+FFFF. Booleans
1255
+ order `false < true` under `lt`/`lte`/`gt`/`gte`.
1256
+ - `Subset.comparator` sorts NULL and absent values after every non-null
1257
+ value ascending and before them descending (Postgres's default); it used
1258
+ to sort them first in both directions. The row-id tie-break is a code-point
1259
+ comparison. Existing cursor pages over text or nullable sort keys may
1260
+ re-order once on upgrade.
1261
+ - `CollectionIndex` keeps one bucket for NULL-or-absent that `eq`/`In`/range
1262
+ lookups never return and `IsNull` reads directly; `ne`, `NotIn` and
1263
+ `IS NOT NULL` stay residual conjuncts. Sorted runs use the code-point
1264
+ comparator. The index is proven equal to the full scan over the
1265
+ differential corpus.
1266
+ - `@thomasfosterau/legation/sql`: `Sql.compileWhere` compiles `IsNull` to `IS NULL` /
1267
+ `IS NOT NULL` and `NotIn` to `NOT IN (…)` (empty list → `1 = 1`). The
1268
+ `BackendContract` corpus now generates NULL, absent and astral text.
1269
+
1270
+ - 49fd066: A pull now carries the permanent rejections its watermark covers, so a client whose push response was lost settles the rejected mutation as rejected, not applied (#365). Before, the pull's watermark acknowledged the rejected id as applied, and its effect was dropped with no way to learn the outcome.
1271
+
1272
+ `PullResponse` gains an optional `rejected` list (`{ clientId, mutationId, reason }`), as `PushResponse` already has (#355). The client settles those entries before the watermark acknowledges them. The field is additive: an older client ignores it, and a pull from an older server omits it, so no protocol version changes.
1273
+
1274
+ `Backend` gains a required `rejections(context)` method, which lists the group's remembered rejections with their reasons (Backend guarantee 4). A custom backend must implement it before it upgrades. `MemoryBackend`, `SqlBackend` and `JsonApiBackend` already remember their rejections and implement it. `BackendContract` rule 13 checks that the list agrees with the reported outcome.
1275
+
1276
+ - 06405ed: A covered read is answered from the replica only once the windows its rows link to have been pulled (#426). A window is pulled once a pull that started after it was held has applied, or once an already-pulled window covers it. Each window is marked on its own, so a new edge window does not reset the windows already pulled. A read needs the edge windows its root rows link to, and an unpulled window of a sibling key does not uncover a read that links only to other keys. A relational registration whose root is pulled but whose edge windows are not yet held covers nothing. Before its windows are pulled, `Client.query` falls through to the server (offline, it answers locally, as for any uncovered read), and the JSON:API façade forwards the request online and answers `503` offline.
1277
+
1278
+ The pull state is recomputed, not persisted: after a restart each window starts unpulled until a pull lands. `ClientEngine` gains a read-only `pullCounts`; `ClientView.pulled(key)` answers with a per-window summary (`Coverage.Pulled`); `Coverage` gains `pulledFor` and `windowKey`; the mirror protocol gains a host message, `PulledRegistrations`, which an older mirror ignores (it is an addition, and replaces no message). A host now pulls promptly after a plain subscribe, as it already did for named queries.
1279
+
1280
+ - a1cea1c: Share the rejection-memory forgetting bound. `floorsOf` (a push's lowest mutation
1281
+ id per client) and `forgetBelow` (drop a client's remembered rejections below its
1282
+ floor) now live in a new `RejectionMemory` module, exported on
1283
+ `@thomasfosterau/legation/internal` for adapter authors. `MemoryBackend` and
1284
+ `JsonApiBackend` bound their rejection memory with `forgetBelow`; `SqlBackend` shares
1285
+ only `floorsOf` and keeps its own forgetting loop. No behaviour changes.
1286
+ - 7e94abc: `Related.observeWith` now propagates row deltas through the join instead of
1287
+ re-grouping whole collections (issue #111, part of the #108 IVM umbrella): a
1288
+ foreign-row change updates only the one or two `fk → rows` buckets its old and
1289
+ new key select and re-hydrates only the base rows keyed to those buckets,
1290
+ instead of rebuilding the collection's whole group index and re-hydrating the
1291
+ full base page. A base-row change re-hydrates only that row. A linkage shift
1292
+ (which foreign collections are targeted) diffs the targeted set against the
1293
+ previous one — the same diff-and-release shape `reconcile` already uses for
1294
+ subscriptions — and starts/stops only the newly (un)targeted collections'
1295
+ observers, instead of tearing every observer down and re-priming from scratch.
1296
+ The public API (`related`/`observeWith`/`relatedWith`) and ADR-0024
1297
+ auto-subscription reconciliation are unchanged. The new `Related.makeJoinEngine`
1298
+ is the pure, Effect-free incremental engine behind this, exported so its
1299
+ equivalence to a from-scratch hydrate (`Related.hydrateFromScratch`) can be
1300
+ property-tested directly.
1301
+ - 9d2f21b: Rename the project to **Legation**. Every package moves to the `@legation`
1302
+ scope: the engine is now `@legation/effect` (formerly
1303
+ `@thomasfosterau/effect-sync-core`), and the adapters/bindings are
1304
+ `@legation/browser`, `@legation/node`, `@legation/jsonapi`, `@legation/sql`,
1305
+ and `@legation/svelte` (formerly `@thomasfosterau/effect-sync-*`).
1306
+
1307
+ Runtime identifiers follow the brand, which is **breaking for persisted local
1308
+ state and dashboards**:
1309
+
1310
+ - `Context.Service` identifiers: `@thomasfosterau/effect-sync-core/*` →
1311
+ `@legation/effect/*` (and per-package equivalents)
1312
+ - Client-store key prefix: `effect-sync/` → `legation/`; CVR store prefix:
1313
+ `effect-sync/cvr/` → `legation/cvr/`. Existing IndexedDB/KV state under the
1314
+ old prefix is orphaned — clients rehydrate via a full pull; export any
1315
+ pending journal before upgrading offline clients.
1316
+ - Metrics: `effect_sync_*` → `legation_*`; spans: `effect-sync.*` →
1317
+ `legation.*`
1318
+ - Default BroadcastChannel name: `effect-sync` → `legation`; Redis hint
1319
+ stream: `effect-sync:hints` → `legation:hints`; cache metadata header:
1320
+ `x-effect-sync-cache-meta` → `x-legation-cache-meta`
1321
+
1322
+ - 3016170: Retry/backoff policies for the sync loop (#34). `ClientEngineOptions.retry`
1323
+ takes per-leg `Schedule`s for `push`, `pull`, and the `pokes` stream; unset
1324
+ legs use `defaultRetryPolicy` (exponential from 1s, factor 2, delay capped at
1325
+ 30s, bounded to 5 retries — mirroring TanStack Query's 1s→30s, no jitter so it
1326
+ composes predictably).
1327
+
1328
+ `runSync` now applies the policy instead of swallowing failures: only
1329
+ `TransportError` is retried (`StoreError`/`MutatorError` are not transient and
1330
+ propagate immediately), and the poke stream re-establishes with backoff via
1331
+ `Stream.retry`. Terminal failures after policy exhaustion are surfaced on a new
1332
+ `ClientEngine.syncFailures` stream and counted in
1333
+ `Telemetry.syncFailuresTotal`, so bindings can show a "sync degraded" state.
1334
+
1335
+ The retry policy composes with the #33 scheduler: the scheduler decides when to
1336
+ attempt, the policy decides how an attempt survives transient failure.
1337
+
1338
+ - f4a1a60: Schema constants are named after the domain type they decode, with a
1339
+ same-named type beside each: `Protocol.MutationSchema` is now
1340
+ `Protocol.Mutation` (value and type), and likewise `PushRequest`,
1341
+ `PushResponse`, `RejectedMutation`, `PatchOp`, `NamedSubscription`,
1342
+ `PullRequest`, `PullResponse`, `ReadRequest`, `ReadResponse` and `Poke`;
1343
+ `Subset.Subset` and `Subset.OrderBy`; `ClientStore.StoredRow`;
1344
+ `Hydration.Snapshot` and `Hydration.SnapshotRow`; `Cvr.WireCvr`;
1345
+ `MirrorProtocol.EdgeSelect` and `MirrorProtocol.MutationStateEntry` (was
1346
+ `MutationStateSchema`); the JSON:API `JsonApiSchema.Links`, `LinkObject`,
1347
+ `ResourceIdentifier`, `Relationship`, `ResourceObject`, `JsonApiError` and
1348
+ `JsonApiDocument`; and the Cloudflare `RoomProtocol.ShardPoke`, `Register` and
1349
+ `Publish`. `Derive.CreateArgs` / `UpdateArgs` / `DeleteArgs`, the room
1350
+ protocol's bodies and `Hydration.SnapshotFromJson` gain their matching types.
1351
+ - c671ccf: Schema-version negotiation and fleet cookie-reset (#48) — the runtime mechanism
1352
+ for ADR-0014's schema-evolution policy (ADR-0018). ADR-0014 stays the policy;
1353
+ this ships the hooks.
1354
+
1355
+ - **Protocol.** `PushRequest`/`PullRequest` gain an optional `schemaVersion`.
1356
+ Additive and wire-compatible: an old client omits it and still decodes, a new
1357
+ server tolerates its absence.
1358
+ - **Server acceptor.** `SyncServer.makeWith` / `layerWith` take
1359
+ `acceptSchemaVersion?`; a rejected version fails `handlePush`/`handlePull` up
1360
+ front with a typed `VersionNotSupportedError` — before any mutation is applied
1361
+ or patch computed, so the journal is never poisoned. Distinct from a
1362
+ `TransportError` across the in-memory transport seam (the RPC carrier degrades
1363
+ it to a `TransportError`, noted).
1364
+ - **Client hook.** `ClientEngineOptions` gain `schemaVersion?` (sent on every
1365
+ push/pull) and `onUpdateNeeded?`, fired exactly once per ongoing rejection (a
1366
+ latch that resets on the next successful request), alongside an `UpdateNeeded`
1367
+ engine event. Pending mutations are retained across a rejection.
1368
+ - **Local `storeVersion` buster.** `KeyValueOptions.storeVersion?` clears
1369
+ confirmed rows + the cookie on a mismatch (so the next pull rehydrates fully)
1370
+ while preserving the pending journal.
1371
+ - **Fleet reset.** `SyncServer` takes `cookieNamespace?`; bumping it on deploy
1372
+ makes every previously-issued cookie unknown, so each client's next pull is a
1373
+ reset patch (`Clear` + full puts) — one place to reset the fleet.
1374
+
1375
+ - 16889f0: Rename every package from the `@legation/*` scope to
1376
+ `@thomasfosterau/legation-*` (#303): `@legation/effect` is now
1377
+ `@thomasfosterau/legation`, and likewise for `browser`, `cloudflare`,
1378
+ `devtools`, `jsonapi`, `node`, `sql` and `svelte`. Subpath entries keep their
1379
+ paths (`@thomasfosterau/legation/internal`,
1380
+ `@thomasfosterau/legation-svelte/kit`,
1381
+ `@thomasfosterau/legation-devtools/svelte`). Update imports and dependency
1382
+ names accordingly; no `@legation/*` name was ever published to npm.
1383
+
1384
+ `Context.Service` identifiers and `Symbol.for` keys follow the package name
1385
+ (`@legation/effect/Client` → `@thomasfosterau/legation/Client`, and
1386
+ per-package equivalents). The reserved identity sentinels
1387
+ (`@legation/anonymous`, `@legation/unstamped`), the `legation/` client-store
1388
+ prefixes, metric and span names are unchanged.
1389
+
1390
+ - fb2a992: Add secondary field-value indexes over the local store (#113): `eq`/`In`
1391
+ predicates resolve via an in-memory hash index and range `Compare`s via an
1392
+ ordered index, instead of a full collection scan, in `ClientEngine.query`,
1393
+ live-query rebuilds (`observe`, seq-gap collapse), and `Related`'s foreign-index
1394
+ priming (which now also narrows its query to the pinned `foreignField IN (...)`
1395
+ values instead of reading the whole foreign collection).
1396
+
1397
+ Declare indexed fields on a `Node`/`Collection` (`indexes: ["done", "authorId"]`,
1398
+ mirroring `hintKeys`), or pass `autoIndex: true` to `Client.make`/
1399
+ `ClientEngine.make` to auto-discover indexable fields from registered subsets'
1400
+ predicates. Both are opt-in — zero-config behavior is unchanged, and any
1401
+ predicate the index can't narrow (`Or`, `Not`, `ne`, non-indexed fields) falls
1402
+ back to today's full scan. Indexes are in-memory only, maintained from
1403
+ confirmed-row patch deltas and rebuilt lazily from the store; they are used
1404
+ only while a collection's journal is empty (a pending optimistic mutation
1405
+ falls back to a full scan for correctness).
1406
+
1407
+ A benchmark (`secondary-index.bench.ts`) shows a ~25-30x speedup for a
1408
+ selective point predicate (1/100th match rate) over a 50k-row collection.
1409
+ Memory overhead is roughly 300-400 bytes per indexed row per declared field
1410
+ (measured via heap snapshots), i.e. ~15-20MB for a 50k-row collection with one
1411
+ declared index.
1412
+
1413
+ - 652ae7b: The publish of a push's change hints is bounded for every broker now. `makeSerialized` (and `layerSerialized`) bounds a custom `BusBroker`'s `publish` with a new `timeout` option, default 5 seconds, so a hung broker can no longer hold a push's commit-and-publish region open. A timed-out publish is logged and dropped, like the other bus failures. The `FanoutBus` `timeout` option now also documents that a `publish` makes two bounded calls (the broker publish, then a lease registration), and that, on Durable Objects, one broker publish is many shard calls under one bound. The Cloudflare `Room` gains a `publishTimeout` option (default 5 seconds) that bounds its shard calls and lease registrations.
1414
+ - 83bcf76: The Server facade (ADR-0022, program step 7): `Server.make({ backend,
1415
+ auth, queries?, middleware?, cvr?, bus? })` builds the one server-side
1416
+ object — the protocol handler (push/pull with CVR diffing, mount it on
1417
+ any router or carrier) plus `server.broadcast({ collection, ids? })`, the
1418
+ single verb for "these resources changed". Pushed mutations broadcast
1419
+ implicitly through the push path's hints; `broadcast` covers out-of-band
1420
+ writers (cron, admin tools, anything bypassing mutations — ADR-0019 in
1421
+ one line). CVR storage and the fan-out bus default to in-memory /
1422
+ in-process and swap for scale-out (ADR-0011) without app-code changes;
1423
+ ADR-0023's topic-derived fan-out lands behind `broadcast` without
1424
+ changing its signature. Additive.
1425
+ - 20e57d3: Add `server.mutate` (ADR-0030, amendment #215): run one named Mutation
1426
+ server-side as one of a client group's mutations, the entry point a form
1427
+ action calls. It takes the client group, a form client id with its mutation id
1428
+ (`1` for a freshly rendered form), the Mutation and its decoded args, and takes
1429
+ the push path minus the transport: identity is resolved through the
1430
+ `IdentityResolver`, the middleware's `onPush` runs, the backend applies the
1431
+ mutation with its id bumped in the same transaction as its effects, and the
1432
+ write is broadcast. It answers a `Server.Receipt`,
1433
+ `{ clientId, mutationId, phase, reason? }`, with `phase` `acknowledged` or
1434
+ `rejected` with the backend's reason. A resubmission of the same
1435
+ `(clientId, mutationId)` is skipped by the ledger and answers the original
1436
+ ruling. Args that do not encode against the Mutation's schema are a defect of
1437
+ the form layer's hand-over, not a field error.
1438
+ - f010749: Breaking: an auth filter now returns a `ReadScope` (ADR-0015's #205 amendment). The three outcomes are `Authorization.AllowAll`, `Authorization.Deny` and `Authorization.filter(predicate, sets)`. A bare `Predicate` is still accepted and means a filter with no named sets. `Authorization.allowAll` now returns `AllowAll` rather than an always-true predicate. Code that calls an `AuthFilter` itself must handle the wider return type (`Authorization.scopeOf` normalises it).
1439
+
1440
+ New: named, session-resolved sets. A predicate refers to one by name with `Predicate.inSet(field, name)`. The set is declared as an `Authorization.NamedSet` with a `source` collection, a `resolve` function and a `maxAge`. The sync server binds each set per session and caches it. A write to the set's source marks it stale and pokes the session, and a set older than its `maxAge` is resolved again on the next pull. Each per-session cookie carries a scope epoch, a hash of the bound sets, so a session whose set changed resets on its next pull. A set that cannot be resolved binds to nothing: it narrows, and never widens. An undeclared collection stays denied.
1441
+
1442
+ - e40c73a: `@thomasfosterau/legation/sql`'s compilers are exact on Postgres and SQLite (ADR-0028 §4 and
1443
+ §6, #156): every predicate node compiles to a SQL boolean expression whose
1444
+ three-valued result per row equals `Predicate.evaluate`, so the `WHERE`
1445
+ returns precisely the rows the JS evaluator returns `Some(true)` for. The
1446
+ differential harness proves it against a real Postgres (PGlite) and SQLite
1447
+ over the whole generated corpus, set and per-row verdict both. `Sql.ts` no
1448
+ longer describes itself as a superset pre-filter.
1449
+
1450
+ - **Literal kinds.** `Sql.compileWhere` and `Sql.compileSelect` take an
1451
+ optional `kinds` map (field → `Predicate.LiteralKind`: `text`, `integer`,
1452
+ `double`, `boolean`, `instant`) and a `foreignKinds` map (collection →
1453
+ kinds) for the inner `where` of an `Exists`. `Dialect` gains
1454
+ `textCollation` and `boolean`.
1455
+ - **Collation clause.** A `Compare`, `In` or `NotIn` on a field declared
1456
+ `text` carries an explicit code-point collation on the column side:
1457
+ `"col" COLLATE "C" = $1` on Postgres, `"col" COLLATE BINARY = ?` on
1458
+ SQLite. The clause requires the declaration — a string literal alone never
1459
+ collates, since it may be bound against a `uuid`, `date`, enum or
1460
+ `timestamptz` column. The schema rule stands: declare filterable and
1461
+ sortable text columns `COLLATE "C"` (Postgres) or leave them `BINARY`
1462
+ (SQLite).
1463
+ - **Typed binds.** A literal binds by the declared kind: strings for `text`
1464
+ and `instant`, numbers for `integer` and `double`, booleans per dialect. A
1465
+ literal of another JS type is a cross-type comparison (rule (d)) and binds
1466
+ as `NULL`, so its verdict is unknown exactly as in `Predicate.evaluate`
1467
+ and SQLite's affinity coercion never sees it; in a list such literals are
1468
+ dropped.
1469
+ - **SQLite booleans are `0`/`1`.** The SQLite dialect binds boolean literals
1470
+ as `1`/`0` (the `node:sqlite` driver refuses a raw boolean), and
1471
+ `SqlBackend` decodes `0`/`1` back to booleans for columns whose new
1472
+ `TableMapping.kinds` entry says `boolean`; other columns stay exactly as
1473
+ the driver returns them. `SqlBackend` resolves an `Exists` like
1474
+ `MemoryBackend` does: NULL foreign values are left out and the rest are
1475
+ decoded by kind.
1476
+ - `@thomasfosterau/legation`: `Predicate.LiteralKind` is the one home of the kind
1477
+ vocabulary; `FilterCorpus.Column.kind` uses it and the corpus's `"string"`
1478
+ kind is renamed `"text"` to match ADR-0028.
1479
+
1480
+ - ca38874: `evaluateMany` on PostgreSQL now recognises every number its JSON rows cannot
1481
+ carry exactly, not only integers past ±(2^53 − 1): a `numeric` with more digits
1482
+ than a double holds, and such a number inside a `json` / `jsonb` column. A pull
1483
+ holding one falls back to one `evaluate` per subset, as a wide integer already
1484
+ did, and the backend logs a warning once per collection and column, whose cause
1485
+ is the new `SqlBackend.InexactJsonNumber` (it names the collection and column,
1486
+ never the value). The README now says integer, double and text values belong
1487
+ in typed columns (`integer`, `double precision`, `text`) rather than JSON.
1488
+ - 27e1040: Action required: an executor without transactions must set `transactional: false`. The new optional `SqlExecutor.transactional` flag defaults to `true`, and with it `SqlBackend` now claims each mutation in the client's ledger row before its handler runs. An executor without transactions that leaves the default gets that claim-first order, where a crash can leave an id claimed and unapplied and the retry skips it. Set `transactional: false` to keep the handler-then-bump order.
1489
+
1490
+ `SqlBackend` claims each mutation with a conditional update from `id - 1` to `id`, instead of reading the ledger, running the handler and then bumping it unconditionally. Overlapping calls for one mutation now apply it once, and the watermark can no longer move backwards under READ COMMITTED (PostgreSQL's default). A permanent rejection still keeps the claim, and a transient failure gives it back. On the handler-then-bump order the bump takes the larger id, so a late call cannot move the watermark back. `Sql.Dialect` gains `greatest` for that bump. See the module docs for each dialect.
1491
+
1492
+ - e68eb3e: New subpath `@thomasfosterau/legation/sql`: a driver-agnostic SQL reference
1493
+ backend. `Sql` translates a `Subset` predicate to a parameterized `WHERE`
1494
+ (SQLite `?` and PostgreSQL `$n` dialects); `SqlBackend` implements the core
1495
+ `Backend` contract over a minimal `SqlExecutor` seam (`query`/`run`/
1496
+ `transaction`) — subset evaluation, row fetching by id, and a transactional,
1497
+ idempotent, gap-checked mutation-id ledger — leaving the driver (`bun:sqlite`,
1498
+ `effect/sql`, `pg`, …) to the app. Addresses the audit's turnkey-backend
1499
+ gap (#23).
1500
+ - 3be5abc: `SqlBackend` now reports a committed rejection to every copy of the mutation, as Backend guarantee 1 requires (#366). A replayed copy of a rejected mutation lists it in `rejected` with the stored reason, and so does a copy that a concurrent call committed first: on SQLite that copy is listed directly, and on PostgreSQL it is refused with a transient `BackendError` whose retry lists it. Before, such a copy reported nothing, so a client whose push response was lost settled the rejected mutation as applied. The rejection is remembered in the ledger row, in the same transaction as the claim (or, on an executor without transactions, before the bump). A push forgets the rejections below its lowest id per client, as `MemoryBackend` does.
1501
+
1502
+ Action required: this changes the `sync_mutations` ledger table. Run the `ALTER TABLE` for your database before upgrading. A ledger without the column fails each push with a `BackendError` that names this migration, and writes nothing.
1503
+
1504
+ ```sql
1505
+ -- SQLite
1506
+ ALTER TABLE sync_mutations ADD COLUMN rejections TEXT;
1507
+
1508
+ -- PostgreSQL
1509
+ ALTER TABLE sync_mutations ADD COLUMN IF NOT EXISTS rejections TEXT;
1510
+ ```
1511
+
1512
+ The sql README's "Ledger without the `rejections` column" section gives a check for the column, the same DDL, and the table a new install creates.
1513
+
1514
+ - 97db489: `SqlBackend` now guarantees serialisable isolation across client groups (Backend guarantee 1, #353). Two behaviour changes follow, and the `Sql.Dialect` interface gains an optional field.
1515
+
1516
+ - **PostgreSQL** (`Sql.postgres`): each `applyMutations` transaction runs `SET TRANSACTION ISOLATION LEVEL SERIALIZABLE` before its first read. A handler that read a row another transaction changed first is refused with SQLSTATE 40001, rolls back, and reports a transient `BackendError`, so the client's retry re-runs it on the committed state. Overlapping calls for one client group that READ COMMITTED would have let through now produce the same serialization failure, then skip on retry. **Action required** for PostgreSQL apps: a serialization failure must reach `SqlBackend` as a `BackendError`, as an executor built over `@effect/sql-pg` does. The client's retry of that batch is expected, not an error to report.
1517
+ - **SQLite** is unchanged. Its writers already serialise (`BEGIN IMMEDIATE`), and it conforms.
1518
+ - **D1** (`transactional: false`) does not conform, since it has no transactions to isolate. The README and the `D1Executor` docs now say so (#342).
1519
+
1520
+ `Sql.Dialect` gains an optional `serializable` statement, which `Sql.postgres` sets. The `SqlExecutor` seam docs state the isolation a third-party executor must provide.
1521
+
1522
+ **PostgreSQL apps must pass `dialect: Sql.postgres` to `SqlBackend.make`.** Without it the backend uses the SQLite dialect, which runs no isolation statement and raises no error, so batches run at READ COMMITTED. A hand-written dialect without a `serializable` statement gets no isolation statement either. Also, the executor's `transaction` must open a top-level transaction: PostgreSQL refuses `SET TRANSACTION` inside a nested one (a savepoint), and `applyMutations` then fails with a `BackendError` rather than running at a weaker isolation.
1523
+
1524
+ - 4a23603: `TableMapping` can be derived from a `Collection` and can separate the
1525
+ relation reads evaluate against from the table mutator handlers write to,
1526
+ and can mark a row a tombstone (#204):
1527
+
1528
+ - **`SqlBackend.fromCollection(collection, options)`** derives a mapping's
1529
+ `kinds` from the collection's declared filterable set
1530
+ (`Collection.filterDeclarationOf`, #154) instead of restating column names
1531
+ and kinds by hand. `table`, and optionally `readRelation` / `idColumn` /
1532
+ `versionColumn` / `tombstone`, are still given explicitly; the existing
1533
+ hand-written record shape remains the override path.
1534
+ - **`TableMapping.readRelation`** names the relation `evaluate`, `getRows`
1535
+ and `Exists` resolution read from when it differs from `table` — the real
1536
+ shape of an app whose current-state relation is a Postgres view over a
1537
+ root version table and a leaf, joined at exactly the leaf's current
1538
+ version. `SqlBackend` never writes rows itself, so `table` stays the
1539
+ write target mutator handlers address.
1540
+ - **`TableMapping.tombstone`** is a `Predicate` (the engine's own filter
1541
+ vocabulary, not a bespoke shape): a row matching it is dropped from
1542
+ `evaluate`'s returned entries, so it is simply absent from the next CVR
1543
+ view and `Cvr.diff` reports it as a delete, exactly like a row that
1544
+ vanished from the relation outright (the fail-closed case the model
1545
+ already relied on, now pinned by a test).
1546
+
1547
+ - f441941: SSR snapshots over a `ClientTransport`, and a Cloudflare service-binding
1548
+ carrier to take them (#211).
1549
+
1550
+ `Hydration.snapshot` no longer requires an in-process `ServerHandler`: pull is
1551
+ the correctness path, so subscribe → pull → dehydrate works identically
1552
+ against a remote Server. Pass `transport` and the render needs nothing from
1553
+ context; omit it and the in-process path is unchanged — a loop-back transport
1554
+ over the in-context handler. `loadSnapshot` in `@thomasfosterau/legation-svelte/kit` takes
1555
+ the same option, so an Effect-based `load` can render against a backend in
1556
+ another Worker.
1557
+
1558
+ `@thomasfosterau/legation-alchemy-cloudflare` provides `BindingTransport`: the sync protocol over a
1559
+ service binding (`env.API.fetch`), for the ordinary two-Worker shape where the
1560
+ Worker rendering the page is not the Worker holding the database. It forwards
1561
+ the inbound cookie and origin so the bound Worker resolves the same caller —
1562
+ and therefore the same room — the browser will reach on its own first pull.
1563
+ Getting that wrong is silent: the snapshot still carries the right rows while
1564
+ its cookie belongs to another room's CVR, degrading every first pull to the
1565
+ reset path. The package's tests pin both sides of it.
1566
+
1567
+ - a01f43b: The public surface is the six concepts (ADR-0022 step 10b). `@thomasfosterau/legation`'s root index shrinks from 43 namespaces to the resource-graph API — `Node`, `Edge`, `Collection`, `Query`, `Aggregate`, `Mutation` (with `Mutator` as a deprecated alias), `Client`, `ClientStore`, `Transport`, `Server`, `Backend`, `MemoryBackend`, `Authorization`, `Predicate`, and the error classes. Everything else — the engine, the mirror seam, plan vocabulary (`Subset`/`Related`), protocol codecs, reactivity plumbing, and server assembly parts — moves to the new `@thomasfosterau/legation/internal` subpath for adapter authors, which the sibling `@thomasfosterau/legation-*` packages now compose from.
1568
+ - 9a6426b: Sync-loop scheduling (#33). `ClientEngine` grows a configurable scheduler on
1569
+ `runSync`, driven by `Effect.Schedule` and the existing `statusChanges` seam:
1570
+
1571
+ - `syncLoop.pushDebounce` auto-pushes after `mutate`/`mutateEncoded`, debounced
1572
+ so a burst of mutations coalesces into a single push.
1573
+ - `syncLoop.pullInterval` pulls periodically as the correctness backstop, so a
1574
+ client converges over pull alone even without pokes.
1575
+ - The baseline triggers (pull on poke, push+pull on reconnect) are retained,
1576
+ and every scheduling fibre is scoped — interrupted when the engine scope
1577
+ closes.
1578
+
1579
+ `@thomasfosterau/legation-browser` adds `BrowserConnectivity.withBrowserConnectivity`, which
1580
+ layers `navigator.onLine` + `online`/`offline` + `visibilitychange` onto any
1581
+ `ClientTransport` so `statusChanges` becomes truthful for carriers (like the RPC
1582
+ transport) that otherwise report themselves permanently connected, and a
1583
+ return-to-visible surfaces a poke.
1584
+
1585
+ `@thomasfosterau/legation-jsonapi` `JsonApiSync` now configures the core loop (`pullInterval`,
1586
+ `pushDebounce` options, 5-second default interval) instead of running its own
1587
+ `Effect.forever` poll; `JsonApiSync.start` just runs `runSync`.
1588
+
1589
+ - fc880dc: Add `Transport.simulateOffline`: a decorator wrapping any `ClientTransport`
1590
+ with a toggleable offline gate (push/pull/read fail with a `TransportError`
1591
+ while toggled offline, mirroring a real network drop, so the engine keeps
1592
+ mutations in the journal and its sync loop flushes them on reconnect).
1593
+ Generalises the offline-gate transport the demo examples each hand-rolled
1594
+ into reusable substrate — it now
1595
+ backs both examples' offline toggle, and `@thomasfosterau/legation-devtools`' actions panel
1596
+ (#120) offline-toggle action.
1597
+ - e34bc24: Connection-status realism at the transport seam: `Transport.withInferredStatus`
1598
+ derives real connectivity from observed push/pull behaviour ("connecting"
1599
+ until the first call settles, "connected" on success, "disconnected" on a
1600
+ `TransportError`) — wrapping the default RPC carrier, which reports itself
1601
+ permanently "connected". `Transport.withFallback(primary, fallback)` layers a
1602
+ fallback carrier (e.g. plain HTTP under WebSockets) with a sideline cooldown
1603
+ (`restoreAfter`, default 30s), an optional per-call primary time budget
1604
+ (`primaryTimeout` — a hanging primary is interrupted, sidelined, and the call
1605
+ falls to the fallback instead of stalling sync), merged pokes that survive a
1606
+ failing primary poke stream, inferred status ("connected" while either
1607
+ carrier serves), and version rejections propagating without falling back.
1608
+ `BrowserConnectivity.withBrowserConnectivity` now combines `navigator.onLine`
1609
+ with the wrapped transport's own status (worst of the two wins) instead of
1610
+ replacing it.
1611
+ - 0470e1c: Add a typed query surface over the existing untyped `Subset`/`LocalRow` (purely
1612
+ additive — the protocol-level and dynamic surface is unchanged).
1613
+
1614
+ - `Subset.for(collection).make({ where, orderBy, limit, cursor })` builds a
1615
+ `TypedSubset<A>`: field names and literal types in `where` (via a
1616
+ field-checked `TypedPredicate<A>`) and `orderBy` are checked against the
1617
+ collection's row type. It erases to a plain `Subset` at runtime.
1618
+ - `ClientEngine.query`/`observe`, `EngineAtoms.liveQuery`, the React `useQuery`
1619
+ and the Svelte `query` store now refine their row type to `A` when handed a
1620
+ `TypedSubset<A>` (a trust-cast — the read path still never decodes, per
1621
+ ADR-0014), so the row type flows through without `row.value as T` casts. Plain
1622
+ `Subset` callers are unaffected.
1623
+ - `ClientEngine.observe(subset, { select, equals? })` returns a projected
1624
+ stream that re-emits only when the projected value changes (reference
1625
+ equality by default), so a change to a field the projection ignores no longer
1626
+ wakes the observer.
1627
+ - Named-query cache/subscription keys (`ClientEngine`'s named subscriptions and
1628
+ `EngineAtoms.namedQuery`) now key on `Hash.canonicalJson` rather than
1629
+ `JSON.stringify`, so two structurally-equal args objects with different key
1630
+ order resolve to the same subscription entry and the same Atom.
1631
+
1632
+ - 439f449: Add `Undo`: an optional, additive undo/redo module layered over anything that
1633
+ can `mutate(name, args)` — no engine surgery. It follows the **paired-intents**
1634
+ design (the `@rocicorp/undo` shape): the caller supplies both directions of a
1635
+ change — an `execute` intent and its reversing `undo` intent — and
1636
+ `Undo.make(handle)` returns an `UndoManager` whose `run` / `undo` / `redo`
1637
+ replay through the handle's ordinary `mutate` pipeline. A `ClientEngine` and a
1638
+ `Client` both satisfy the accepted `MutateHandle` shape structurally; because
1639
+ their `mutate` methods don't share an error channel, both `MutateHandle` and
1640
+ `UndoManager` are generic in that error type, so a failed mutation is still
1641
+ observable as whatever the supplied handle fails with. Because undo and redo
1642
+ are just mutations, they are optimistic, journaled, rebased, and offline-safe
1643
+ like any write; the module only owns the history.
1644
+
1645
+ History is **in-memory and per-client** (one manager per engine, a pair of
1646
+ stacks in a `SubscriptionRef`) — it does not survive a reload, though the
1647
+ journal still carries any pending undo/redo mutations to the backend. `run`
1648
+ clears the redo stack (a new action invalidates redo); `undo` and `redo` are
1649
+ no-ops (never errors) when their stack is empty. `group(effect)` coalesces every
1650
+ `run` made while `effect` runs into a single step: one `undo` replays each
1651
+ member's `undo` in reverse order, one `redo` replays each member's `execute` in
1652
+ order. `canUndo` / `canRedo` are one-shot effects; `canUndoChanges` /
1653
+ `canRedoChanges` are `SubscriptionRef.changes`-backed streams for bindings.
1654
+
1655
+ The **delete-then-undo** criterion is met by the paired-intents pattern: capture
1656
+ the row's pre-delete value from the local view at run time and pass it as the
1657
+ undo's re-create args, so an undo restores the row exactly.
1658
+
1659
+ `@thomasfosterau/legation-svelte` gains `useUndo(manager)` — `canUndo` / `canRedo` as runes
1660
+ over the manager's change streams, plus `undo` / `redo` handlers. The `todo`
1661
+ example wires ⌘Z / Ctrl+Z (Shift for redo) through the manager and documents
1662
+ the recipe.
1663
+
1664
+ - 3a88118: The unified Query read (ADR-0022, program step 3): `Query.make({
1665
+ collection, where, include, order, first, after })` declares a read shaped
1666
+ like the data — an equality-map `where` (full `Predicate` as the escape
1667
+ hatch), edges by name in `include` (with per-edge `first` windows), order
1668
+ tokens, cursor pagination — and `Query.plan` compiles it against the node
1669
+ graph to a root `Subset` plus edge plans (`Subset`/`Predicate` become the
1670
+ plan's internal representation). `Query.run`/`Query.observe` execute reads
1671
+ over any `ClientView.Reader`, resolving includes through the existing
1672
+ `Related` machinery and applying paginated-edge order + `first` windows to
1673
+ the resolved join (true per-edge sync windows land with the
1674
+ paginated-edge step). Named queries are the same concept with a name —
1675
+ `Query.named` (the old `(name, args)` form of `Query.make` still works via
1676
+ overload).
1677
+ - 1b8b120: URL-keyed subscriptions over the mirror channel (ADR-0029 section 2, issue #159).
1678
+
1679
+ - `Client.subscribe` / `Client.unsubscribe` accept a JSON:API URL as well as a `Query.Read`. A Client built with the new `urls` option (`Client.UrlReads`: `parse(url)` and `canonical(read)`) parses the URL, plans the read and canonicalises it, so `subscribe(url)` and `subscribe(read)` of one query — and any non-canonical spelling of the URL (parameter order, a repeated `include` key, an API prefix, a `fields[]` projection) — share one ledger entry keyed by the canonical URL beside the subset hash. A URL the bridge refuses (an unknown collection, an unparsable filter, an undeclared edge) fails with a `QueryError` naming the offending parameter and registers nothing; a Client built without `urls` fails a URL call with a `QueryError`, never a defect. `Client.subscriptions` lists the canonical URLs of the explicit pins for devtools, and `Client.coverageOf` names the covering subscription's canonical URL (`Verdict.url`) while a pin holds it. `Client.make`, `Client.connect` and `Client.fromView` take `urls`; `@thomasfosterau/legation` itself does not know the URL grammar (ADR-0006), so `@thomasfosterau/legation-jsonapi` builds the seam from the node graph: `JsonApiQuery.urlReads(nodes)`.
1680
+ - An explicit pin now registers its edge windows. `Client.subscribe(read)` with `include` holds the foreign windows the read's edges select — `ClientView.pinEdges`, a new member of the view: `Related.trackEdges` (the relational read's reconcile loop without its join) over an owned engine, the relational registration with the host over a connected one — until the last pin releases, and records the edge selects on the ledger entry, so a read with `include` is covered by a pin that declared the edge (ADR-0029 rule 4). The commerce shell's `myCartItems` pin (`include: { variant: true }`) now syncs its edge window. Release stays symmetric with the pin count; the ledger update and the registration it decides are one critical section.
1681
+ - A relational registration no longer waits on the network: `Related.relatedWith` (behind `observe` with `include`, `related` and now pins) runs its pulls in the background, one at a time with one queued, so a stalled transport delays rows and never blocks the caller; a failed pull only delays rows (an offline `Client.make` no longer dies).
1682
+ - Engine subscriptions are counted where two registrations can hold one window: `ClientView.fromEngine` and `EngineHost` register through `Subscriptions.counted(engine)` — idempotent, so a tab hosting for others (`ElectedHost`) counts once for both fronts — and a relational read's own root subscription ending beside an explicit pin, or one relational read's foreign window beside another's, no longer drops the window from the engine's set-based subscription list.
1683
+ - The mirror protocol gains `SubscribeUrl` / `UnsubscribeUrl` (`{ mirrorId, url }`): a page with no `Client` posts them on the channel, and an `EngineHost` built with `nodes` and `urls` parses, plans and registers the URL's root and edge windows under that `mirrorId` — a thin adapter over the existing `Subscribe` and `SubscribeRelated`, refcounted and swept like any mirror's registrations. `EngineHost`'s `registry` defaults to `nodes.collections`. `ServiceWorkerHost.run` and `ElectedHost.run` forward `nodes` / `urls`; `ElectedHost`'s Client takes `urls` too.
1684
+ - `Query.tryPlan(nodes, read)` is `Query.plan` with its `QueryError` as a typed failure.
1685
+
1686
+ - cf48723: Web Push as a poke carrier, to converge a client with no open tab (#214). `PushPoke.sink` (on `@thomasfosterau/legation/internal`) is an `InvalidationSink` that sends a contentless marker, `PushPoke.payload`, to the push subscriptions the app's registry names for a change, through the app's own sender: the library holds no credential and stores no subscription. The coalescing window and the per-target budget are required options with no default. In `@thomasfosterau/legation-browser`, `PushPoke.listen` is the worker's `push` listener: the library's push registers the Background Sync tag by default, or pulls with `action: "pull"`, only when the replica is stamped for whoever is signed in now, and every other push is left to the app's own handler. `ServiceWorkerHost.start` takes it as `pushPoke`.
1687
+ - b4489c5: Write-path incremental view maintenance. A `mutate` and a push acknowledgement now carry the collections they touch, so a live query observing a different collection no longer re-materialises when an unrelated collection changes; the collections are discovered by running the mutator's local handler through a lightweight collection-tracking writer. The materialised optimistic overlay is cached and shared across observers (invalidated on every journal append/trim and pull), so a single change costs one journal replay rather than one per observer. `LiveView.put`/`del` gain an id index: an absent id is now an O(1) no-op and an existing row is located by binary search on its sort key (O(log n) for distinct sort keys, O(log n + equal-key run) on ties) instead of a linear scan. A checked-in benchmark (`bun run bench`) exercises a mutation burst over a large collection with many observers.
1688
+
1689
+ Targeted overlay-delta recomputation on pull-with-pending remains staged: a pull while the journal is non-empty still falls back to a global rebuild, preserving rebase correctness.
1690
+
1691
+ ### Patch Changes
1692
+
1693
+ - 56a2e51: With `syncLoop.pushDebounce` set, `ClientEngine.runSync` now auto-pushes a
1694
+ mutation journaled at any point after it starts. Previously the push-debounce
1695
+ fibre subscribed to the push trigger only once it first ran, a few scheduler
1696
+ turns after `runSync` returned. A mutation journaled in that gap, after the
1697
+ connect leg had already read the journal, stayed pending until something else
1698
+ pushed: another mutation, a reconnect or an explicit `push`. A mutation made
1699
+ right after start-up could hit this whether `runSync` is forked (`Client.make`,
1700
+ the browser package's `ElectedHost`) or awaited (`ServiceWorkerHost`, the
1701
+ JSON:API package's `JsonApiSync`). `runSync` now subscribes before it forks any
1702
+ of its fibres.
1703
+ - d76b04d: Docs: add the "Coming from TanStack Query / SwiftData / SWR" capability guide
1704
+ (`docs/comparisons/coming-from-tanstack-query.md`) and link it from each package README.
1705
+ - 1743c2b: `ClientEngine` settles each mutation once. A push response that repeats a
1706
+ permanent rejection the client already holds (the response of a copy that
1707
+ loses a race, #355) no longer emits a second `MutationRejected` event or
1708
+ resolves a `mutateWithAck` handle twice. A rejection reported for a mutation
1709
+ the client has already acknowledged no longer overwrites its `acknowledged`
1710
+ state. The first committed outcome the client observed stands: a later,
1711
+ contradictory report is logged at debug and ignored.
1712
+ - 3e29e1b: `BackendContract.verifyBackendContract` gains an optional `awaitPeer` on `@thomasfosterau/legation/internal`. Rule 11 holds a parked handler while the peer group's call runs. A backend whose peer does real I/O (PostgreSQL) can now supply `awaitPeer`, which returns once the peer has finished or is blocked on a lock the parked call holds, so the overlap is reached on every run instead of by scheduler luck (#416). Without it the rule keeps its bounded turn count, so existing callers are unchanged.
1713
+
1714
+ **For third-party backends:** the rule 11 handler is registered under `BackendContract.countUp.name` (`contract/count-up`). It passes `gate` unchanged to `park` between its read and its write, and writes the counter plus one. A handler that omits `park` fails rule 11 with "finished without reaching park()". Only the contract's own handler calls `park`.
1715
+
1716
+ - 8995115: `Derive`'s combinators (#114) now consume `observeChanges` directly when a
1717
+ leaf is built from a full `ClientView` via `Derive.observed`, instead of
1718
+ diffing consecutive `observe()` pages to recover a delta `LiveView` already
1719
+ computed (#133 item 1, ADR-0027 §4's flagged follow-up). `Incremental.
1720
+ deltaFromChanges` reads an `observeChanges` batch's enter/update/exit/move
1721
+ decision directly; `observed`'s returned source carries this feed invisibly
1722
+ alongside its ordinary page stream, so `filter`/`map`/`join`/`groupBy`/
1723
+ `distinct`/`orderedWindow` built on top of it pick it up automatically. A
1724
+ source built any other way — a raw `Stream`, `view.observe(subset)` called
1725
+ directly, or another combinator's own output — still diffs pages, unchanged.
1726
+
1727
+ No public API changes: `Derive.observed`'s parameter type widens from
1728
+ `ClientView.Reader` to accept a full `ClientView.ClientView` too, and every
1729
+ combinator's signature is untouched.
1730
+
1731
+ - 8995115: `CollectionIndex` acceleration (#113) is no longer bypassed for every
1732
+ collection whenever any mutation is pending (#133 item 4): since #109 the
1733
+ engine already computes exactly which collections a batch of pending
1734
+ mutations touches for the rebase path, and `indexForCollection` now reuses
1735
+ that to scope the bypass — a pending mutation on collection A leaves
1736
+ collection B's declared/auto-discovered index usable. An unscoped result
1737
+ (an unknown mutator, undecodable args) still conservatively falls back to a
1738
+ full scan for every collection, same as before. No public API changes —
1739
+ `indexes`/`autoIndex` options and query results are unaffected; this only
1740
+ changes when the full-scan fallback kicks in.
1741
+ - ae18cb3: Add a one-sentence doc comment to every exported declaration that had none, so editor hovers on the published `.d.ts` say what each export is for. This covers the root `@thomasfosterau/legation` surface (`Backend`, `Server`, `ClientStore`, the `Predicate` and `Aggregate` constructors, …), its `/internal` seams, and the options and config interfaces of the other packages. The `@thomasfosterau/legation` module header now counts seven concepts (Hydration joined ADR-0022's six). Documentation only; no API or behaviour changes.
1742
+ - cc2f5b7: Observers subscribe to their change hub BEFORE taking the initial
1743
+ snapshot (#84). A hub only delivers to live subscribers, so a change
1744
+ published while an observer materialized its first page was silently
1745
+ lost — and because seq-gap recovery needs a _next_ item to notice the
1746
+ gap, a quiet collection never re-emitted: `Related` joins missed foreign
1747
+ writes, live queries slept through a mutation that landed in the
1748
+ subscribe window. `ClientEngine`'s subset observers and `TabMirror`'s
1749
+ key observers now hold the subscription first; a buffered change the
1750
+ snapshot already reflects steps as a stale seq or an idempotent
1751
+ re-apply.
1752
+ - a874acb: Bump the pinned Effect version from `4.0.0-beta.75` to `4.0.0-beta.83` across all
1753
+ packages (no engine API changes; build, typecheck, and the full test suite pass
1754
+ unchanged).
1755
+
1756
+ The version is now centralised in a root **Bun catalog**
1757
+ (`package.json` → `workspaces.catalog`): every package's `devDependencies`
1758
+ reference `effect`/`@effect/platform-*`/`@effect/vitest` as `catalog:`, while the
1759
+ published `peerDependencies` keep the concrete `4.0.0-beta.83` string. The
1760
+ Every example is a workspace member so they share the same `effect` version
1761
+ as the engine instead of pinning its own.
1762
+
1763
+ - c5d3484: README: the `effect` floor (`4.0.0-rc.118`) is now tested on every pull
1764
+ request, where CI runs the test suite against it, instead of in a weekly
1765
+ canary. The peer range (`effect >=4.0.0-rc.118`) and the tested pin (`4.0.0`)
1766
+ are unchanged.
1767
+ - e9363c0: Peer `effect ^4.0.0` (was `>=4.0.0-rc.118`), and `@effect/platform-node` /
1768
+ `@effect/platform-browser` `^4.0.0` likewise, per the peer-range policy: the
1769
+ range is raised only when a dependency requires it. The range form changes
1770
+ (the caret no longer admits the 4.0 release candidates or Effect 5); every
1771
+ package's tests pass at `effect` 4.0.0.
1772
+
1773
+ `@thomasfosterau/legation-svelte` peers `effect ^4.0.2`: its dependency
1774
+ `@thomasfosterau/effect-svelte` 0.6.0 peers `effect >=4.0.2`.
1775
+
1776
+ - 454e75c: ADR-0025's read path is reusable outside the protocol carrier (#163).
1777
+ `SyncServer.readSubsets(deps, context, request)` is `handleRead` without the
1778
+ identity resolution: the filterable check, `SyncMiddleware.onRead`, the
1779
+ deny-by-default auth filters, then `Backend.evaluate` + `getRows` per subset;
1780
+ `handleRead` calls it, and the derived REST handlers in `@thomasfosterau/legation-jsonapi`
1781
+ call it behind an HTTP request's identity. Every subset of a batch is
1782
+ authorized before any is evaluated, as `handleRead` always did, and the rows
1783
+ of each subset now come back in the evaluated order (`getRows` promises
1784
+ none), so a fallthrough read over a SQL backend is windowed exactly as
1785
+ `Subset.apply` orders it. `Query.runVia(fetch, nodes, read)` runs a read over
1786
+ an external row source — the plan, `Related.queryVia`'s two phases, and the
1787
+ edge windows — and `Query.runPlanVia(fetch, nodes, plan)` does the same over
1788
+ an already-compiled plan; `Client.query`'s server fallthrough compiles once
1789
+ and uses the latter.
1790
+ - 2003cd6: Built-in multi-tab leader election for the no-service-worker degraded
1791
+ topology (#119): `@thomasfosterau/legation-browser`'s `ElectedHost.run` wraps
1792
+ `navigator.locks` (held for the tab's whole lifetime, no voluntary
1793
+ step-down) around `ServiceWorkerHost.run`'s main-thread host composition —
1794
+ `ClientEngine` + durable store + transports + `BroadcastMirrorChannel`
1795
+ serving while elected, a `Client.connect` mirror over the same channel
1796
+ while not. It hands back one `Client` that keeps working unchanged across
1797
+ the mirror→host promotion (a switching `ClientView` swaps the underlying
1798
+ tier, replaying explicit subscriptions and re-hydrating live
1799
+ `observe`/`related` streams) and a `role: Stream<"host" | "mirror">` for
1800
+ devtools/debug UI. A promoted tab boots against the SAME durable store —
1801
+ same journal, rows, and cookie — so the CVR conversation resumes rather
1802
+ than restarting. Environments without `navigator.locks` get a documented
1803
+ fallback (becomes host immediately), not a polyfill.
1804
+
1805
+ `@thomasfosterau/legation`'s `Mirror` now fails any request still in flight when
1806
+ the host it was talking to is gone — either because the mirror's own scope
1807
+ closed, or because a second `HostReady` announces the host restarted — with
1808
+ a clear `TransportError` instead of leaving the caller's `mutate` /
1809
+ `mutateWithAck` / server-fallthrough `read` call hanging forever. A
1810
+ mutation already journaled by the old host before it died is never lost:
1811
+ it's durable in the shared store and the next host's sync loop picks it up
1812
+ regardless of what this caller was told.
1813
+
1814
+ - 6e9e119: Differential filter harness (issue #153, ADR-0028).
1815
+
1816
+ - `BackendContract` rule 6 (reference subset semantics) now also runs a small
1817
+ generated corpus of predicates, order terms and limits over the contract's
1818
+ `rank`/`tag` columns, so every backend inherits the check for every operator
1819
+ in the core. This is stricter than before: text order is code-point order,
1820
+ so a backend whose text columns carry a locale collation fails the contract
1821
+ by design — ADR-0028 requires `COLLATE "C"` (Postgres) or `BINARY` (SQLite).
1822
+ - `FilterCorpus` is a new `@thomasfosterau/legation/internal` export: seeded corpus
1823
+ generators over Effect's `Random` service (`Random.withSeed`), a minimiser
1824
+ that shrinks a disagreeing predicate/row pair, and a disagreement reporter.
1825
+ - `@thomasfosterau/legation/sql`'s tests gain the differential harness itself: `Predicate.test`
1826
+ and `Sql.compileWhere` on SQLite are compared against a Postgres oracle
1827
+ running in-process via PGlite, with the known JS divergences pinned until
1828
+ #155 closes them.
1829
+
1830
+ - a1b95d4: The rune hooks speak Query over the Client (ADR-0022 step 8). `useQuery`/`useAggregate`/`useAggregateGrouped` take declarative `Query.Read` thunks — `include`d edges resolve onto each row's `related` (windowed per the edge's declared order), so `useRelated` is removed; `useMutation`/`useMutationWithAck` run over the context Client. The hooks read a `ClientRuntime` (`ManagedRuntime<Client.Client>`) provided via `setClientRuntime`. `@thomasfosterau/legation` adds the `Client` service tag and exposes `Client.nodes` (the registry the Client was built from).
1831
+ - dfb8759: `ClientEngine` now tracks every journal entry it pushes in `mutationStates`, including entries a direct store write put there (`Hydration.hydrate` into a running engine) and entries whose boot-time journal read failed. Each one settles to `"acknowledged"` or `"rejected"` like a locally journaled mutation, so a server rejection of a hydrated entry now emits `MutationRejected`. Before, the entry was pushed but untracked, and a rejection for an untracked id was silently ignored (no event, no state change).
1832
+
1833
+ A hydrated entry is announced like a local one: it emits `MutationJournaled` once, when it is adopted, so its outcome event follows an opening event as it does for a local mutation. The payload is the same `clientId` and `mutationId` the outcome events carry. Re-reading the journal does not announce an entry twice. This adds an event to the stream for hydrated entries, so `registerOnJournal` in `@thomasfosterau/legation-browser` (which listens for `MutationJournaled` while offline) now also registers a background sync for them; it is a consequence of this change, not a change to that package.
1834
+
1835
+ An entry the engine already tracks keeps its phase, and a rejection that contradicts an outcome already observed is logged at warning level and ignored. The journal read that adopts entries now runs under the engine's overlay lock, so a pull's trim cannot land between the read and the adoption.
1836
+
1837
+ - 9c9f2b0: Fix a secondary index that could keep pre-pull rows. An index build reads a
1838
+ collection's confirmed rows, yields, then installs; a pull whose patch changed
1839
+ an indexed field of that collection could land in that window, and the stale
1840
+ index was installed and kept until the next rebuild, so indexed queries could
1841
+ return rows that no longer match (or miss rows that now do). Each collection now
1842
+ carries a generation that every confirmed-row write (and every field-set change)
1843
+ bumps; a build discards its result and retries when the generation moved during
1844
+ its read, and falls back to a full scan after repeated races.
1845
+ - ab1fb4e: A refused or failed journal append no longer burns a mutation id (#258).
1846
+ `journalHighWater` refusals, quota errors and aborted transactions used to leave
1847
+ the id counter one ahead of the journal, so the next mutation carried
1848
+ `lastId + 2`, every backend rejected the gap, and the journal never drained.
1849
+ `ClientStore` gains `appendNext(draft)`, which allocates the id and appends as
1850
+ one store step (the key-value store checks the high-water mark first and
1851
+ persists the counter last, healing it from the journal's highest id after a
1852
+ crash); the engine journals through it. Custom `ClientStore` implementations
1853
+ must provide `appendNext`; `nextMutationId` stays for low-level use but an id
1854
+ reserved without an append is a gap.
1855
+
1856
+ An id that has left the journal is never issued again: `acknowledge` and
1857
+ `quarantinePending` raise the client's counter to the highest id they remove (in
1858
+ the key-value store, before deleting anything), and the key-value `appendNext`
1859
+ is uninterruptible. Previously a lost counter write (or an interrupt between the
1860
+ index and counter writes) let the next append reissue an acknowledged id, which
1861
+ the backend skipped as an idempotent duplicate, silently losing the write. The
1862
+ in-memory store's journal writers are now mutually exclusive: `appendMutation`,
1863
+ `appendNext`, `acknowledge`, `compactPending`, `nextMutationId`,
1864
+ `quarantinePending`, `releaseQuarantine` and `discardQuarantine` all hold one
1865
+ permit, so a read-then-write operation can no longer overwrite a concurrent
1866
+ append or a released quarantine. `appendNext` heals its counter from the journal
1867
+ exactly as the key-value store does, and so does `nextMutationId` on both
1868
+ stores.
1869
+
1870
+ - eb9555a: The filter-grammar bridge (ADR-0028 §5, #161): `@thomasfosterau/legation-jsonapi` now
1871
+ translates both ways between effect-jsonapi's filter AST and the engine's
1872
+ `Predicate`, parses a JSON:API URL into a `Query.Read`, and encodes the whole
1873
+ predicate exactly. Bumps `@thomasfosterau/effect-jsonapi` to ^0.13.0, which
1874
+ carries the grammar (`Filter`, `Sort`, `Query.Filter`, `Query.canonical`,
1875
+ `Resource.filterable` / `Resource.sortable`).
1876
+
1877
+ - `JsonApiQuery.fromFilterAst(ast)` is total — every grammar node has an
1878
+ engine node — and `toFilterAst(predicate)` is partial: `Exists`, an empty
1879
+ `In` / `NotIn` list and a `null` literal have no URL form and return `None`,
1880
+ so callers fail closed. `foldConstants` folds the two definite cases away
1881
+ exactly (an empty `In` is false, an empty `NotIn` true under ADR-0028).
1882
+ - `JsonApiQuery.parseRead(nodes, url)` / `parseParams(nodes, collection,
1883
+ params)` decode `include`, `fields`, `sort`, `page` and `filter` with
1884
+ effect-jsonapi's `Query.schema` derived from the **node graph** (each node's
1885
+ filterable set — operators and literal kinds — and edges become the codec's
1886
+ declaration; `resourcesOf(nodes)` exposes the synthesised resources) and map
1887
+ them onto the read: `filter` → `where`, `sort` → `order`, `page[limit]` /
1888
+ `page[after]` → `first` / `after`, `include` → edge selects. Include paths
1889
+ are single level (ADR-0028 D1). `fields[]` is carried beside the read as a
1890
+ projection, never into the plan. Every rejection is a `QueryError` whose new
1891
+ `parameter` field names the offending query key (the 400's
1892
+ `source.parameter`). A `/{type}/{id}` URL reads as `filter[id]`, first 1.
1893
+ - `JsonApiQuery.canonical(nodes, read)` compiles the read and encodes its plan
1894
+ through effect-jsonapi's canonical encoder — one string per plan, the
1895
+ subscription identity of ADR-0029 — and `parseRead` accepts it back.
1896
+ - The encoder is exact: `defaultEncoder` pushes the whole predicate as the
1897
+ grammar's canonical `filter[...]` form (operators, `or`, `not`, `isnull`,
1898
+ `nin` included — nothing is dropped any more); `safeFilters` is gone. A
1899
+ subset whose predicate folds to false is answered without a request, and one
1900
+ with no URL form fails closed. `QueryEncoder.encode` now returns
1901
+ `Effect<Option<string>, QueryError>`; `JsonApiRequestOptions.filter` is a
1902
+ filter AST (`Filter.eq("status", "open")`) instead of a `Record<string,
1903
+ string>`; query strings are serialised through `Query.serialise` (readable
1904
+ `[`/`]` in keys). `buildQueryString` lives in `JsonApiQuery` and stays
1905
+ re-exported from `CollectionLoader`.
1906
+ - `NodeBridge.fromResource` / `registry` read the filterable set off the
1907
+ resource's own declaration (`Resource.filterable` / `Resource.sortable`): an
1908
+ attribute keeps its name and derives its kind from its schema; a to-one
1909
+ relationship's declaration lands on the edge's key field (`author` →
1910
+ `authorId`, kind `text`). The `filterable` / `sortable` options remain as
1911
+ per-field overrides (the way to give an `instant` its kind).
1912
+
1913
+ - 26ad3f3: `ClientStore.makeKeyValue` now drops, when it opens, any journal index entry
1914
+ whose body is missing. A crash (or a failed write) part-way through
1915
+ `acknowledge` removed the acknowledged bodies but left their index entries,
1916
+ which inflated `pendingCount` and the journal-depth gauge, could make the
1917
+ `journalHighWater` check refuse appends early, and made `compactPending` refuse
1918
+ to renumber over the gap until the next acknowledgement trimmed them. The
1919
+ dropped ids stay reserved, so none is reissued, and `pendingCount` still reads
1920
+ the index alone.
1921
+ - cb0a33d: The in-memory `ClientStore` now takes the atomic batch path that the key-value store takes when it has an `AtomicKeyValue`: a pull's release and a push acknowledgement's park and trim each run as one step under the store's lock, with no release record. A release the store refuses now writes nothing, so the unpulled tail keeps its entries and can sit above its bound until the next push acknowledgement or pull releases them, rather than being finished from a record.
1922
+ - 912d95d: `MemoryBackend` now meets Backend guarantee 1. Each mutation's writes are staged
1923
+ until its handler returns, and the staged writes commit with the client's
1924
+ `lastMutationId` bump in one state update. A pull or `evaluate` that runs while
1925
+ a handler is mid-flight no longer sees its writes ahead of the bump. A handler
1926
+ that fails with `BackendError` after a write leaves no write behind, and a
1927
+ `MutationRejected` still discards the writes while bumping the id. Handlers
1928
+ still read their own staged writes, and row versions are assigned as before.
1929
+ - 7290d2b: `MemoryBackend` no longer double-applies a mutation when two `applyMutations`
1930
+ calls for one client group overlap (a client retry while the first push is
1931
+ still in flight). The commit re-checks the client's `lastMutationId`, so a copy
1932
+ whose mutation another call already committed is dropped, reported as a replay
1933
+ skip, and no longer moves the watermark backwards. The bump also takes the max,
1934
+ so `lastMutationId` never falls. `Backend` guarantee 1 now states that calls for
1935
+ one client group may overlap and what must hold when they do.
1936
+ - 41c33e3: `MemoryBackend` now reports the committed outcome from every copy of a mutation. A copy that loses a race to another copy, or replays a mutation already committed, lists it in `rejected` when it was committed as permanently rejected, with the same reason. Before, such a copy reported nothing, so a client could settle a rejected mutation as applied, or show its optimistic effects until the next pull, depending on which push response arrived first. A committed rejection is remembered until the client's next push starts past it, so the backend holds at most that push's unacknowledged rejections per client.
1937
+ - a77261c: `MemoryBackend` no longer emits a stale value in a push's change hint for a row
1938
+ that a later mutation of the same push deleted. A put in an earlier mutation
1939
+ left its value in the batch's hint values after a later mutation deleted the
1940
+ row, so the hint carried a row that no longer exists and fine-grained fan-out
1941
+ routed by it. The delete now removes the row from the batch's values, as a
1942
+ delete within one mutation already did.
1943
+ - a04918b: A mirror's `mutate`, `mutateWithAck` and server-fallthrough `read` now get their own answer when another tab on the same host has a request of the same number in flight. `Mutate` and `Read` carry the mirror's id, and the host echoes it on `MutateAck`, `MutateError`, `MutateSettled` and `ReadResult`; a mirror ignores a reply addressed to another. The field is optional both ways, so a mirror and a host from before this change still work together, as they did before.
1944
+ - bc4c599: Fix a lost-write window in paced compaction (#249 follow-up). Once
1945
+ `ClientStore.compactPending` had made a compaction durable (the write-ahead
1946
+ intent), a failure applying it (an IndexedDB quota error, an aborted
1947
+ transaction) or an interrupt before the engine caught up left the store
1948
+ renumbered while the engine, `Paced` trackers and `mutateWithAck` handles kept
1949
+ the old ids, so the next paced call dropped the user's newest write. Now
1950
+ `compactPending` returns the committed renumbering as soon as the intent is
1951
+ durable (the next journal operation, or the next open, rolls the rest
1952
+ forward), the store commit and the engine's re-key run uninterruptibly, the
1953
+ memory store updates journal and counter together, and `Paced` journals and
1954
+ remembers an id in one uninterruptible step.
1955
+
1956
+ Also: a journal found at engine boot or hydrated from a snapshot is frozen
1957
+ (never renumbered), since it may already have been pushed; a durable
1958
+ compaction reads only the journal index and the bodies it moves instead of the
1959
+ whole journal; `dropped` is now a valid phase on the mirror wire (a dropped
1960
+ state used to make the whole `MutationStatesUpdate` undecodable); and the
1961
+ compaction events are emitted inside the journal lock.
1962
+
1963
+ - 20a6e18: Fix paced compaction stranding the journal behind a mutation-id gap (#249).
1964
+ `Paced.coalesce`/`debounce`/`throttle` drop superseded unpushed entries, which
1965
+ left holes in the client's mutation ids. Both reference backends reject a gap
1966
+ (ADR-0008's exactly-once ledger), so every push failed and the journal never
1967
+ drained. The client now renumbers: `ClientEngine.dropIfPending` drops the
1968
+ entries and renumbers the surviving unpushed ones contiguously, in order, from
1969
+ the last entry a push may have sent (or from the first pending id), rewinding
1970
+ the id counter to match. The backends are unchanged and still reject gaps.
1971
+
1972
+ - `dropIfPending` now returns `{ dropped, renumbered }` (was the dropped ids),
1973
+ and `Paced` applies the moves to every paced instance on the engine so none
1974
+ acts on an id that now names a different entry.
1975
+ - Safety: compaction never touches an entry a push attempt may have sent. The
1976
+ engine records a durable per-client "sent" watermark (`ClientStore.markSent`)
1977
+ before each push leaves, so a push whose response was lost keeps its ids
1978
+ (renumbering an applied write would get a later one skipped as a duplicate).
1979
+ It still refuses while a push is in flight, and runs under the journal lock so
1980
+ it is atomic with `mutate` and with a push claiming the pending set.
1981
+ - `ClientStore.dropPending` (unreleased) is replaced by `compactPending`
1982
+ (drop + renumber + counter rewind, as one step) and `markSent`. The
1983
+ KeyValue store does this through a write-ahead intent that is rolled forward
1984
+ on the next open, so a crash never leaves a gap, a duplicate or a stale
1985
+ counter. Custom `ClientStore` implementations must provide both.
1986
+ - `mutateWithAck` handles and `mutationStates` follow renumbered entries, a
1987
+ dropped entry's `acknowledged` effect is interrupted instead of leaking, and
1988
+ `engineEvents` gains `MutationRenumbered`. `MutationHandle.mutationId` is the
1989
+ id at journal time and can change while the entry is unpushed.
1990
+
1991
+ - 821b10d: The publish of a push's change hints is bounded by a timeout now that it runs
1992
+ inside the uninterruptible commit-and-publish region. A hung broker used to hold
1993
+ that region open indefinitely, so a push could never finish. Each broker call the
1994
+ bus makes is now bounded: `FanoutBus` (its publish and lease registration, option
1995
+ `timeout`), the Redis broker's `XADD` (option `timeout`), and the Durable Object
1996
+ `DurableFanoutBroker` (its shard calls, option `timeout`, which aborts the request).
1997
+ All three default to 5 seconds. A timed-out call is logged and dropped, the same as
1998
+ any other bus failure: a lost poke only delays the next pull, so correctness never
1999
+ depends on the bus.
2000
+ - 9e74a83: A push that takes the acknowledged-but-unpulled tail to half its bound now starts
2001
+ a pull ahead of the overflow, so a healthy transport never reaches the bound
2002
+ (#337). Before, with no pokes and no `pullInterval`, nothing pulled on its own,
2003
+ so a long run of pushes could fill the tail and release its oldest overlay
2004
+ entries early, reverting the view until a pull landed. The pull runs in the
2005
+ engine's scope and the push does not wait for it; at most one such pull is in
2006
+ flight, and a failed one does not block the next.
2007
+ - e7f4871: A push acknowledgement no longer reverts the view before the pull lands (#286). The push used to trim a mutation's optimistic effects from the overlay as soon as the backend acknowledged it, so an observer saw the pre-mutation value until the next pull. The applied entries now stay overlaid until a pull reports them, and the pull reveals the confirmed row in one change. A permanently rejected mutation still leaves the view at its push, since the backend applied nothing.
2008
+
2009
+ The acknowledged entries are held in memory and bounded at 1024: past that, the oldest are released with a warning. An identity reset clears them. A pull's confirmed rows and its `lastMutationIds` are read as one snapshot, re-reading (up to three passes) when a push commits mid-pull, so, on a backend whose mutations commit their `lastMutationId` with their effects, a pull never leaves out a push its rows carry and an acknowledged mutation is never counted twice. `MemoryBackend` and `SqlBackend` on D1 do not commit them together yet, so a mutation can still be counted twice there until the next pull (#341, #342). Only when a push lands on every pass does a pull report a push its rows lack, which reverts that mutation until the next pull.
2010
+
2011
+ - 916d91b: `SyncServer`'s push handler no longer loses a fan-out when it is interrupted.
2012
+ The backend commit and the publish of its change hints now run as one
2013
+ uninterruptible region, so an interrupt that lands after the commit still
2014
+ publishes the hints. Before, an interrupt between the two (a client dropping
2015
+ mid-push, say) left the change committed but never poked the other client
2016
+ groups, and a replayed retry carries no hints to repair it. The trade-off: a
2017
+ slow `applyMutations` can no longer be cancelled mid-push; an interrupt waits
2018
+ for the commit and its publish to finish.
2019
+ - 47f4e15: `SyncServer.readSubsets`, the read behind `handleRead` and the derived REST handlers, evaluates its subsets through `Backend.evaluateAll` (one `evaluateMany` call on a backend that has it, such as `SqlBackend`'s single `UNION ALL`) and fetches their rows with one `getRows` per collection, instead of an `evaluate` and a `getRows` per subset (#534). Each subset's rows still come back in its evaluated order.
2020
+ - f829146: Rewrite the `@thomasfosterau/legation` and `@thomasfosterau/legation-browser` READMEs against the current API. The stubs are replaced by install, usage and API sections with examples that compile against the published entry points: the root `Node`, `Query`, `Mutation`, `Client` and `Server` surface and the browser host, store, cache and Background Sync modules. The `@thomasfosterau/legation` package description now names the ADR-0022 surface instead of the v1 vocabulary. Documentation only; no API or behaviour changes.
2021
+ - 13658a7: The Redis Streams broker (`makeRedisBroker`) resolves each subscription's start position once it succeeds, then reads on from the last entry it saw. A hint published between a block timeout and the next read is delivered: the read continues from a fixed ID (`XREVRANGE ... COUNT 1`, or `0-0` for an empty stream), not from `$`, which would move forward on each read. The `"$"` option keeps its meaning: a subscriber reads only hints published after its start position.
2022
+
2023
+ `subscribe` makes one start-position attempt and waits at most 500 ms for it, then returns either way. So `SyncServer` construction, which subscribes to the bus, waits at most 500 ms when Redis is unreachable. While Redis answers in time, a hint published once `subscribe` returns is never missed. If the attempt fails or times out, the subscription's reader makes the attempt on its first step, and retries each failed step with a back-off of 200 ms, doubling, capped at 5 s, until Redis answers. While Redis is unreachable, a publish that races the start can be missed; delivery stays best-effort (ADR-0009).
2024
+
2025
+ Each outage logs a warning on its first failed step, a debug line for each further retry, and an info line when Redis answers again.
2026
+
2027
+ - 31cfb2c: The Redis Streams broker (`makeRedisBroker`) bounds each reader `XREAD BLOCK` at its `block` time plus 2 seconds. A read with no reply by then is logged, backed off and retried from the last ID seen, as a failed start lookup already was, so a connection that accepts a read and never answers no longer parks the subscriber silently. A normal BLOCK timeout (a null reply) is still a success, and `block: 0` is not bounded.
2028
+ - 4fbd0b7: Fix a race in `Related.observeWith` that could leave a live join permanently
2029
+ stale: a foreign-row write landing between the join's index priming and its
2030
+ foreign observers subscribing was swallowed by the observers'
2031
+ initial-emission drop, and nothing re-emitted until the next foreign write.
2032
+ The observers' initial current-rows emission is no longer dropped — an
2033
+ unchanged rebuild re-hydrates to the same join and dedups away via
2034
+ `changesWith`, while a racing write now correctly re-emits.
2035
+ - 507ccb1: Every tagged error is now a branded `Schema.TaggedError` rather than a
2036
+ `Data.TaggedError`: `StoreError`, `TransportError`, `QueryError` and the rest
2037
+ of the engine's errors, the JSON:API adapter's `SyncError` / `NetworkError` /
2038
+ `ParseError` / `ApiError` / `ConflictError` / `ResourceBridgeError`, and
2039
+ `HostNotReady`. Construction, `_tag`, `message` and `Effect.catchTag` are
2040
+ unchanged; each class now carries a schema and a nominal brand, so a
2041
+ structurally similar object no longer type-checks as one.
2042
+ - 22f320d: `ClientEngine` runs pulls one at a time. Before, a pull issued while another was in flight did not wait for it, so two pulls could leave with the same cookie and an older response applied last could revert rows and the cookie. Now a pull waits for the pull in flight, and an explicit `pull` resolves once a pull that started after the call has been applied, so a call is never answered from data older than itself. Pokes that arrive during a pull share one follow-up pull. Pulls still run in the caller's fibre, so the caller's context and interruption behave as before.
2043
+ - 8d02113: Fix the mutation-id bump on executors without transactions (`transactional: false`) failing on PostgreSQL. The upsert's `GREATEST` named the ledger's `last_mutation_id` unqualified, which PostgreSQL reads as ambiguous with `excluded`, so every push on such an executor was refused. The column is now qualified with the ledger table's name.
2044
+ - bf9054d: Two fixes surfaced by running `verifyBackendContract` over a real SQLite
2045
+ executor (the commerce example): `SqlBackend`'s mutation-ledger upsert
2046
+ re-bound a placeholder index, which renders an extra unmatched `?` under the
2047
+ sqlite dialect (the fake-executor tests never bound params positionally) —
2048
+ now `excluded.`-based and dialect-safe. And `verifyBackendContract` no longer
2049
+ imposes a value-encoding convention the engine itself doesn't require
2050
+ (ADR-0014 keeps values opaque): the idempotency rule checks the
2051
+ discriminating field instead of exact value equality, and the evaluation rows
2052
+ use a string discriminator instead of a boolean (SQLite has no boolean type).
2053
+ - 985c4d5: `SqlBackend` on an executor without transactions forgets a rejection that an earlier push recorded before its bump failed, once a retry applies the same mutation. Before, the stale rejection outlived the retry, so every later replay reported a rejection for a mutation that had applied (#425).
2054
+ - 97cc5f9: Build with `vp pack` (tsdown) instead of `tsc --build`. Each entry point is now one ESM bundle with one declaration file (the two `@thomasfosterau/legation` entries share a chunk, so a value reached through `.` and `./internal` is still one instance). The `exports` map, entry file names and published dependency and peer ranges are unchanged, and nothing from npm is bundled. The source also moved to the Effect lint presets' idioms (namespace imports from `effect/*` submodules, `effect/Predicate` guards, tag-exact error recovery); behaviour is unchanged.