@thomasfosterau/legation-browser 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,842 @@
1
+ # @thomasfosterau/legation-browser
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d356db4: Flush the journal after the tab has closed (#213, #246). `BrowserConnectivity`
8
+ flushes on reconnect and return-to-visible, but only while a page is open;
9
+ `BackgroundSync` covers the rest by answering a Background Sync `sync` event
10
+ in the service worker.
11
+
12
+ - **`BackgroundSync.listen`** installs the worker's `sync` listener. Call it at
13
+ the top level of the worker script: a worker woken for `sync` dispatches the
14
+ event straight after evaluating the script, so the listener holds the event
15
+ open (up to `readyTimeout`) until a host attaches, then flushes. Its
16
+ `identity` option is **required** (see below).
17
+ - **`ServiceWorkerHost.start({ backgroundSync })`** attaches the host's engine
18
+ to the listener for as long as it serves, and registers the tag only when
19
+ the journal cannot drain now: a mutation journaled while the worker is
20
+ offline (`navigator.onLine === false`, or the `offline` option), or a push
21
+ that failed terminally (`syncFailures`). Never on an online write — online,
22
+ Chromium fires a fresh registration's `sync` at once, which would push each
23
+ write on its own and bypass `pushDebounce` and paced compaction.
24
+ `registerOnJournal: false` turns host-side registration off.
25
+ - **`BackgroundSync.register`** is the page-side registration helper. It never
26
+ fails: `"Registered"`, `"Unsupported"` (no `SyncManager` — Safari, Firefox)
27
+ or `"Refused"`, logged at debug level only. Absent Background Sync, the
28
+ journal flushes on the next connectivity signal.
29
+ - **`BackgroundSync.flush`** is the flush itself: `ClientEngine.push`, not a
30
+ replay loop, so exactly-once comes from the backend's ledger and a refused
31
+ write is surfaced as `rejected` rather than discarded. A transport failure
32
+ rejects the event so the browser reschedules it. `"Flushed"` means the push
33
+ completed (possibly with nothing pending).
34
+
35
+ Only the library's own tag (`BackgroundSync.defaultTag`, or `tag`) is
36
+ answered. The identity boundary (#212) applies: quarantined entries are
37
+ unreachable from push, and `identity` — the fingerprint of whoever is signed
38
+ in now, as the app resolves it in the worker — is required, so a flush skips
39
+ (`"IdentityChanged"`) whenever the store is not stamped for that identity,
40
+ which catches a tab closed before `Client.reset` ran. An app with no sign-in
41
+ passes `BackgroundSync.identityUnchecked` to opt out explicitly.
42
+
43
+ `ServiceWorkerHost`'s docs no longer say Background Sync registration stays
44
+ app code. Periodic Background Sync is still app code, deliberately.
45
+
46
+ - a94df19: Bound every in-process message hub so a stalled or slow consumer can no longer
47
+ grow a queue without limit (#44). Because pull is the correctness path, each hub
48
+ sheds load under pressure without threatening convergence:
49
+
50
+ - **Poke hub** (`Transport.makePokeHub`): now `sliding(1)` per client group with
51
+ replay 1. A poke storm collapses to one queued poke per group — an outstanding
52
+ poke absorbs the rest — and a late/reconnecting subscriber still sees the most
53
+ recent poke.
54
+ - **Invalidation bus** (`InvalidationBus.makeInProcess` and the memory broker):
55
+ now bounded with a `dropping` strategy at a configurable capacity. A dropped
56
+ hint only delays the next pull (ADR-0009); drops are counted in
57
+ `legation_hints_dropped_total`. New `makeInProcessWith` / `layerInProcessWith`
58
+ / `makeMemoryBrokerWith` take a `capacity` option.
59
+ - **Engine change hub** (`ClientEngine`): now bounded `dropping` at a
60
+ configurable capacity (`ClientEngineOptions.changeHubCapacity`, default 1024).
61
+ Each view-change carries a monotonic sequence number; a consumer that sees a
62
+ seq gap collapses it into a full `Rebuild` that subsumes the dropped `Patch`es,
63
+ so the view stays correct under pressure and behaves identically under none.
64
+ Collapses are counted in `legation_change_collapses_total`.
65
+ - **Mirror change hub** (`TabMirror`): now bounded `dropping` at a configurable
66
+ capacity (`TabMirrorOptions.changeHubCapacity`, default 1024). A seq gap makes
67
+ the observer re-read its subset from the mirror's `Ref` (a full refresh,
68
+ lossless because the `Ref` holds the latest rows), counted in
69
+ `legation_mirror_refreshes_total`.
70
+
71
+ A `Patch` is never silently dropped: it is only ever shed behind a seq gap that
72
+ forces a `Rebuild` before the view is next read.
73
+
74
+ - 2ff573c: `BrowserConnectivity.withBrowserConnectivity` now reports connectivity from a
75
+ service worker. A worker has no `window`, so it gets no `online`/`offline`
76
+ events; there the wrapper probes `navigator.onLine` on a timer and emits a
77
+ status change when it flips. When the navigator goes back online, `statusChanges`
78
+ emits `connected` once, even if the transport still reports `disconnected`, so
79
+ the sync loop's push and pull run at once for a worker-hosted engine instead of
80
+ waiting on the push retry backoff (#328). `status` still reads the worse of the
81
+ two signals. Where a `window` exists the `online`/`offline` listeners are
82
+ unchanged, but the reconnect edge applies there too: an `online` event emits
83
+ `connected` once whatever the transport reports. `statusChanges` is therefore
84
+ an edge trigger for the sync loop, not a mirror of `status`; a connection label
85
+ should read `status`.
86
+
87
+ The new optional `pollInterval` option sets the probe interval (default
88
+ `"5 seconds"`); it bounds how long a reconnect takes to reach the sync loop.
89
+
90
+ Auto-push still does not consult connectivity status: a push attempted while
91
+ the status is `disconnected` goes out anyway, because `navigator.onLine` can be
92
+ wrong and a failed push already backs off. This is documented on the module.
93
+
94
+ - 5496ddf: Bump the pinned Effect version from `4.0.0-beta.84` to `4.0.0-beta.107` across
95
+ all packages (catalog and exact `peerDependencies`), and move
96
+ `@thomasfosterau/legation-jsonapi` and the commerce example from
97
+ `@thomasfosterau/effect-jsonapi@^0.6.0` to `^0.12.0` (#152).
98
+
99
+ **Why beta.107.** effect-jsonapi 0.12 requires `effect >= 4.0.0-beta.104` and
100
+ develops against `4.0.0-beta.107` exactly, so that is the version its typings
101
+ are known to line up with. The later `4.0.0-rc.*` releases were not attempted
102
+ in this change: the exact-pin policy (ADR-0003) prefers the version the
103
+ adopted library is built against, and every `effect/unstable/*` module this
104
+ repo uses (`httpapi`, `http`, `rpc`, `reactivity`, `persistence`, `sql`)
105
+ compiles and tests clean on beta.107.
106
+
107
+ **Effect API drift absorbed (no behavioural change):**
108
+
109
+ - `Schedule.either` / `Schedule.both` are gone; the default retry policy is now
110
+ `Schedule.max([Schedule.min([exponential, spaced]), recurs(5)])`, which is
111
+ the same policy (`min` recurs while any schedule recurs at the shortest
112
+ delay, `max` recurs while all recur at the longest delay).
113
+ - `Schema.TaggedErrorClass` is now `Schema.TaggedError` (the wire
114
+ `SyncRpcError` is unchanged on the wire).
115
+ - `SchemaRepresentation.fromASTs` is now `SchemaRepresentation.toRepresentations`
116
+ (spec export output is unchanged).
117
+ - `HttpApiGroup.Any` is gone and `HttpApi`'s group parameter is now
118
+ invariant, so `JsonApiSync.layer`, `JsonApiSync.inspect` and
119
+ `Convention.reflectResources` are generic over the concrete `HttpApi`
120
+ instead of accepting a widened alias. The exported `JsonApiSync.JsonApi`
121
+ alias is now `HttpApi.Constraint` (identifier and groups erased); pass your
122
+ concrete api to the functions rather than annotating it with the alias.
123
+
124
+ **effect-jsonapi 0.6 → 0.12 at the ADR-0016 boundary.** `Document`, `Query`,
125
+ `Resource`, `Relationship` and `Endpoint` lost no exports; `Query` gained
126
+ `IncludeOption`/`IncludePathsOf`/`OffsetPageStruct`/`bracketPageKeys` and
127
+ `Resource` gained `IncludeDepth`/`IncludePathsTo`. `schema.ts`, `types.ts` and
128
+ `CollectionLoader.ts` compile unchanged. One typing regression was noticed in
129
+ 0.12: endpoints with `:id` params (`Endpoint.get`/`delete`/`update`) declare
130
+ their params as `Schema.Struct<Schema.Struct.Fields & { id }>`, which leaks
131
+ `unknown` into the requirements of the corresponding `HttpApiClient` calls.
132
+ The commerce example narrows this with a small `run` helper in
133
+ `server/client.ts`; nothing in the published packages is affected.
134
+
135
+ The bump is `minor` rather than `patch` because the exact `effect` peer pin
136
+ means every consumer must move `effect` in the same change, and the
137
+ `@thomasfosterau/legation-jsonapi` type signatures above changed shape.
138
+
139
+ - 19eb4b0: Bump the pinned Effect version from `4.0.0-beta.65` to `4.0.0-beta.75` across all packages.
140
+
141
+ `@thomasfosterau/legation-browser` now builds its IndexedDB cache store on
142
+ `@effect/platform-browser` (`BrowserKeyValueStore.layerIndexedDb` + core's
143
+ `KeyValueStoreAdapter`) instead of a hand-rolled IndexedDB wrapper.
144
+ `@effect/platform-browser` is a new peer dependency of the browser package, and
145
+ `IndexedDbCacheStore.make` is now a scoped resource (requires `Scope`); the
146
+ `storeName` option was removed (the underlying object store is managed by
147
+ platform-browser).
148
+
149
+ - 38c3147: Effect-idiomatic API refactor:
150
+
151
+ - Protocol messages (`ClientMessage`, `ServerMessage`, `PushResult`, `WireKeyFilter`), cache
152
+ events (`CacheEvent`), and outbox events (`OutboxEvent`) are now `Data.TaggedEnum`s with
153
+ generated constructors, `$is` guards, and exhaustive `$match` matchers
154
+ (e.g. `Protocol.ClientMessage.Subscribe({...})`). The types are structurally unchanged, so
155
+ existing object-literal construction still compiles.
156
+ - `SyncCacheDefaults` `Context.Reference` for ambient cache timing defaults
157
+ (staleTime / timeToLive / staleWhileRevalidate).
158
+ - The cache's in-flight dedupe map uses `MutableHashMap`; background stale-while-revalidate
159
+ refresh failures are now logged instead of silently swallowed.
160
+ - The WebSocket transports validate incoming frames with `ProtocolSchema` codecs instead of
161
+ unchecked `JSON.parse` casts, and bridge socket events into Effect without `Effect.runFork`
162
+ inside callbacks.
163
+ - All tests use `@effect/vitest` (`it.effect` / `it.live` / `it.layer`, `TestClock`).
164
+
165
+ - fed4634: Peer the Effect v4 release-candidate line through an open floor rather than
166
+ an exact pin (#222), so an app can install `@thomasfosterau/legation-*` beside
167
+ `@thomasfosterau/effect-auth` and `@thomasfosterau/effect-jsonapi` and resolve
168
+ a single copy of `effect`. The published floor is `>=4.0.0-rc.118` (see
169
+ `effect-rc118-floor.md`), on `effect` and on the `@effect/platform-browser` /
170
+ `@effect/platform-node` peers.
171
+
172
+ **`@thomasfosterau/legation-jsonapi`**: a repeated shorthand filter key
173
+ (`?filter[name]=a&filter[name]=b`) is read as the comma list it spells
174
+ (`filter[name]=a,b`, an `In` predicate) rather than rejected with a 400,
175
+ because `@thomasfosterau/effect-jsonapi` accepts the repeated-key spelling at
176
+ every list-valued position.
177
+
178
+ The packages run in browsers and workers and deliberately declare no `engines`
179
+ (ADR-0003).
180
+
181
+ - fcc52eb: Target Effect `4.0.0-rc.118`: every package peers `effect >=4.0.0-rc.118`
182
+ (#261), and the `@effect/platform-browser` / `@effect/platform-node` peers use
183
+ the same floor. Install `effect` and every `@effect/*` companion at
184
+ `4.0.0-rc.118` or later together.
185
+
186
+ The packages import Effect's modules at their rc.118 paths (`effect/rpc`,
187
+ `effect/http`, `effect/http-api`, …); rc.118 removed the `effect/unstable/*`
188
+ export paths, so the packages don't resolve beside an earlier `effect`.
189
+
190
+ **`@thomasfosterau/legation-jsonapi`** depends on `@thomasfosterau/effect-jsonapi@^0.16.1`,
191
+ the first release that peers `effect >=4.0.0-rc.118`.
192
+
193
+ **`@thomasfosterau/legation-svelte`** depends on `@thomasfosterau/effect-svelte@^0.4.0`, for
194
+ the same reason.
195
+
196
+ - 2003cd6: Built-in multi-tab leader election for the no-service-worker degraded
197
+ topology (#119): `@thomasfosterau/legation-browser`'s `ElectedHost.run` wraps
198
+ `navigator.locks` (held for the tab's whole lifetime, no voluntary
199
+ step-down) around `ServiceWorkerHost.run`'s main-thread host composition —
200
+ `ClientEngine` + durable store + transports + `BroadcastMirrorChannel`
201
+ serving while elected, a `Client.connect` mirror over the same channel
202
+ while not. It hands back one `Client` that keeps working unchanged across
203
+ the mirror→host promotion (a switching `ClientView` swaps the underlying
204
+ tier, replaying explicit subscriptions and re-hydrating live
205
+ `observe`/`related` streams) and a `role: Stream<"host" | "mirror">` for
206
+ devtools/debug UI. A promoted tab boots against the SAME durable store —
207
+ same journal, rows, and cookie — so the CVR conversation resumes rather
208
+ than restarting. Environments without `navigator.locks` get a documented
209
+ fallback (becomes host immediately), not a polyfill.
210
+
211
+ `@thomasfosterau/legation`'s `Mirror` now fails any request still in flight when
212
+ the host it was talking to is gone — either because the mirror's own scope
213
+ closed, or because a second `HostReady` announces the host restarted — with
214
+ a clear `TransportError` instead of leaving the caller's `mutate` /
215
+ `mutateWithAck` / server-fallthrough `read` call hanging forever. A
216
+ mutation already journaled by the old host before it died is never lost:
217
+ it's durable in the shared store and the next host's sync loop picks it up
218
+ regardless of what this caller was told.
219
+
220
+ - c105fbf: Drive HTTP-cache invalidation from collection changes (#49).
221
+
222
+ - **`PullCompleted` enrichment** (`@thomasfosterau/legation`): the `PullCompleted`
223
+ `EngineEvent` now carries `collections` — the distinct collections the pulled
224
+ patch touched (a `Clear` stands for every collection the client currently
225
+ holds, since it resets the whole view). The field is always present. This lets
226
+ a collection-scoped reaction fire even on a client with an empty journal (the
227
+ common case, where a pull emits a `Patch` change and no `ViewRebuilt`).
228
+
229
+ - **`HttpCacheInvalidation`** (`@thomasfosterau/legation-browser`): `invalidateOnChanges` forks
230
+ a scoped fibre that evicts every cached response tagged for a changed
231
+ collection via the store's `invalidateTags`; a store error is logged and
232
+ swallowed, never fatal. `collectionChanges` derives the change feed from an
233
+ engine's `engineEvents` (mapping `PullCompleted.collections` and scoped
234
+ `ViewRebuilt.collections`, dropping empties). Wire it in a service worker by
235
+ forking `invalidateOnChanges` alongside `ServiceWorkerHost.run` (recipe in the
236
+ host's doc comment).
237
+
238
+ The tag convention is `collection:{name}` (the default `tagFor`): a response
239
+ tagged `collection:todos` is evicted whenever `todos` changes. This is a
240
+ collection-level granularity ceiling — every response tagged for a changed
241
+ collection is evicted; a per-row `collection:{name}:{id}` convention could
242
+ follow once patch-op row ids are plumbed through to the change feed (not
243
+ implemented here).
244
+
245
+ - e1a028a: Per-row HTTP-cache tags (#207): row-granular cache invalidation, closing the
246
+ collection-level ceiling noted in `.changeset/http-cache-invalidation.md`.
247
+
248
+ - **Change feed carries row ids** (`@thomasfosterau/legation`): the `PullCompleted`
249
+ `EngineEvent` now also carries `rows` — the `(collection, id)` pairs the
250
+ pulled patch's `Put`/`Del` ops touched (a `Clear` contributes to
251
+ `collections` only, since it has no finite row list). Always present,
252
+ possibly empty.
253
+
254
+ - **Fine tag convention** (`@thomasfosterau/legation-browser`): `HttpCacheInvalidation` gains
255
+ `defaultFineTagFor` — `collection:{name}:{id}`, one level finer than the
256
+ existing `collection:{name}` (`defaultTagFor`). `collectionChanges` now
257
+ yields `Touched` (`{ collections, rows }`) and `invalidateOnChanges` evicts
258
+ the union of both tag sets on every change. The coarse eviction is kept
259
+ deliberately even though every touched row's collection is already implied:
260
+ an aggregate document can be staled by a row it does not yet contain, which
261
+ has no fine tag to match.
262
+
263
+ - **Response tagging + budget** (`@thomasfosterau/legation-browser`): `tagsFor(collection,
264
+ rowIds)` builds a response's tags — coarse plus one fine tag per row, up to
265
+ `FINE_TAG_ROW_BUDGET` (43, derived in a comment against the real ceiling on
266
+ the `x-legation-cache-meta` header these tags ride on). Beyond the budget, a
267
+ response is tagged coarse-only with `budgetExceeded: true` on the result.
268
+ `tagsForObserved` pairs the same computation with a logged warning and the
269
+ new `legation_cache_tag_budget_exceeded_total` counter
270
+ (`Telemetry.cacheTagBudgetExceededTotal`), so the fallback is observable
271
+ rather than silent.
272
+
273
+ Both granularities share one tag spelling across the client-side
274
+ invalidation path and response tagging, so the edge/KV invalidation sink
275
+ (#206) can reuse it unchanged.
276
+
277
+ - Scope the durable replica to a resolved identity (#212), closing the gap where
278
+ adopting legation would make a multi-tenant app on a shared device _less_ safe
279
+ than what it replaced. The invariant, now in `CONTEXT.md` beside the HTTP-cache
280
+ section: **a durable replica belongs to exactly one resolved identity, and an
281
+ identity change is a reset, not a rebase.**
282
+
283
+ Identity is resolved server-side per pull, which is what makes the auth filter
284
+ trustworthy — but the replica the authorised rows land in outlives the session
285
+ that authorised them. A signs out, B signs in, and B's Client boots onto A's
286
+ rows because they are the freshest thing on the device and nothing recorded
287
+ which identity authorised them. A's unpushed journal made it worse: durable,
288
+ causally ordered, and about to go out under B's cookies.
289
+
290
+ - **`Identity` (`@thomasfosterau/legation`, new)**: `Fingerprint`, the opaque
291
+ app-supplied token a replica is stamped with — a session-subject hash, **never
292
+ PII and never a credential**, as the type's own docs say. `anonymous` is the
293
+ signed-out principal; `unstamped` is a replica that has never been scoped, and
294
+ equals nothing. `transitionOf` is the whole rule, in one place so boot and
295
+ runtime cannot disagree: `Unchanged`, `Upgrade` (anonymous → signed in, the
296
+ one benign transition), or `Reset`.
297
+
298
+ - **`ClientStore` identity boundary**: a new `identity` option stamps the
299
+ fingerprint and compares it on open. A mismatch quarantines the unpushed
300
+ journal under the **superseded** fingerprint, then drops confirmed rows and
301
+ the pull cookie. New surface: `getIdentity` / `setIdentity`,
302
+ `quarantinePending` / `quarantinedMutations` / `quarantines` /
303
+ `releaseQuarantine` / `discardQuarantine`, and `clearCookie`. A boundary that
304
+ cannot be established **seals** the store — every operation fails loudly,
305
+ nothing is served, nothing is destroyed, and the next open retries — rather
306
+ than reporting a boundary that isn't there.
307
+
308
+ - **Quarantine is its own keyspace**, which is the security property rather than
309
+ an implementation detail: `pendingMutations` reads the journal and `push`
310
+ reads `pendingMutations`, so a quarantined entry is structurally unreachable
311
+ from any push path — a background flush included — under any identity but the
312
+ one it is keyed by. Mutation ids are preserved rather than renumbered, so an
313
+ entry that was in flight at reset is at worst acknowledged and skipped by the
314
+ backend's exactly-once ledger, never applied twice. Only
315
+ `discardQuarantine` destroys one: no reset and no boot does.
316
+
317
+ - **`Client.reset(fingerprint)`** applies the same rule at runtime, dropping the
318
+ Client's window ledger along with the rows so coverage cannot answer the new
319
+ principal from the old replica. `Client.make` takes an `identity` and adopts
320
+ it before the sync loop starts. `identity`, `quarantines`,
321
+ `quarantinedMutations` and `discardQuarantine` round out the surface; a
322
+ Client built with `connect` says the host owns the replica rather than faking
323
+ a reset.
324
+
325
+ - **The sync client id is scoped to the principal** while the boundary is on
326
+ (derived from the configured `clientId` plus the fingerprint). Mutation ids
327
+ are a per-client sequence the backend requires to be contiguous, so a second
328
+ principal continuing the first's numbering would push a gap and restarting it
329
+ would collide with ids already applied; scoping gives each principal its own
330
+ sequence, and gives a returning one back exactly the sequence they left.
331
+
332
+ - **`IndexedDbClientStore` (`@thomasfosterau/legation-browser`)** takes an `identity`, and
333
+ `layerNamespaced` offers the per-identity replica as the alternative to
334
+ resetting — namespace for a device with a few regular users who switch back
335
+ and forth, reset where sign-ins are one-off or unbounded.
336
+
337
+ Opt-in: with no `identity` configured, nothing is stamped, nothing is compared,
338
+ and behaviour is unchanged. Turning it on resets an unstamped replica once,
339
+ which is the safe direction — an unstamped replica is exactly the state this
340
+ boundary exists to distrust.
341
+
342
+ - a3b2ee4: The JSON:API façade (ADR-0029, issue #166): a fetch handler that answers a
343
+ covered JSON:API `GET` from the replica and stays out of the way otherwise.
344
+
345
+ - `JsonApiFacade.fetchHandler({ nodes, api, view, coverageOf, cookie?, resources?, baseUrl?, prefix?, online? })`
346
+ in `@thomasfosterau/legation-jsonapi` builds the façade from the app's `HttpApi`
347
+ (`Convention.reflectResources` gives the `list` / `getOne` routes it
348
+ recognises; those whose type is no node are left to the server), the
349
+ node graph, the replica's view and a coverage source. It presumes the
350
+ api's read endpoints are declared with the graph's codec
351
+ (`JsonApiQuery.querySchema` / `singleRowSchema`) and served by the
352
+ derived handlers, which is what lets a parse failure be answered locally.
353
+ `matches(request)` says whether a request is its business — a `GET` whose
354
+ `Accept` names `application/vnd.api+json` on a reflected route under
355
+ `baseUrl` — and `handle(request)` answers: a covered request gets `200`
356
+ with the document the one writer produces (byte-equal in content to the
357
+ derived REST handler's for the same declarations, the optimistic overlay
358
+ visible as `Client.query` sees it), `Cache-Control: no-store` and
359
+ `x-legation-served-at` set to the replica's CVR cookie (the store's,
360
+ empty until the first pull); a URL the grammar rejects gets the codec's
361
+ `400` document (`RestHandlers.queryErrorDocument`), except an `include`
362
+ the graph does not declare, which the server rules on; an uncovered
363
+ request while offline gets the `503` `offline` error document of
364
+ ADR-0029 §4 (`handler.offline`, with the cookie, also for a forward that
365
+ rejected at the transport). Everything else is `undefined` for the caller
366
+ to forward unchanged: a non-candidate, an uncovered request while online,
367
+ a collection no bound resource declares, and a single-resource request
368
+ whose row the replica does not hold (the 404 is the endpoint's own).
369
+ Coverage is every read `ReplicaDocuments.toCover` names, each judged by
370
+ `coverageOf` — `client.coverageOf`, or the host's registrations below.
371
+ The module lives in `@thomasfosterau/legation-jsonapi` by ADR-0006: it needs this
372
+ package's codec, writer and route reflection plus the standard
373
+ `Request` / `Response` globals, and `navigator.onLine` is an option with a
374
+ guarded default, so `@thomasfosterau/legation-browser` stays free of effect-jsonapi.
375
+ - `EngineHost.run` returns a `Host` whose `registrations` lists what the
376
+ host holds on its mirrors' behalf — root windows, relational registrations
377
+ with their edge selects, both halves of a `SubscribeUrl` with its canonical
378
+ URL while a party holds it by URL — as `Coverage.Subscription`s: the
379
+ ledger a façade's coverage check scans in a worker, where no `Client`'s
380
+ own ledger sees the tabs' pins. A registration is listed once its engine
381
+ registration exists (a relational one once its reconcile loop has
382
+ started; one naming an edge the registry does not declare registers
383
+ nothing) and dropped when the last mirror releases it or is swept.
384
+ `Coverage.verdictOf` and
385
+ `Coverage.coverageOf(nodes, subscriptions)` give the same `Verdict` as
386
+ `Client.coverageOf` over any such ledger; `Client` uses the former.
387
+ - `ServiceWorkerHost.start` serves exactly as `run` does and returns the
388
+ `Host` — the engine and its registrations — for the worker to build the
389
+ façade from; `run` is `start` then serve forever.
390
+ - The commerce worker composes façade, then its unchanged HTTP cache, then
391
+ the network (`examples/commerce/src/service-worker.ts`,
392
+ `src/lib/facade.ts`, `src/lib/host.ts`); the api's shape moves to
393
+ `server/api.ts` so the worker reflects the routes the server serves; and
394
+ the replica test runs the façade over the same seed: every URL the
395
+ shell's pins cover is answered with the served document's bytes, and the
396
+ rest go to the network. `Convention.segmentsOf` and `Convention.isParam`
397
+ are exported.
398
+
399
+ - 3538a81: Depend on the engine core under its new name, `@thomasfosterau/legation` (formerly `@thomasfosterau/legation-effect`), and its engine seams at `@thomasfosterau/legation/internal`. Install `@thomasfosterau/legation` alongside these packages and import the core from it.
400
+ - 501b12c: The mirror seam sheds its browser-tab coupling: the party served by
401
+ `EngineHost` is any client context — a browser tab, an Electron window,
402
+ an extension, a CLI instance — so the vocabulary now says what the
403
+ contract means. `TabMirror` is `Mirror` (module, namespace, and type),
404
+ `tabId` is `mirrorId` (wire field included), `TabMessage` is
405
+ `MirrorMessage` (`decodeTabMessage` → `decodeMirrorMessage`), the host's
406
+ liveness option `tabTimeout` is `mirrorTimeout`, and
407
+ `MirrorChannel.makeLoopbackPair` returns `{ host, mirror }`. Clean
408
+ break, no aliases; `@thomasfosterau/legation-browser` re-exports the new names.
409
+ - 7f1efac: Named queries and auto-subscribing relational reads cross the mirror
410
+ protocol (ADR-0021 follow-up). `ClientView` gains
411
+ `subscribeQuery`/`unsubscribeQuery`/`queryNamed`/`observeNamed` and
412
+ `related` — both hosts satisfy the widened seam (`ClientViewError` now
413
+ includes `QueryError`). The tab↔host protocol adds
414
+ `SubscribeNamed`/`UnsubscribeNamed` (the host engine owns the server
415
+ round-trip) and `SubscribeRelated`/`UnsubscribeRelated`, where the host runs
416
+ the reconcile loop AND the join and the tab receives resolved rows
417
+ (`RelatedUpdate`) — data work stays off the main thread. `EngineHost` takes
418
+ an optional `registry` for relational resolution and refcounts all
419
+ registration kinds per tab. A new `HostReady` announcement makes
420
+ registrations survive host restarts: a starting host broadcasts it and
421
+ every mirror replays the registrations it holds — covering killed service
422
+ workers and tabs that raced the worker's boot. On a mirror, observing IS
423
+ subscribing: `TabMirror.observe`/`observeNamed`/`related` auto-register
424
+ their subject with the host for the stream's lifetime through a per-key
425
+ interest ledger (a mirror only carries keys some tab registered, and the
426
+ host refcounts per tab, so the ledger posts `Subscribe` on first interest
427
+ and `Unsubscribe` only when the last observer ends and no explicit
428
+ `subscribe` pins the key). In `@thomasfosterau/legation-svelte`, `namedQuery`,
429
+ `useNamedQuery`, and `useRelated({ subscribe: true })` now work over
430
+ mirror-only runtimes (the internal engine-lookup fallback is removed).
431
+ - cf0b26c: The mirror seam moves to core, where ADR-0006's dependency rule places it:
432
+ `MirrorProtocol`, the `MirrorChannel` interface + loopback pair,
433
+ `EngineHost`, and `TabMirror` are exported from `@thomasfosterau/legation` — none
434
+ of them needs a browser global, so Electron windows, extensions, and CLI
435
+ processes can host or consume mirrors without `@thomasfosterau/legation-browser`. The
436
+ browser package keeps what is actually browser-coupled — the new
437
+ `BroadcastMirrorChannel.make(name)` binding (formerly
438
+ `MirrorChannel.makeBroadcastChannel`) and `ServiceWorkerHost` — and
439
+ re-exports the moved modules for compatibility.
440
+ - f54f568: Per-mutation outcome surface (#36). Callers can now await a mutation's
441
+ authoritative backend outcome — applied, or permanently rejected with a reason
442
+ (#35) — rather than only its journal ack.
443
+
444
+ - `ClientEngine.mutateWithAck` returns a `MutationHandle` whose `acknowledged`
445
+ effect resolves with the `MutationOutcome` once the backend settles the
446
+ mutation via a push or pull acknowledgement. `ClientEngine.mutationEvents`
447
+ streams the lifecycle (`journaled`, then `acknowledged` or `rejected`). Plain
448
+ `mutate`/`mutateEncoded` are unchanged and register no deferred; pending
449
+ acknowledgements are interrupted on engine scope close, so they never hang.
450
+ - Svelte gains `useMutationWithAck` (over an additive
451
+ `EngineAtoms.mutationWithAck` atom), returning a promise of the outcome.
452
+ - The tab mirror forwards the outcome: `MirrorProtocol` gains an additive
453
+ `MutateSettled` host→tab message, `EngineHost` awaits the backend verdict on a
454
+ scoped fibre and posts it, and `TabMirror.mutateWithAck` awaits it (the
455
+ existing journal-ack `mutate` is unchanged).
456
+
457
+ - 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.
458
+
459
+ 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.
460
+
461
+ - 9d2f21b: Rename the project to **Legation**. Every package moves to the `@legation`
462
+ scope: the engine is now `@legation/effect` (formerly
463
+ `@thomasfosterau/effect-sync-core`), and the adapters/bindings are
464
+ `@legation/browser`, `@legation/node`, `@legation/jsonapi`, `@legation/sql`,
465
+ and `@legation/svelte` (formerly `@thomasfosterau/effect-sync-*`).
466
+
467
+ Runtime identifiers follow the brand, which is **breaking for persisted local
468
+ state and dashboards**:
469
+
470
+ - `Context.Service` identifiers: `@thomasfosterau/effect-sync-core/*` →
471
+ `@legation/effect/*` (and per-package equivalents)
472
+ - Client-store key prefix: `effect-sync/` → `legation/`; CVR store prefix:
473
+ `effect-sync/cvr/` → `legation/cvr/`. Existing IndexedDB/KV state under the
474
+ old prefix is orphaned — clients rehydrate via a full pull; export any
475
+ pending journal before upgrading offline clients.
476
+ - Metrics: `effect_sync_*` → `legation_*`; spans: `effect-sync.*` →
477
+ `legation.*`
478
+ - Default BroadcastChannel name: `effect-sync` → `legation`; Redis hint
479
+ stream: `effect-sync:hints` → `legation:hints`; cache metadata header:
480
+ `x-effect-sync-cache-meta` → `x-legation-cache-meta`
481
+
482
+ - c189ae7: Add a runtime-specific adapter package:
483
+
484
+ - `@thomasfosterau/legation-browser` — WebSocket client transport (`ClientTransport` over the browser `WebSocket` global) and IndexedDB-backed `CacheStore` persistence for offline-first apps.
485
+
486
+ - 16889f0: Rename every package from the `@legation/*` scope to
487
+ `@thomasfosterau/legation-*` (#303): `@legation/effect` is now
488
+ `@thomasfosterau/legation`, and likewise for `browser`, `cloudflare`,
489
+ `devtools`, `jsonapi`, `node`, `sql` and `svelte`. Subpath entries keep their
490
+ paths (`@thomasfosterau/legation/internal`,
491
+ `@thomasfosterau/legation-svelte/kit`,
492
+ `@thomasfosterau/legation-devtools/svelte`). Update imports and dependency
493
+ names accordingly; no `@legation/*` name was ever published to npm.
494
+
495
+ `Context.Service` identifiers and `Symbol.for` keys follow the package name
496
+ (`@legation/effect/Client` → `@thomasfosterau/legation/Client`, and
497
+ per-package equivalents). The reserved identity sentinels
498
+ (`@legation/anonymous`, `@legation/unstamped`), the `legation/` client-store
499
+ prefixes, metric and span names are unchanged.
500
+
501
+ - 9a6426b: Sync-loop scheduling (#33). `ClientEngine` grows a configurable scheduler on
502
+ `runSync`, driven by `Effect.Schedule` and the existing `statusChanges` seam:
503
+
504
+ - `syncLoop.pushDebounce` auto-pushes after `mutate`/`mutateEncoded`, debounced
505
+ so a burst of mutations coalesces into a single push.
506
+ - `syncLoop.pullInterval` pulls periodically as the correctness backstop, so a
507
+ client converges over pull alone even without pokes.
508
+ - The baseline triggers (pull on poke, push+pull on reconnect) are retained,
509
+ and every scheduling fibre is scoped — interrupted when the engine scope
510
+ closes.
511
+
512
+ `@thomasfosterau/legation-browser` adds `BrowserConnectivity.withBrowserConnectivity`, which
513
+ layers `navigator.onLine` + `online`/`offline` + `visibilitychange` onto any
514
+ `ClientTransport` so `statusChanges` becomes truthful for carriers (like the RPC
515
+ transport) that otherwise report themselves permanently connected, and a
516
+ return-to-visible surfaces a poke.
517
+
518
+ `@thomasfosterau/legation-jsonapi` `JsonApiSync` now configures the core loop (`pullInterval`,
519
+ `pushDebounce` options, 5-second default interval) instead of running its own
520
+ `Effect.forever` poll; `JsonApiSync.start` just runs `runSync`.
521
+
522
+ - e34bc24: Connection-status realism at the transport seam: `Transport.withInferredStatus`
523
+ derives real connectivity from observed push/pull behaviour ("connecting"
524
+ until the first call settles, "connected" on success, "disconnected" on a
525
+ `TransportError`) — wrapping the default RPC carrier, which reports itself
526
+ permanently "connected". `Transport.withFallback(primary, fallback)` layers a
527
+ fallback carrier (e.g. plain HTTP under WebSockets) with a sideline cooldown
528
+ (`restoreAfter`, default 30s), an optional per-call primary time budget
529
+ (`primaryTimeout` — a hanging primary is interrupted, sidelined, and the call
530
+ falls to the fallback instead of stalling sync), merged pokes that survive a
531
+ failing primary poke stream, inferred status ("connected" while either
532
+ carrier serves), and version rejections propagating without falling back.
533
+ `BrowserConnectivity.withBrowserConnectivity` now combines `navigator.onLine`
534
+ with the wrapped transport's own status (worst of the two wins) instead of
535
+ replacing it.
536
+ - 1b8b120: URL-keyed subscriptions over the mirror channel (ADR-0029 section 2, issue #159).
537
+
538
+ - `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)`.
539
+ - 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.
540
+ - 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).
541
+ - 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.
542
+ - 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.
543
+ - `Query.tryPlan(nodes, read)` is `Query.plan` with its `QueryError` as a typed failure.
544
+
545
+ - 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`.
546
+
547
+ ### Patch Changes
548
+
549
+ - ef59fa7: A durable pull now writes once, and an interrupted pull is never recorded. The
550
+ key-value seam gains an optional `AtomicKeyValue` service on
551
+ `@thomasfosterau/legation/internal`: an all-or-nothing `apply` over
552
+ `set` and `remove` operations. When a host supplies it, `ClientStore` commits a
553
+ pull's rows, cookie, journal trim and unpulled-tail release as one batch, with
554
+ no release record, and a push acknowledgement's park and trim as one batch. A
555
+ store without the service keeps the release-record path from #387, unchanged,
556
+ and an open still finishes a leftover record either way.
557
+
558
+ `@thomasfosterau/legation-browser` supplies the service over IndexedDB: one
559
+ `readwrite` transaction on the database and object store the platform
560
+ `KeyValueStore` already uses, so existing databases need no migration. The
561
+ database is opened without a version, so one a later version created still
562
+ opens; the browser store's `KeyValueStore` opens it the same way, in place of
563
+ the platform's, which pins version 1 and fails on a newer database.
564
+
565
+ If another connection upgrades that database, or deletes it, the store's
566
+ connections close, since they would otherwise block the change. From then on
567
+ every call fails with "the IndexedDB database was upgraded by another
568
+ connection; reload to continue", and the store does not reopen itself. A
569
+ database without the `entries` store fails its calls with a message that names
570
+ that store, and a sealed store's message now carries the reason from its cause.
571
+
572
+ - 309f682: Background Sync's flush can now pull between chunks when the journal is larger than half the unpulled bound (see the `@thomasfosterau/legation` changeset). A flush whose pull between chunks fails now rejects its `sync` event with a transport error instead of reporting `Flushed`, so the browser reschedules it and the journal waits for the next flush.
573
+ - 4c59860: Add `observeChanges(subset, options?)`: a public row-level change feed
574
+ (`RowChange`, #115) on `ClientEngine`, `ClientView`, and `Mirror` alike
575
+ (ADR-0021), for keyed-list bindings, side-effect subscriptions, and delta
576
+ pipelines that today have to diff `observe()`'s pages by hand. Batched per
577
+ view-step:
578
+
579
+ - `enter`/`exit` are anchored to the subset's windowed result — a predicate
580
+ or limit-boundary crossing (an insert pushing the last visible row out, a
581
+ delete pulling the next one in) produces the pair a consumer diffing two
582
+ pages would derive; `update` marks a same-membership value change.
583
+ - A `Rebuild` (the optimistic overlay shifted) or a seq-gap collapse (a
584
+ bounded change hub dropped one or more changes) emits a `reset` marker
585
+ followed by fresh `enter`s for the current window, rather than a diff
586
+ against unknown prior state.
587
+ - `{ includeInitial: true }` emits the current window as `enter`s in the
588
+ first batch, with the same subscribe-before-snapshot guard `observe`
589
+ already uses (#84) — a late subscriber sees exactly one batch, no gap or
590
+ duplicate row.
591
+
592
+ `ClientEngine` derives batches from the same incrementally-maintained
593
+ `LiveView` window `observe` already keeps (surfaced, not recomputed);
594
+ `Mirror` — which has no local `LiveView` — diffs the whole-page updates its
595
+ host already broadcasts, so both hosts emit identical batches for identical
596
+ page transitions. `@thomasfosterau/legation-browser`'s `ElectedHost` switches an observer
597
+ onto whichever `ClientView` (mirror or owned engine) is currently active, so
598
+ `observeChanges` works unchanged across a leader-election transition too.
599
+
600
+ A confirmed-row pull and a plain local mutation (#109's delta-maintained
601
+ overlay tracks a mutator's local handler directly and publishes exactly what
602
+ it did) both diff precisely; `Rebuild` is reserved for cases the overlay
603
+ genuinely can't diff — a pull racing a still-pending mutation, an unknown
604
+ mutator, a reset (`Clear`) patch — or a seq-gap collapse.
605
+
606
+ Move events are not yet part of the contract: `LiveView` rows carry a stable
607
+ position (#112) at runtime, but a row repositioned by a neighbor's
608
+ insert/delete without its own value changing isn't distinguished from
609
+ "unchanged" here — a consumer that needs order re-derives it from a paired
610
+ `observe()` or the position of `enter`s within a batch, pending that
611
+ follow-up. See `docs/observing-row-changes.md` for the framework-binding
612
+ guide (a Svelte 5 keyed-list example) and the `runOnChanges`-style
613
+ side-effect pattern.
614
+
615
+ - 62dcb1c: `Hydration.dehydrate` now reads the store as one state. It used to read the
616
+ confirmed rows, the cookie, the journal and the unpulled tail in four separate
617
+ calls, so a pull or a push acknowledgement that landed between them could leave
618
+ a snapshot whose rows lacked an acknowledged mutation's effect, while that
619
+ mutation sat in neither the journal nor the tail. `ClientStore` gains an
620
+ optional `snapshot` member that reads all four together: under the store's lock
621
+ for the memory and key-value stores, and in one read transaction on IndexedDB.
622
+ `dehydrate` uses it when the store offers it and reads member by member
623
+ otherwise, so a third-party store behaves as before.
624
+
625
+ The `AtomicKeyValue` seam on `@thomasfosterau/legation/internal` gains an
626
+ optional `readAll`, which the browser's IndexedDB binding supplies as one
627
+ `readonly` transaction over the entries store. A key-value store with that seam
628
+ decodes its snapshot from that single read.
629
+
630
+ - 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.
631
+ - 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.
632
+ - d1b01a4: Tested against `effect` 4.0.0 (and the matching `@effect/*` companions); the
633
+ peer floor is unchanged at `effect >=4.0.0-rc.118`.
634
+
635
+ `@thomasfosterau/legation` adapts to 4.0.0's stricter, type-only `Schema.brand`:
636
+
637
+ - `Node.Id` keeps its signature and runtime behaviour; its internal brand
638
+ identifier is cast to satisfy the new concrete-identifier check.
639
+ - `Node.AnyNode`'s `Id` property is now
640
+ `Schema.Codec<string & Brand.Brand<string>, string>` instead of `Id<string>`.
641
+ 4.0.0's `brand<String, B>` is type-only and no longer assignable to a wider
642
+ ``brand<String, `${string}/id`>``, so no concrete `Node<"todos", …>` was
643
+ assignable to `AnyNode`. Every concrete node is assignable again, and `Id`
644
+ is still a string-encoded branded id schema; only code that reads `.Id` off
645
+ an `AnyNode`-typed value sees the wider brand.
646
+
647
+ Checked the 4.0.0 behavioural changes against the packages and found no impact:
648
+ no use of `partition`/`separate`/`Option.partitionMap`, `Queue.State`,
649
+ `MessageStorage` or `RpcMessage.ExitEncoded`. The RPC client's missed-pong
650
+ handling now fails in-flight WebSocket calls rather than reporting a transient
651
+ error; `RpcTransport.fromOrigin` already treats a failed WebSocket call as a
652
+ `TransportError` and falls back to HTTP. The RPC server's WebSocket route now
653
+ closes with 1000/1001/1011, which nothing in the packages inspects.
654
+
655
+ - a874acb: Bump the pinned Effect version from `4.0.0-beta.75` to `4.0.0-beta.83` across all
656
+ packages (no engine API changes; build, typecheck, and the full test suite pass
657
+ unchanged).
658
+
659
+ The version is now centralised in a root **Bun catalog**
660
+ (`package.json` → `workspaces.catalog`): every package's `devDependencies`
661
+ reference `effect`/`@effect/platform-*`/`@effect/vitest` as `catalog:`, while the
662
+ published `peerDependencies` keep the concrete `4.0.0-beta.83` string. The
663
+ Every example is a workspace member so they share the same `effect` version
664
+ as the engine instead of pinning its own.
665
+
666
+ - c5d3484: README: the `effect` floor (`4.0.0-rc.118`) is now tested on every pull
667
+ request, where CI runs the test suite against it, instead of in a weekly
668
+ canary. The peer range (`effect >=4.0.0-rc.118`) and the tested pin (`4.0.0`)
669
+ are unchanged.
670
+ - e9363c0: Peer `effect ^4.0.0` (was `>=4.0.0-rc.118`), and `@effect/platform-node` /
671
+ `@effect/platform-browser` `^4.0.0` likewise, per the peer-range policy: the
672
+ range is raised only when a dependency requires it. The range form changes
673
+ (the caret no longer admits the 4.0 release candidates or Effect 5); every
674
+ package's tests pass at `effect` 4.0.0.
675
+
676
+ `@thomasfosterau/legation-svelte` peers `effect ^4.0.2`: its dependency
677
+ `@thomasfosterau/effect-svelte` 0.6.0 peers `effect >=4.0.2`.
678
+
679
+ - 604ffd3: The IndexedDB-backed `KeyValueStore` implements `keys`, which `KeyValueStore.make` requires from Effect 4.1. On Effect 4.0 the member is unused.
680
+ - 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.
681
+ - 507ccb1: Every tagged error is now a branded `Schema.TaggedError` rather than a
682
+ `Data.TaggedError`: `StoreError`, `TransportError`, `QueryError` and the rest
683
+ of the engine's errors, the JSON:API adapter's `SyncError` / `NetworkError` /
684
+ `ParseError` / `ApiError` / `ConflictError` / `ResourceBridgeError`, and
685
+ `HostNotReady`. Construction, `_tag`, `message` and `Effect.catchTag` are
686
+ unchanged; each class now carries a schema and a nominal brand, so a
687
+ structurally similar object no longer type-checks as one.
688
+ - 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.
689
+ - 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.
690
+ - Updated dependencies [2a1f63c]
691
+ - Updated dependencies [ef59fa7]
692
+ - Updated dependencies [56a2e51]
693
+ - Updated dependencies [d09dc65]
694
+ - Updated dependencies [db2366a]
695
+ - Updated dependencies [97db489]
696
+ - Updated dependencies [cc67999]
697
+ - Updated dependencies [a94df19]
698
+ - Updated dependencies [d76b04d]
699
+ - Updated dependencies [6f53fb1]
700
+ - Updated dependencies [d675d2e]
701
+ - Updated dependencies [1743c2b]
702
+ - Updated dependencies [1c56914]
703
+ - Updated dependencies [615e792]
704
+ - Updated dependencies [c8d9afc]
705
+ - Updated dependencies [4709f41]
706
+ - Updated dependencies [f0348b4]
707
+ - Updated dependencies [3e29e1b]
708
+ - Updated dependencies [a053736]
709
+ - Updated dependencies [8a8adfc]
710
+ - Updated dependencies [8995115]
711
+ - Updated dependencies [8995115]
712
+ - Updated dependencies [87fa7a5]
713
+ - Updated dependencies [822001c]
714
+ - Updated dependencies [4c59860]
715
+ - Updated dependencies [961e604]
716
+ - Updated dependencies [5937efe]
717
+ - Updated dependencies [08ac9cd]
718
+ - Updated dependencies [1fc3725]
719
+ - Updated dependencies [8995115]
720
+ - Updated dependencies [8a8adfc]
721
+ - Updated dependencies [025fabb]
722
+ - Updated dependencies [0b8fccb]
723
+ - Updated dependencies [e7369e1]
724
+ - Updated dependencies [8995115]
725
+ - Updated dependencies [c856d66]
726
+ - Updated dependencies [01a8c64]
727
+ - Updated dependencies [3362c37]
728
+ - Updated dependencies [62dcb1c]
729
+ - Updated dependencies [be4be06]
730
+ - Updated dependencies [ae18cb3]
731
+ - Updated dependencies [be0a44d]
732
+ - Updated dependencies [cc2f5b7]
733
+ - Updated dependencies [e818dc4]
734
+ - Updated dependencies [d1b01a4]
735
+ - Updated dependencies [5496ddf]
736
+ - Updated dependencies [19eb4b0]
737
+ - Updated dependencies [a874acb]
738
+ - Updated dependencies [c5d3484]
739
+ - Updated dependencies [38c3147]
740
+ - Updated dependencies [e9363c0]
741
+ - Updated dependencies [fed4634]
742
+ - Updated dependencies [fcc52eb]
743
+ - Updated dependencies [454e75c]
744
+ - Updated dependencies [2003cd6]
745
+ - Updated dependencies [52c614f]
746
+ - Updated dependencies [94d6327]
747
+ - Updated dependencies [91f07db]
748
+ - Updated dependencies [6e9e119]
749
+ - Updated dependencies [6c2c802]
750
+ - Updated dependencies [ebcc6aa]
751
+ - Updated dependencies [a1b95d4]
752
+ - Updated dependencies [c105fbf]
753
+ - Updated dependencies [e1a028a]
754
+ - Updated dependencies [dfb8759]
755
+ - Updated dependencies [9260efb]
756
+ - Updated dependencies [0644af5]
757
+ - Updated dependencies [4b05d55]
758
+ - Updated dependencies
759
+ - Updated dependencies [82911cc]
760
+ - Updated dependencies [9c9f2b0]
761
+ - Updated dependencies [7b21d11]
762
+ - Updated dependencies [6f54de8]
763
+ - Updated dependencies [ab1fb4e]
764
+ - Updated dependencies [792d8f5]
765
+ - Updated dependencies [a3b2ee4]
766
+ - Updated dependencies [eb9555a]
767
+ - Updated dependencies [c0ab703]
768
+ - Updated dependencies [8c65627]
769
+ - Updated dependencies [26ad3f3]
770
+ - Updated dependencies [d7cafd2]
771
+ - Updated dependencies [cb0a33d]
772
+ - Updated dependencies [912d95d]
773
+ - Updated dependencies [7290d2b]
774
+ - Updated dependencies [41c33e3]
775
+ - Updated dependencies [a77261c]
776
+ - Updated dependencies [c47786c]
777
+ - Updated dependencies [501b12c]
778
+ - Updated dependencies [7f1efac]
779
+ - Updated dependencies [a04918b]
780
+ - Updated dependencies [cf0b26c]
781
+ - Updated dependencies [ddb1fda]
782
+ - Updated dependencies [f54f568]
783
+ - Updated dependencies [fc880dc]
784
+ - Updated dependencies [9cbd160]
785
+ - Updated dependencies [0bebedf]
786
+ - Updated dependencies [bc4c599]
787
+ - Updated dependencies [20a6e18]
788
+ - Updated dependencies [b4d2b80]
789
+ - Updated dependencies [2201b4c]
790
+ - Updated dependencies [0cdefe7]
791
+ - Updated dependencies [66f3b5b]
792
+ - Updated dependencies [7e11e61]
793
+ - Updated dependencies [6175e38]
794
+ - Updated dependencies [caf4761]
795
+ - Updated dependencies [821b10d]
796
+ - Updated dependencies [9e74a83]
797
+ - Updated dependencies [49fd066]
798
+ - Updated dependencies [06405ed]
799
+ - Updated dependencies [e7f4871]
800
+ - Updated dependencies [916d91b]
801
+ - Updated dependencies [47f4e15]
802
+ - Updated dependencies [f829146]
803
+ - Updated dependencies [13658a7]
804
+ - Updated dependencies [31cfb2c]
805
+ - Updated dependencies [a1cea1c]
806
+ - Updated dependencies [7e94abc]
807
+ - Updated dependencies [4fbd0b7]
808
+ - Updated dependencies [9d2f21b]
809
+ - Updated dependencies [3016170]
810
+ - Updated dependencies [f4a1a60]
811
+ - Updated dependencies [507ccb1]
812
+ - Updated dependencies [c671ccf]
813
+ - Updated dependencies [16889f0]
814
+ - Updated dependencies [fb2a992]
815
+ - Updated dependencies [22f320d]
816
+ - Updated dependencies [652ae7b]
817
+ - Updated dependencies [83bcf76]
818
+ - Updated dependencies [20e57d3]
819
+ - Updated dependencies [f010749]
820
+ - Updated dependencies [8d02113]
821
+ - Updated dependencies [bf9054d]
822
+ - Updated dependencies [e40c73a]
823
+ - Updated dependencies [ca38874]
824
+ - Updated dependencies [27e1040]
825
+ - Updated dependencies [e68eb3e]
826
+ - Updated dependencies [985c4d5]
827
+ - Updated dependencies [3be5abc]
828
+ - Updated dependencies [97db489]
829
+ - Updated dependencies [4a23603]
830
+ - Updated dependencies [f441941]
831
+ - Updated dependencies [a01f43b]
832
+ - Updated dependencies [9a6426b]
833
+ - Updated dependencies [97cc5f9]
834
+ - Updated dependencies [fc880dc]
835
+ - Updated dependencies [e34bc24]
836
+ - Updated dependencies [0470e1c]
837
+ - Updated dependencies [439f449]
838
+ - Updated dependencies [3a88118]
839
+ - Updated dependencies [1b8b120]
840
+ - Updated dependencies [cf48723]
841
+ - Updated dependencies [b4489c5]
842
+ - @thomasfosterau/legation@0.1.0