@thomasfosterau/legation-jsonapi 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,973 @@
1
+ # @thomasfosterau/legation-jsonapi
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1fc3725: Add relationship declarations to collections and a protocol-agnostic `Related`
8
+ read over the mirror. A collection can now declare `one`/`many` relationships by
9
+ field mapping (`localField`/`foreignField`); `Collection.registry` rejects a
10
+ duplicate relationship name within a collection and a relationship pointing at an
11
+ unknown collection at build time. The new core `Related` module resolves and
12
+ incrementally maintains those joins over any `ClientEngine` mirror —
13
+ `query`/`observe` (a write to a related row re-hydrates without re-resolving the
14
+ base) and `related` (auto-subscribes the narrowed `foreignField IN (...)` foreign
15
+ subsets and tears them down with the scope). The incremental-join plumbing is
16
+ exposed as a strategy seam (`queryWith`/`observeWith`/`relatedWith` plus
17
+ `fieldMappingStrategy`), and the JSON:API `Relationships` module now delegates to
18
+ it with a linkage-based strategy — no behaviour change for existing users.
19
+ `Related.resolveExistsLocally` renders `Predicate.exists` on the client by
20
+ rewriting each node to an `In` over the synced foreign collection.
21
+ - c856d66: A covered read is judged and answered from one snapshot of its root rows, so a
22
+ pull between the coverage judgment and the answer can no longer serve a root row
23
+ whose edge windows are not pulled yet (#426). `Client.query` and the façade read
24
+ the root once. `Client.coverageOf` takes an optional `{ snapshot }` (the root rows
25
+ already read, with their subset) that the verdict is judged over for that subset;
26
+ `Query.runRows` answers a read from rows already read. A mirror that predates
27
+ `PulledRegistrations` still judges coverage from its registrations alone, so the
28
+ #422 race persists for it until it is upgraded.
29
+ - 5496ddf: Bump the pinned Effect version from `4.0.0-beta.84` to `4.0.0-beta.107` across
30
+ all packages (catalog and exact `peerDependencies`), and move
31
+ `@thomasfosterau/legation-jsonapi` and the commerce example from
32
+ `@thomasfosterau/effect-jsonapi@^0.6.0` to `^0.12.0` (#152).
33
+
34
+ **Why beta.107.** effect-jsonapi 0.12 requires `effect >= 4.0.0-beta.104` and
35
+ develops against `4.0.0-beta.107` exactly, so that is the version its typings
36
+ are known to line up with. The later `4.0.0-rc.*` releases were not attempted
37
+ in this change: the exact-pin policy (ADR-0003) prefers the version the
38
+ adopted library is built against, and every `effect/unstable/*` module this
39
+ repo uses (`httpapi`, `http`, `rpc`, `reactivity`, `persistence`, `sql`)
40
+ compiles and tests clean on beta.107.
41
+
42
+ **Effect API drift absorbed (no behavioural change):**
43
+
44
+ - `Schedule.either` / `Schedule.both` are gone; the default retry policy is now
45
+ `Schedule.max([Schedule.min([exponential, spaced]), recurs(5)])`, which is
46
+ the same policy (`min` recurs while any schedule recurs at the shortest
47
+ delay, `max` recurs while all recur at the longest delay).
48
+ - `Schema.TaggedErrorClass` is now `Schema.TaggedError` (the wire
49
+ `SyncRpcError` is unchanged on the wire).
50
+ - `SchemaRepresentation.fromASTs` is now `SchemaRepresentation.toRepresentations`
51
+ (spec export output is unchanged).
52
+ - `HttpApiGroup.Any` is gone and `HttpApi`'s group parameter is now
53
+ invariant, so `JsonApiSync.layer`, `JsonApiSync.inspect` and
54
+ `Convention.reflectResources` are generic over the concrete `HttpApi`
55
+ instead of accepting a widened alias. The exported `JsonApiSync.JsonApi`
56
+ alias is now `HttpApi.Constraint` (identifier and groups erased); pass your
57
+ concrete api to the functions rather than annotating it with the alias.
58
+
59
+ **effect-jsonapi 0.6 → 0.12 at the ADR-0016 boundary.** `Document`, `Query`,
60
+ `Resource`, `Relationship` and `Endpoint` lost no exports; `Query` gained
61
+ `IncludeOption`/`IncludePathsOf`/`OffsetPageStruct`/`bracketPageKeys` and
62
+ `Resource` gained `IncludeDepth`/`IncludePathsTo`. `schema.ts`, `types.ts` and
63
+ `CollectionLoader.ts` compile unchanged. One typing regression was noticed in
64
+ 0.12: endpoints with `:id` params (`Endpoint.get`/`delete`/`update`) declare
65
+ their params as `Schema.Struct<Schema.Struct.Fields & { id }>`, which leaks
66
+ `unknown` into the requirements of the corresponding `HttpApiClient` calls.
67
+ The commerce example narrows this with a small `run` helper in
68
+ `server/client.ts`; nothing in the published packages is affected.
69
+
70
+ The bump is `minor` rather than `patch` because the exact `effect` peer pin
71
+ means every consumer must move `effect` in the same change, and the
72
+ `@thomasfosterau/legation-jsonapi` type signatures above changed shape.
73
+
74
+ - 19eb4b0: Bump the pinned Effect version from `4.0.0-beta.65` to `4.0.0-beta.75` across all packages.
75
+
76
+ `@thomasfosterau/legation-browser` now builds its IndexedDB cache store on
77
+ `@effect/platform-browser` (`BrowserKeyValueStore.layerIndexedDb` + core's
78
+ `KeyValueStoreAdapter`) instead of a hand-rolled IndexedDB wrapper.
79
+ `@effect/platform-browser` is a new peer dependency of the browser package, and
80
+ `IndexedDbCacheStore.make` is now a scoped resource (requires `Scope`); the
81
+ `storeName` option was removed (the underlying object store is managed by
82
+ platform-browser).
83
+
84
+ - fed4634: Peer the Effect v4 release-candidate line through an open floor rather than
85
+ an exact pin (#222), so an app can install `@thomasfosterau/legation-*` beside
86
+ `@thomasfosterau/effect-auth` and `@thomasfosterau/effect-jsonapi` and resolve
87
+ a single copy of `effect`. The published floor is `>=4.0.0-rc.118` (see
88
+ `effect-rc118-floor.md`), on `effect` and on the `@effect/platform-browser` /
89
+ `@effect/platform-node` peers.
90
+
91
+ **`@thomasfosterau/legation-jsonapi`**: a repeated shorthand filter key
92
+ (`?filter[name]=a&filter[name]=b`) is read as the comma list it spells
93
+ (`filter[name]=a,b`, an `In` predicate) rather than rejected with a 400,
94
+ because `@thomasfosterau/effect-jsonapi` accepts the repeated-key spelling at
95
+ every list-valued position.
96
+
97
+ The packages run in browsers and workers and deliberately declare no `engines`
98
+ (ADR-0003).
99
+
100
+ - fcc52eb: Target Effect `4.0.0-rc.118`: every package peers `effect >=4.0.0-rc.118`
101
+ (#261), and the `@effect/platform-browser` / `@effect/platform-node` peers use
102
+ the same floor. Install `effect` and every `@effect/*` companion at
103
+ `4.0.0-rc.118` or later together.
104
+
105
+ The packages import Effect's modules at their rc.118 paths (`effect/rpc`,
106
+ `effect/http`, `effect/http-api`, …); rc.118 removed the `effect/unstable/*`
107
+ export paths, so the packages don't resolve beside an earlier `effect`.
108
+
109
+ **`@thomasfosterau/legation-jsonapi`** depends on `@thomasfosterau/effect-jsonapi@^0.16.1`,
110
+ the first release that peers `effect >=4.0.0-rc.118`.
111
+
112
+ **`@thomasfosterau/legation-svelte`** depends on `@thomasfosterau/effect-svelte@^0.4.0`, for
113
+ the same reason.
114
+
115
+ - 19cc12d: The JSON:API façade answers the mutation status URL (ADR-0030 §2, #511):
116
+ `GET {prefix}/_legation/mutations/{mutationId}`, the `Location` of every write
117
+ receipt, served from the engine's mutation-state map (new option
118
+ `mutationStates`) and never forwarded. `200` with one `legation-mutations`
119
+ resource (`phase`, `mutator`, `journaledAt`, `settledAt` once settled,
120
+ `reason` only when rejected, `links.self` / `links.resource`); `404`
121
+ `code: "unknown_mutation"` for an id the map does not hold. `Prefer: wait=N`
122
+ holds a pending answer until the mutation settles or `N` seconds pass, capped
123
+ by the new `maxWait` option (default 30 s), with `Preference-Applied: wait=N`.
124
+ New module `MutationStatus` builds the documents.
125
+ - aba1401: The JSON:API façade answers a write it cannot journal offline with ADR-0030
126
+ §3's error documents (#512): `400 invalid_document`, `409 type_mismatch` /
127
+ `id_mismatch` and `422 unsupported_relationship` for a malformed or mismatched
128
+ body, each with `source.pointer`; the `503` `offline` document with a second
129
+ error `client_id_required` (`source.pointer: "/data/id"`) for a create without
130
+ a client id, or `not_journalable` (the `MutatorError`'s message) for a
131
+ mutation the engine refuses; the plain `503` `offline` document for
132
+ `Prefer: handling=strict`. Online, the same requests are forwarded untouched.
133
+ Attribute values are never validated by the façade.
134
+ - 3362c37: The JSON:API façade intercepts writes (ADR-0030 §1, §3, §5, #510). Given the
135
+ engine's journal seam (`mutate`, the worker engine's `mutateWithAck`), a
136
+ `POST` with a client id, a `PATCH` or a `DELETE` on a reflected write route is
137
+ journaled as the graph-derived CRUD mutation and answered `202 Accepted` with a
138
+ write receipt — the resource as journaled, `meta.legation.status: "pending"`
139
+ and a `Location` naming the mutation status URL — when offline, when the
140
+ forward rejected at the transport, or on `Prefer: respond-async`
141
+ (`Preference-Applied: respond-async`). Online and unasked, writes are
142
+ forwarded unchanged and journal nothing; `Prefer: handling=strict` never
143
+ journals. New options `mutate`, `intentOf` (default
144
+ `FacadeWrites.graphIntentOf(nodes)`), `writes` and `pull`; new
145
+ `FetchHandler.forwarded(request, response)`, which signals `pull` after a
146
+ `2xx` forwarded write.
147
+
148
+ **Breaking:** `FetchHandler.offline` is now a function of the request,
149
+ `offline(request)`: a write candidate is journaled and answered with its
150
+ receipt (pass the clone taken before forwarding), anything else gets the 503
151
+ `offline` document as before. Replace `facade.offline` with
152
+ `facade.offline(request)`.
153
+
154
+ - 2dbbce7: `FacadeWrites.writesOf(nodes, options?)` is the inverse of `graphIntentOf` (ADR-0030 §5, #529): for every graph-derived CRUD mutation (`CrudMutation`, filtered by `writes` as the registry is) it says how the args replay as one JSON:API write, with each `one` edge's `via` key moved out of the attributes into `relationships[edge].data` (`null` linkage when the key is `null`). Pass it as a `JsonApiBackend`'s `writes`, so a node-graph app's journaled `{type}/create`, `{type}/update` and `{type}/delete` reach the server as the `POST`, `PATCH` and `DELETE` the app would have sent.
155
+
156
+ The write's `type` is always the mutation's own: args naming another type are refused (`FacadeWrites.InvalidWriteArgs`), and `JsonApiBackend` now reports any `writes` function that throws as invalid args, a `BackendError`, rather than a defect.
157
+
158
+ - d12e078: Issue #218: the filter grammar (effect-jsonapi's query-parameter format) and
159
+ `Predicate` (legation's own subset language) are two different things, not
160
+ one language with an internal engine form — `CONTEXT.md` and ADR-0016 are
161
+ corrected to say so; ADR-0028, `Predicate`, every interpreter and the
162
+ generated corpus are unchanged.
163
+
164
+ `JsonApiQuery.fromFilterAst(ast)` no longer claims to be total and no longer
165
+ throws for a literal outside `string | number | boolean | Date`: it now
166
+ returns `Result.Result<Predicate.Predicate, QueryError>`, so a caller
167
+ bridging a filter AST built over a resource with a richer decoded attribute
168
+ type gets a typed failure instead of an uncaught exception. Its doc comment
169
+ now states the field-namespace precondition (field names are copied
170
+ verbatim, correct only because a caller pre-synthesises a resource whose
171
+ fields are already the engine's) and names the `Date → toISOString()` step
172
+ as a load-bearing encode (ADR-0028 rule e), and states plainly that
173
+ `toFilterAst` is not its inverse. `JsonApiQuery.parseParams` /
174
+ `JsonApiQuery.canonical` are unaffected in observable behaviour: both call
175
+ sites' literals are always wire scalars by construction (decoded through
176
+ the node-derived synthetic resource, or copied from a `Predicate.Literal`),
177
+ so the typed failure is unreachable there — the change makes that
178
+ unreachability an enforced fact instead of an assumption, and gives the
179
+ exported function's own general contract a typed failure instead of a
180
+ thrown defect.
181
+
182
+ A new property test exercises the Date encode step directly (the existing
183
+ `FilterCorpus`-generated corpus can never produce a `Date`, since
184
+ `Predicate.Literal` is a closed wire-scalar union, so it could not catch a
185
+ regression there) and checks it against the wire text a resource that
186
+ actually declares a `Date`-decoded attribute would send — the façade/REST
187
+ equivalence obligation of ADR-0029 §6.
188
+
189
+ - 1f4a9ce: Push the journal as one `atomic:operations` request when the server advertises
190
+ the JSON:API Atomic Operations extension (#160). `JsonApiBackendConfig.atomic`
191
+ (`{ path }`) switches `applyMutations` from one `POST`/`PATCH`/`DELETE` per
192
+ mutation to a single all-or-nothing request per batch — `create` → `add`,
193
+ `update` → `update`, `delete` → `remove`, sent with the extension's media type
194
+ — and `JsonApiSync.layer` fills it in automatically when the reflected api has
195
+ an endpoint taking an `atomic:operations` document (effect-jsonapi's
196
+ `Endpoint.operations`); `JsonApiSyncOptions.atomic: false` opts out, `{ path }`
197
+ names the endpoint explicitly.
198
+
199
+ Delivery over the atomic path is exactly-once per batch rather than
200
+ at-least-once: the in-session ledger advances only once the server has applied
201
+ every operation. A transient failure (transport, 5xx, or a 4xx naming no
202
+ operation) moves nothing and the client retries the batch; a 4xx error document
203
+ whose `source.pointer` names an operation is a permanent rejection of that
204
+ mutation — it is reported in `rejected`, the batch is resent without it, and
205
+ every mutation is then acknowledged in order (the poison-mutation semantics of
206
+ ADR-0008). Change hints come from the result document's resources. Creates
207
+ keep sending the client-generated `id`; `lid` is supported by the wire format
208
+ but unused until a mutator needs a server-assigned id. The per-request path is
209
+ unchanged and still documented as at-least-once.
210
+
211
+ Route reflection now recognises an atomic operations endpoint structurally (a
212
+ request payload with a top-level `atomic:operations` member) and skips it:
213
+ previously `POST /operations` classified as `create` on a phantom `operations`
214
+ resource, which derived a phantom collection and mutator. `Convention` gains
215
+ `reflectAtomic` and `isAtomicEndpoint`; `JsonApiSync.inspect` reports the
216
+ endpoint as `atomic`. Reflected endpoint refs now carry the endpoint's
217
+ `identifier` as their `name` (Effect 4 renamed the field).
218
+
219
+ Also: `JsonApiBackendConfig.writes` expresses mutators beyond the derived CRUD
220
+ ones as JSON:API writes (a `Write` is one create/update/delete of one
221
+ resource), which is what lets `BackendContract.verifyBackendContract` run over
222
+ the backend in atomic mode against an effect-jsonapi `Endpoint.operations`
223
+ server; the atomic path rejects mutation-id gaps as the contract requires; and
224
+ `getRows` now omits a row the server answers `404` for instead of failing.
225
+
226
+ - 6f62539: Auto-subscribe and `include` for relationship resolution.
227
+
228
+ - `Relationships.related(engine, subset, names)` is an auto-subscribing
229
+ relational read: it subscribes the base subset and, from its linkage, the
230
+ narrowed `id IN (...)` subsets of each referenced collection, tracking the
231
+ live linkage and tearing the subscriptions down with the scope. Callers no
232
+ longer wire up the related collections by hand. Composes with the query
233
+ encoder so the related subsets narrow server-side via `filter[id]`.
234
+ - `JsonApiSync`/`JsonApiBackend` gain an `includes` option (collection →
235
+ relationship names). Listings then carry `?include=...`, and the compound
236
+ document's related resources are harvested so an auto-subscribed
237
+ `id IN (...)` subset is served from that response — the related rows arrive
238
+ with their parent, no extra round trip.
239
+
240
+ - 454e75c: Derived REST `list` / `get` handlers over the engine's read path (#163): a
241
+ JSON:API `GET` is ADR-0025's `read` operation with a document body, so the
242
+ REST tier and the sync tier now share one grammar, one evaluator and one auth
243
+ path — the premise of ADR-0029's "same URL, same result".
244
+
245
+ - `RestHandlers.make({ nodes, backend, policy, context, middleware?, prefix? })`
246
+ binds handler factories for effect-jsonapi's `Endpoint.list` / `Endpoint.get`:
247
+ `rest.list(resource)` and `rest.get(resource, { notFound })`. Each parses the
248
+ request URL into a `Query.Read` with `JsonApiQuery.parseParams`, resolves the
249
+ request's identity through the `context` seam (HTTP request → `SyncContext`,
250
+ the HTTP analogue of `IdentityResolver`), runs the read through the
251
+ filterable check, the deny-by-default auth filters and `Backend.evaluate` +
252
+ `getRows` with the two-phase include resolution, and serialises the rows.
253
+ Scoping is the policy's alone: an anonymous request to a private collection is
254
+ an empty collection for `list` and a 404 for `get`, as the sync tier's empty
255
+ allow-set behaves. A URL the grammar rejects is the codec's 400 error
256
+ document with `source.parameter`, before any backend call; a collection the
257
+ policy does not declare is a 403; a missing row fails with the endpoint's own
258
+ 404 class. Linkage follows the resource's declaration: every
259
+ `Relationship.many` the endpoint's resource declares is resolved — on the
260
+ primary resource and on each included resource's own declaration — because
261
+ effect-jsonapi resource schemas require every declared relationship, and
262
+ nothing the resource does not declare is touched; `include` decides
263
+ `included`. A paginated node edge lists and includes under a
264
+ `Relationship.many` declaration (links plus its rows' linkage in the edge's
265
+ order) and carries links only under `Relationship.paginated`. Derivation is
266
+ opt-in per endpoint, beside hand-written handlers.
267
+ - `DocumentWriter.collection(input)` / `DocumentWriter.data(input)` is the one
268
+ rows-to-document function both the REST handlers and the service-worker façade
269
+ (#164) serialise through: a pure function of the node graph, the read, the
270
+ resolved rows and the `fields[]` projection. Attributes are the node's, `one`
271
+ edges link through their key fields, collection edges through the resolved
272
+ edge rows, paginated edges carry the spec's `links` (and their rows' linkage
273
+ when included); `included` is deduplicated
274
+ with full linkage checked; `fields[type]` projects attributes after linkage;
275
+ `links.self` is the read's canonical string and `links.next` the same query
276
+ with `page[after]` at the last row when the window is full; `linkage` carries
277
+ the edge rows resolved for included resources. Node rows only — `Derive.ts`'s
278
+ `JsonApiRow` has no node graph to plan against.
279
+ - `RestHandlers.queryErrorDocument(error)` renders a `QueryError` as the 400
280
+ document (the codec's own when the rejection was a decoding failure).
281
+
282
+ - a3b2ee4: The JSON:API façade (ADR-0029, issue #166): a fetch handler that answers a
283
+ covered JSON:API `GET` from the replica and stays out of the way otherwise.
284
+
285
+ - `JsonApiFacade.fetchHandler({ nodes, api, view, coverageOf, cookie?, resources?, baseUrl?, prefix?, online? })`
286
+ in `@thomasfosterau/legation-jsonapi` builds the façade from the app's `HttpApi`
287
+ (`Convention.reflectResources` gives the `list` / `getOne` routes it
288
+ recognises; those whose type is no node are left to the server), the
289
+ node graph, the replica's view and a coverage source. It presumes the
290
+ api's read endpoints are declared with the graph's codec
291
+ (`JsonApiQuery.querySchema` / `singleRowSchema`) and served by the
292
+ derived handlers, which is what lets a parse failure be answered locally.
293
+ `matches(request)` says whether a request is its business — a `GET` whose
294
+ `Accept` names `application/vnd.api+json` on a reflected route under
295
+ `baseUrl` — and `handle(request)` answers: a covered request gets `200`
296
+ with the document the one writer produces (byte-equal in content to the
297
+ derived REST handler's for the same declarations, the optimistic overlay
298
+ visible as `Client.query` sees it), `Cache-Control: no-store` and
299
+ `x-legation-served-at` set to the replica's CVR cookie (the store's,
300
+ empty until the first pull); a URL the grammar rejects gets the codec's
301
+ `400` document (`RestHandlers.queryErrorDocument`), except an `include`
302
+ the graph does not declare, which the server rules on; an uncovered
303
+ request while offline gets the `503` `offline` error document of
304
+ ADR-0029 §4 (`handler.offline`, with the cookie, also for a forward that
305
+ rejected at the transport). Everything else is `undefined` for the caller
306
+ to forward unchanged: a non-candidate, an uncovered request while online,
307
+ a collection no bound resource declares, and a single-resource request
308
+ whose row the replica does not hold (the 404 is the endpoint's own).
309
+ Coverage is every read `ReplicaDocuments.toCover` names, each judged by
310
+ `coverageOf` — `client.coverageOf`, or the host's registrations below.
311
+ The module lives in `@thomasfosterau/legation-jsonapi` by ADR-0006: it needs this
312
+ package's codec, writer and route reflection plus the standard
313
+ `Request` / `Response` globals, and `navigator.onLine` is an option with a
314
+ guarded default, so `@thomasfosterau/legation-browser` stays free of effect-jsonapi.
315
+ - `EngineHost.run` returns a `Host` whose `registrations` lists what the
316
+ host holds on its mirrors' behalf — root windows, relational registrations
317
+ with their edge selects, both halves of a `SubscribeUrl` with its canonical
318
+ URL while a party holds it by URL — as `Coverage.Subscription`s: the
319
+ ledger a façade's coverage check scans in a worker, where no `Client`'s
320
+ own ledger sees the tabs' pins. A registration is listed once its engine
321
+ registration exists (a relational one once its reconcile loop has
322
+ started; one naming an edge the registry does not declare registers
323
+ nothing) and dropped when the last mirror releases it or is swept.
324
+ `Coverage.verdictOf` and
325
+ `Coverage.coverageOf(nodes, subscriptions)` give the same `Verdict` as
326
+ `Client.coverageOf` over any such ledger; `Client` uses the former.
327
+ - `ServiceWorkerHost.start` serves exactly as `run` does and returns the
328
+ `Host` — the engine and its registrations — for the worker to build the
329
+ façade from; `run` is `start` then serve forever.
330
+ - The commerce worker composes façade, then its unchanged HTTP cache, then
331
+ the network (`examples/commerce/src/service-worker.ts`,
332
+ `src/lib/facade.ts`, `src/lib/host.ts`); the api's shape moves to
333
+ `server/api.ts` so the worker reflects the routes the server serves; and
334
+ the replica test runs the façade over the same seed: every URL the
335
+ shell's pins cover is answered with the served document's bytes, and the
336
+ rest go to the network. `Convention.segmentsOf` and `Convention.isParam`
337
+ are exported.
338
+
339
+ - eb9555a: The filter-grammar bridge (ADR-0028 §5, #161): `@thomasfosterau/legation-jsonapi` now
340
+ translates both ways between effect-jsonapi's filter AST and the engine's
341
+ `Predicate`, parses a JSON:API URL into a `Query.Read`, and encodes the whole
342
+ predicate exactly. Bumps `@thomasfosterau/effect-jsonapi` to ^0.13.0, which
343
+ carries the grammar (`Filter`, `Sort`, `Query.Filter`, `Query.canonical`,
344
+ `Resource.filterable` / `Resource.sortable`).
345
+
346
+ - `JsonApiQuery.fromFilterAst(ast)` is total — every grammar node has an
347
+ engine node — and `toFilterAst(predicate)` is partial: `Exists`, an empty
348
+ `In` / `NotIn` list and a `null` literal have no URL form and return `None`,
349
+ so callers fail closed. `foldConstants` folds the two definite cases away
350
+ exactly (an empty `In` is false, an empty `NotIn` true under ADR-0028).
351
+ - `JsonApiQuery.parseRead(nodes, url)` / `parseParams(nodes, collection,
352
+ params)` decode `include`, `fields`, `sort`, `page` and `filter` with
353
+ effect-jsonapi's `Query.schema` derived from the **node graph** (each node's
354
+ filterable set — operators and literal kinds — and edges become the codec's
355
+ declaration; `resourcesOf(nodes)` exposes the synthesised resources) and map
356
+ them onto the read: `filter` → `where`, `sort` → `order`, `page[limit]` /
357
+ `page[after]` → `first` / `after`, `include` → edge selects. Include paths
358
+ are single level (ADR-0028 D1). `fields[]` is carried beside the read as a
359
+ projection, never into the plan. Every rejection is a `QueryError` whose new
360
+ `parameter` field names the offending query key (the 400's
361
+ `source.parameter`). A `/{type}/{id}` URL reads as `filter[id]`, first 1.
362
+ - `JsonApiQuery.canonical(nodes, read)` compiles the read and encodes its plan
363
+ through effect-jsonapi's canonical encoder — one string per plan, the
364
+ subscription identity of ADR-0029 — and `parseRead` accepts it back.
365
+ - The encoder is exact: `defaultEncoder` pushes the whole predicate as the
366
+ grammar's canonical `filter[...]` form (operators, `or`, `not`, `isnull`,
367
+ `nin` included — nothing is dropped any more); `safeFilters` is gone. A
368
+ subset whose predicate folds to false is answered without a request, and one
369
+ with no URL form fails closed. `QueryEncoder.encode` now returns
370
+ `Effect<Option<string>, QueryError>`; `JsonApiRequestOptions.filter` is a
371
+ filter AST (`Filter.eq("status", "open")`) instead of a `Record<string,
372
+ string>`; query strings are serialised through `Query.serialise` (readable
373
+ `[`/`]` in keys). `buildQueryString` lives in `JsonApiQuery` and stays
374
+ re-exported from `CollectionLoader`.
375
+ - `NodeBridge.fromResource` / `registry` read the filterable set off the
376
+ resource's own declaration (`Resource.filterable` / `Resource.sortable`): an
377
+ attribute keeps its name and derives its kind from its schema; a to-one
378
+ relationship's declaration lands on the edge's key field (`author` →
379
+ `authorId`, kind `text`). The `filterable` / `sortable` options remain as
380
+ per-field overrides (the way to give an `instant` its kind).
381
+
382
+ - a01f775: Make relationship live queries incremental. `Relationships.observe` (and the
383
+ auto-subscribing `related`) now maintain the join instead of re-resolving it on
384
+ every change: the base page is kept current by `engine.observe(subset)` and each
385
+ referenced collection by its own `engine.observe`, so a write to a related row
386
+ re-hydrates the join without re-querying the base, and a base change that shifts
387
+ _which_ collections are referenced re-establishes the referenced observers (via
388
+ `switchMap`). Per-collection lookup indexes are cached and only the changed
389
+ slice is rebuilt; changes that don't move the resolved result are deduped away.
390
+
391
+ This also fixes a correctness gap exposed by the now-deduped
392
+ `ClientEngine.observe`: a change to _only_ a referenced collection (e.g. editing
393
+ a comment without touching its issue) no longer fails to update the join.
394
+
395
+ - f24f45b: `JsonApiSync.layer` keeps the client store and the ledger durable together: a new `keyValueStore` option (the durable `KeyValueStore` the client store is built over) makes the client store default to `ClientStore.layerKeyValue()` over it, and the ledger default to `Ledger.keyValue` over it, so a reload keeps the journal, the acknowledged mutation ids and the remembered rejections (#468). An explicit `store` or `ledger` still overrides its default, and without `keyValueStore` both stay in memory as before. The README and the backend's delivery notes now say that a failed ledger write after a successful server write fails the push: mutations acknowledged earlier in that push stay acknowledged, and the resend re-applies the rest, the same window as a crash.
396
+ - 8a6fc54: `JsonApiBackend` takes an optional `ledger` store, so its ledger (the acknowledged mutation ids and the remembered rejections of each client group) survives a restart (#468). `Ledger.keyValue` keeps it in an Effect `KeyValueStore`, and `JsonApiSync.layer` accepts the same option. Without one, the ledger stays in memory as before, and a restart of the backend forgets it: a resent mutation that was already applied looks new, and a permanent rejection can no longer be reported to a replayed copy or a pull. The README documents the restart caveat.
397
+ - 74fe923: Extend the JSON:API sync derivation with server-side query narrowing and
398
+ relationship resolution.
399
+
400
+ - `JsonApiQuery`: a pluggable `QueryEncoder` seam that translates a `Subset`
401
+ into a JSON:API query string, with `JsonApiSync` accepting a `queryEncoder`
402
+ override. The shipped default soundly encodes the `eq`/`in` conjuncts and
403
+ `sort` it can express (`filter[field]=…`, `sort=field,-field`) and drops the
404
+ rest; since `Subset.apply` re-runs the full predicate locally, the server
405
+ filter only needs to return a superset. A dedicated JSON:API package can
406
+ supply a richer encoder (operator filters, cursor pages).
407
+ - `Relationships`: resolve JSON:API relationship linkage against the local
408
+ mirror — `resolve`/`hydrate` (pure) plus `query`/`observe` over a
409
+ `ClientEngine`, giving a reactive `related()` (to-one and to-many) that
410
+ re-resolves when a referenced row changes.
411
+ - Engine rows for JSON:API resources are now **flattened** (attributes at the
412
+ top level, `id` and `relationships` reserved) so predicates and `orderBy`
413
+ address attribute fields directly — matching how JSON:API `filter[field]`
414
+ works. Mutator arguments remain structured (`{ id, attributes, … }`).
415
+
416
+ - fc467ea: Compound documents from the replica (#164): the worker-side caller of the one
417
+ serialiser, so the same URL yields the same document content from the
418
+ replica as from the derived REST handler (ADR-0029 §6).
419
+
420
+ - `ReplicaDocuments.make({ nodes, resources?, prefix? })` binds the
421
+ replica-side writer. `fromUrl(view, url)` parses a URL through
422
+ `JsonApiQuery.parseRead` (the read and its `fields[]` projection together),
423
+ runs it over a local view — the replica's rows, optimistic overlay
424
+ included, through the two-phase include with the plan's edge windows — and
425
+ writes a single-resource document for `/{type}/{id}` (`data: null` when
426
+ the replica holds no row) or a collection document otherwise, through
427
+ `DocumentWriter`; `collection(view, parsed)` and `data(view, parsed)` take
428
+ a parsed read. Linkage follows the resource declarations it is given —
429
+ the resources the REST endpoints are declared with, so both sides emit the
430
+ same relationships by construction — or, without any, the node graph's
431
+ synthetic resources (`JsonApiQuery.resourcesOf(nodes)`). Parity holds when
432
+ the endpoint is declared with the node-derived codec
433
+ (`JsonApiQuery.querySchema`) and collection edges are `many`; an `include`
434
+ edge or a `fields[type]` type the bound resource does not declare is
435
+ refused with the codec's `QueryError` shape before any query runs.
436
+ `toCover(read)` names the reads a request needs covered for its document
437
+ to be faithful: the read itself, plus one whole-collection read per
438
+ collection the declared linkage resolves from beyond the read's includes;
439
+ the caller (the façade) serves a URL only when every one is covered
440
+ (`Client.coverageOf`). A collection no bound resource declares is a
441
+ `QueryError` from every entry point, never a defect. The module depends on
442
+ nothing but `effect` and the engine, so it runs in a service worker.
443
+ - `RestHandlers` derives its linkage through the same package-internal
444
+ derivation the replica-side writer uses (what a document's relationships
445
+ are resolved from beside the URL's includes, over any row source); its
446
+ behaviour is unchanged.
447
+ - `JsonApiQuery.parseRead` reports the row id a `/{type}/{id}` path
448
+ addressed (`ParsedRead.id`), so a caller knows to write a single-resource
449
+ document. `JsonApiQuery.invalidParameter` builds the `QueryError` shape
450
+ the codec's checks raise.
451
+ - The commerce example checks every read `src/lib/domain.ts` declares
452
+ against the same seed on both sides, byte for byte
453
+ (`examples/commerce/tests/replica-documents.test.ts`).
454
+
455
+ - 0cddcc4: `JsonApiSync.layer` accepts a `store` option to back the engine with a
456
+ durable `ClientStore` (IndexedDB, localStorage) instead of the in-memory
457
+ default, so the journal and confirmed rows survive reloads — the happy path
458
+ becomes offline-first with one option.
459
+ - b44e9ae: Add convention-first sync derivation from an app's JSON:API `HttpApi`
460
+ (`JsonApiSync`). Point it at an Effect `HttpApi` and it reflects the endpoints,
461
+ classifies them by method + path into JSON:API CRUD roles (`Convention`),
462
+ derives one collection and the create/update/delete mutators per resource type
463
+ (`Derive`), and backs the BYO `Backend` contract with HTTP calls
464
+ (`JsonApiBackend`) — content-hash row versions stand in for the version token a
465
+ plain REST API doesn't expose. `JsonApiSync.layer` composes the whole
466
+ client-direct stack (in-process sync room + client engine) so an existing REST
467
+ API gains optimistic writes, a durable journal, rebase, and live queries with
468
+ no bespoke backend; `JsonApiSync.start` runs the sync loop and polls for remote
469
+ changes.
470
+
471
+ Delivery is at-least-once (the in-session ledger has no cooperating server), so
472
+ keep writes idempotent: client-generated ids on create, naturally-idempotent
473
+ PATCH/DELETE.
474
+
475
+ - 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.
476
+ - ddb1fda: Restructure the repository into a monorepo of sync-engine packages, with package boundaries drawn by dependency footprint:
477
+
478
+ - `@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.
479
+ - `@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.
480
+
481
+ - e1b8333: `NodeBridge` reads more sync metadata off the resource itself (#220). A `paginated` relationship's edge order comes from its own declaration, `Relationship.paginated(ref, { order })`, so `options.order` is no longer required. Two `@legation/` resource annotations, built with `NodeBridge.syncAnnotations({ inverses, kinds })`, name an inverse edge (`@legation/inverses`) and give a filterable field its literal kind (`@legation/kinds`, for an `instant` stored as text). They survive `Resource.annotate` and `Resource.extend`. `FromResourceOptions` stay as explicit overrides and win over the resource's declaration. A malformed annotation, or a kind for a field the resource does not declare filterable, throws `ResourceBridgeError` when the graph is built.
482
+ - f4f0688: The effect-jsonapi Node bridge (ADR-0022 program step 9): `NodeBridge.fromResource` / `NodeBridge.registry` derive the sync engine's node graph from `@thomasfosterau/effect-jsonapi` resource definitions — one/optional relationships become one-edges with derived key fields, many/paginated relationships resolve their inverses across bridged one-edges, and the `edges` option promotes a foreign-key attribute to an edge so the library's acyclic resource graphs bridge without hand-naming inverses. Bumps the effect-jsonapi dependency to ^0.6.0.
483
+ - 7e11e61: Permanent mutation rejection semantics (#35). A mutator handler can now fail
484
+ with the new `MutationRejected` error to mark a failure permanent (failed
485
+ validation, revoked permission, violated business rule). The backend records
486
+ the mutation as processed — bumps `lastMutationId` atomically **without**
487
+ applying effects — so a poison mutation is acknowledged and skipped rather than
488
+ wedging the queue: mutations behind it still apply, and the client drops the
489
+ optimistic effect on the next rebase with no app code. A `BackendError` keeps
490
+ today's transient behaviour (abort the batch, client retries).
491
+
492
+ - `Backend.ApplyResult` gains an optional `rejected` list; `PushResponse`
493
+ gains an additive optional `rejected` field (older clients ignore it, and the
494
+ per-mutation outcome surface #36 reads it).
495
+ - `MemoryBackend` rolls a rejected mutation's staged writes back (snapshot),
496
+ `SqlBackend` via a per-mutation `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`, and
497
+ `JsonApiBackend` classifies HTTP 4xx (except 408/429) as permanent rejections.
498
+ - `BackendContract.verifyBackendContract` gains a rule covering both failure
499
+ classes.
500
+ - `CONTEXT.md` documents the "transient failure" vs "permanent rejection"
501
+ glossary entries.
502
+
503
+ - 6175e38: Predicate-aware fan-out matching. A committed write now pokes a client group
504
+ only when some hinted change plausibly affects one of its subscriptions,
505
+ rather than every group whose subscriptions touch the hinted collection.
506
+
507
+ Change hints gain an optional, wire-compatible `values` field carrying the
508
+ current row values of the PUTs in a change (`MemoryBackend`, `SqlBackend` and
509
+ `JsonApiBackend` populate it best-effort; a backend that can't cheaply produce
510
+ a value omits it). An old, value-less hint decodes and behaves exactly as
511
+ before. The `SyncServer` fan-out loop matches a value-carrying PUT against the
512
+ subscription's predicate, a delete against the group's last-synced CVR, and
513
+ falls back to the collection-level match for value-less hints and `Exists`
514
+ predicates (soundness first — over-matching is safe, under-matching would
515
+ delay convergence). A new `legation_pokes_suppressed_total` metric counts the
516
+ pokes predicate matching avoids.
517
+
518
+ - 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.
519
+
520
+ `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.
521
+
522
+ `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.
523
+
524
+ - 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.
525
+
526
+ 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.
527
+
528
+ - 9d2f21b: Rename the project to **Legation**. Every package moves to the `@legation`
529
+ scope: the engine is now `@legation/effect` (formerly
530
+ `@thomasfosterau/effect-sync-core`), and the adapters/bindings are
531
+ `@legation/browser`, `@legation/node`, `@legation/jsonapi`, `@legation/sql`,
532
+ and `@legation/svelte` (formerly `@thomasfosterau/effect-sync-*`).
533
+
534
+ Runtime identifiers follow the brand, which is **breaking for persisted local
535
+ state and dashboards**:
536
+
537
+ - `Context.Service` identifiers: `@thomasfosterau/effect-sync-core/*` →
538
+ `@legation/effect/*` (and per-package equivalents)
539
+ - Client-store key prefix: `effect-sync/` → `legation/`; CVR store prefix:
540
+ `effect-sync/cvr/` → `legation/cvr/`. Existing IndexedDB/KV state under the
541
+ old prefix is orphaned — clients rehydrate via a full pull; export any
542
+ pending journal before upgrading offline clients.
543
+ - Metrics: `effect_sync_*` → `legation_*`; spans: `effect-sync.*` →
544
+ `legation.*`
545
+ - Default BroadcastChannel name: `effect-sync` → `legation`; Redis hint
546
+ stream: `effect-sync:hints` → `legation:hints`; cache metadata header:
547
+ `x-effect-sync-cache-meta` → `x-legation-cache-meta`
548
+
549
+ - f4a1a60: Schema constants are named after the domain type they decode, with a
550
+ same-named type beside each: `Protocol.MutationSchema` is now
551
+ `Protocol.Mutation` (value and type), and likewise `PushRequest`,
552
+ `PushResponse`, `RejectedMutation`, `PatchOp`, `NamedSubscription`,
553
+ `PullRequest`, `PullResponse`, `ReadRequest`, `ReadResponse` and `Poke`;
554
+ `Subset.Subset` and `Subset.OrderBy`; `ClientStore.StoredRow`;
555
+ `Hydration.Snapshot` and `Hydration.SnapshotRow`; `Cvr.WireCvr`;
556
+ `MirrorProtocol.EdgeSelect` and `MirrorProtocol.MutationStateEntry` (was
557
+ `MutationStateSchema`); the JSON:API `JsonApiSchema.Links`, `LinkObject`,
558
+ `ResourceIdentifier`, `Relationship`, `ResourceObject`, `JsonApiError` and
559
+ `JsonApiDocument`; and the Cloudflare `RoomProtocol.ShardPoke`, `Register` and
560
+ `Publish`. `Derive.CreateArgs` / `UpdateArgs` / `DeleteArgs`, the room
561
+ protocol's bodies and `Hydration.SnapshotFromJson` gain their matching types.
562
+ - 16889f0: Rename every package from the `@legation/*` scope to
563
+ `@thomasfosterau/legation-*` (#303): `@legation/effect` is now
564
+ `@thomasfosterau/legation`, and likewise for `browser`, `cloudflare`,
565
+ `devtools`, `jsonapi`, `node`, `sql` and `svelte`. Subpath entries keep their
566
+ paths (`@thomasfosterau/legation/internal`,
567
+ `@thomasfosterau/legation-svelte/kit`,
568
+ `@thomasfosterau/legation-devtools/svelte`). Update imports and dependency
569
+ names accordingly; no `@legation/*` name was ever published to npm.
570
+
571
+ `Context.Service` identifiers and `Symbol.for` keys follow the package name
572
+ (`@legation/effect/Client` → `@thomasfosterau/legation/Client`, and
573
+ per-package equivalents). The reserved identity sentinels
574
+ (`@legation/anonymous`, `@legation/unstamped`), the `legation/` client-store
575
+ prefixes, metric and span names are unchanged.
576
+
577
+ - 9a6426b: Sync-loop scheduling (#33). `ClientEngine` grows a configurable scheduler on
578
+ `runSync`, driven by `Effect.Schedule` and the existing `statusChanges` seam:
579
+
580
+ - `syncLoop.pushDebounce` auto-pushes after `mutate`/`mutateEncoded`, debounced
581
+ so a burst of mutations coalesces into a single push.
582
+ - `syncLoop.pullInterval` pulls periodically as the correctness backstop, so a
583
+ client converges over pull alone even without pokes.
584
+ - The baseline triggers (pull on poke, push+pull on reconnect) are retained,
585
+ and every scheduling fibre is scoped — interrupted when the engine scope
586
+ closes.
587
+
588
+ `@thomasfosterau/legation-browser` adds `BrowserConnectivity.withBrowserConnectivity`, which
589
+ layers `navigator.onLine` + `online`/`offline` + `visibilitychange` onto any
590
+ `ClientTransport` so `statusChanges` becomes truthful for carriers (like the RPC
591
+ transport) that otherwise report themselves permanently connected, and a
592
+ return-to-visible surfaces a poke.
593
+
594
+ `@thomasfosterau/legation-jsonapi` `JsonApiSync` now configures the core loop (`pullInterval`,
595
+ `pushDebounce` options, 5-second default interval) instead of running its own
596
+ `Effect.forever` poll; `JsonApiSync.start` just runs `runSync`.
597
+
598
+ - 1b8b120: URL-keyed subscriptions over the mirror channel (ADR-0029 section 2, issue #159).
599
+
600
+ - `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)`.
601
+ - 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.
602
+ - 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).
603
+ - 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.
604
+ - 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.
605
+ - `Query.tryPlan(nodes, read)` is `Query.plan` with its `QueryError` as a typed failure.
606
+
607
+ ### Patch Changes
608
+
609
+ - 1ea4723: Add `JsonApiQuery.querySchema(nodes, collection)` and
610
+ `JsonApiQuery.singleRowSchema(nodes, collection)`: the query codecs of a
611
+ collection's `list` and `get` endpoints, as the node graph declares them —
612
+ the codecs `parseParams` and `singleRowParams` decode with — for
613
+ `Endpoint.list(R, { query })` / `Endpoint.get(R, { query })`, so an
614
+ effect-jsonapi endpoint served by `RestHandlers` declares the grammar the
615
+ handler parses (the key-field spelling of a relationship filter,
616
+ `filter[categoryId]`, and `sort=id`).
617
+ - a053736: `BackendContract.verifyBackendContract` probes write-then-`BackendError` (#343).
618
+ A new contract mutator, `BackendContract.writeThenFail` (`contract/write-then-fail`),
619
+ writes a row and then fails transiently. The new rule 10 checks that the row keeps
620
+ its value and version, no new row appears, and `lastMutationId` is not bumped, so
621
+ the client's retry applies the mutation again. Guarantee 1 (`Backend.ts`) requires
622
+ this, and `failAlways` does not probe it because it writes nothing first.
623
+
624
+ Visibility before the bump is documented in `BackendContract` as a per-backend
625
+ obligation, not a generic rule: the harness cannot observe a backend mid-mutation.
626
+
627
+ The unknown-mutator `BackendError` that `MemoryBackend`, `SqlBackend` and
628
+ `JsonApiBackend` raise now carries a typed cause, built by `UnknownMutator.make`
629
+ (`@thomasfosterau/legation/internal`). Rule 10 and rule 4 (`contract/fail`, #382)
630
+ recognise an unregistered mutator by that cause, so a backend that words the message
631
+ differently is still caught. The exact message `Unknown mutator: <name>` stays as a
632
+ fallback for a third-party backend that does not use the helper yet. Rule 4 now also
633
+ needs the probe to fail with a `BackendError`: a `failAlways` that dies with a defect
634
+ fails the rule instead of passing it.
635
+
636
+ **Action required:** backend authors who run `verifyBackendContract` (imported from
637
+ `@thomasfosterau/legation/internal`) must register `BackendContract.failAlways`
638
+ and `BackendContract.writeThenFail`. The second is a handler that writes a row and then
639
+ fails with `BackendError`, called with args `{ collection, id }`. An unregistered mutator
640
+ fails rule 4 or rule 10. `BackendContract.memoryHandlers` already includes both. A
641
+ non-transactional executor (D1-style) fails rule 10 by design, since each statement
642
+ commits on its own (#342), so it is not run through this contract.
643
+
644
+ - 5937efe: Add the relational `Predicate.exists` node: a correlated relationship test
645
+ (this row matches if a row exists in another collection whose `where` holds and
646
+ whose `foreignField` equals this row's `localField`). Backends resolve it to an
647
+ allow-set with `Predicate.resolveExists` before evaluation — MemoryBackend over
648
+ its rows, the SQL backend via a `SELECT DISTINCT` subquery (the `Sql` translator
649
+ also compiles `Exists` to a correlated `IN (SELECT …)`). This closes the deeper
650
+ parts of the relational-query and relationship-scoped-auth gaps: server-side
651
+ joins and "visible if you're a member of the project" auth **without
652
+ denormalizing** the join key onto the row. The JSON:API backend can't resolve
653
+ correlated joins over REST and fails closed on `Exists`.
654
+ - 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.
655
+ - d1b01a4: Tested against `effect` 4.0.0 (and the matching `@effect/*` companions); the
656
+ peer floor is unchanged at `effect >=4.0.0-rc.118`.
657
+
658
+ `@thomasfosterau/legation` adapts to 4.0.0's stricter, type-only `Schema.brand`:
659
+
660
+ - `Node.Id` keeps its signature and runtime behaviour; its internal brand
661
+ identifier is cast to satisfy the new concrete-identifier check.
662
+ - `Node.AnyNode`'s `Id` property is now
663
+ `Schema.Codec<string & Brand.Brand<string>, string>` instead of `Id<string>`.
664
+ 4.0.0's `brand<String, B>` is type-only and no longer assignable to a wider
665
+ ``brand<String, `${string}/id`>``, so no concrete `Node<"todos", …>` was
666
+ assignable to `AnyNode`. Every concrete node is assignable again, and `Id`
667
+ is still a string-encoded branded id schema; only code that reads `.Id` off
668
+ an `AnyNode`-typed value sees the wider brand.
669
+
670
+ Checked the 4.0.0 behavioural changes against the packages and found no impact:
671
+ no use of `partition`/`separate`/`Option.partitionMap`, `Queue.State`,
672
+ `MessageStorage` or `RpcMessage.ExitEncoded`. The RPC client's missed-pong
673
+ handling now fails in-flight WebSocket calls rather than reporting a transient
674
+ error; `RpcTransport.fromOrigin` already treats a failed WebSocket call as a
675
+ `TransportError` and falls back to HTTP. The RPC server's WebSocket route now
676
+ closes with 1000/1001/1011, which nothing in the packages inspects.
677
+
678
+ - a874acb: Bump the pinned Effect version from `4.0.0-beta.75` to `4.0.0-beta.83` across all
679
+ packages (no engine API changes; build, typecheck, and the full test suite pass
680
+ unchanged).
681
+
682
+ The version is now centralised in a root **Bun catalog**
683
+ (`package.json` → `workspaces.catalog`): every package's `devDependencies`
684
+ reference `effect`/`@effect/platform-*`/`@effect/vitest` as `catalog:`, while the
685
+ published `peerDependencies` keep the concrete `4.0.0-beta.83` string. The
686
+ Every example is a workspace member so they share the same `effect` version
687
+ as the engine instead of pinning its own.
688
+
689
+ - c5d3484: README: the `effect` floor (`4.0.0-rc.118`) is now tested on every pull
690
+ request, where CI runs the test suite against it, instead of in a weekly
691
+ canary. The peer range (`effect >=4.0.0-rc.118`) and the tested pin (`4.0.0`)
692
+ are unchanged.
693
+ - ea200ab: Depend on `@thomasfosterau/effect-jsonapi` `^0.17.0`. A document whose read carries `include` now has an `included` member even when nothing resolved (`included: []`), as JSON:API 1.1 requires of a compound document; a read without `include` still omits it. An effect-jsonapi 0.17 endpoint answers 400 to a `filter[…]` its query schema doesn't declare, so an endpoint served by `RestHandlers` must declare the handler's grammar with `Endpoint.list(R, { query: JsonApiQuery.querySchema(nodes, collection) })`, as documented.
694
+ - e9363c0: Peer `effect ^4.0.0` (was `>=4.0.0-rc.118`), and `@effect/platform-node` /
695
+ `@effect/platform-browser` `^4.0.0` likewise, per the peer-range policy: the
696
+ range is raised only when a dependency requires it. The range form changes
697
+ (the caret no longer admits the 4.0 release candidates or Effect 5); every
698
+ package's tests pass at `effect` 4.0.0.
699
+
700
+ `@thomasfosterau/legation-svelte` peers `effect ^4.0.2`: its dependency
701
+ `@thomasfosterau/effect-svelte` 0.6.0 peers `effect >=4.0.2`.
702
+
703
+ - 6c2c802: Close the sync tier's filter surface with a per-node **filterable set** (ADR-0028
704
+ rule d, #154).
705
+
706
+ - `Node.make` gains `filterable` (per attribute: the operators a client may use
707
+ — `eq ne lt lte gt gte in nin isnull` — optionally with an explicit literal
708
+ kind) and `sortable`. The resolved declaration is on `Node.filterable` /
709
+ `Node.sortable` and travels onto the derived `Collection` like `indexes`, so
710
+ the server enforces it without Node definitions.
711
+ - Defaults: `id` and every `one`-edge `via` field are filterable by `eq` / `in`
712
+ (kind `text`; a declaration naming a key field widens it, never replaces
713
+ it); `sortable` always includes `id`, and `Node.registry` adds every
714
+ paginated edge's `order` fields to its target's `sortable`, since the
715
+ per-parent edge windows sort by them. The literal kind of a declared
716
+ attribute is derived from its schema's encoded form (`String` → `text`,
717
+ `Number` → `double`, `Schema.Int` → `integer`, `Boolean` → `boolean`); an
718
+ `instant` must be declared explicitly. A declaration naming a field the row
719
+ does not carry, or an attribute whose kind cannot be derived, throws a
720
+ `FilterDeclarationError` at definition time.
721
+ - `Authorization.policy(filters, { collections })` carries the declarations;
722
+ `authorize` checks every client-authored `where` / `orderBy` against them
723
+ **before** the auth filter is AND-ed in and before `Backend.evaluate` runs:
724
+ an undeclared field, a declared field with an undeclared operator, a literal
725
+ of the wrong kind, a client-supplied `Exists`, or an unsortable `orderBy`
726
+ fails the pull or read with an `AuthorizationError` naming the collection,
727
+ field and operator. Today's wire forms `Not(In)` and a null-literal
728
+ `Compare` are checked as `nin` and `isnull`. The check runs on the request
729
+ as the client sent it: the auth filter's own predicate, what a
730
+ `SyncMiddleware` rewrites in, and named-query resolver output are
731
+ server-authored and exempt. A policy built without `collections` keeps the
732
+ previous open behaviour.
733
+ - `NodeBridge.fromResource` / `registry` accept `filterable` / `sortable` per
734
+ resource until effect-jsonapi carries the per-attribute annotation.
735
+
736
+ - 74d8699: `JsonApiBackend` no longer emits a stale value in a push's change hint for a row that a later mutation of the same push deleted. A create or update in an earlier mutation left its value in the batch's hint values after a later mutation deleted the row, on both the per-resource and the atomic-operations paths, so the hint carried a row that no longer exists. The delete now removes the row from the batch's values, as a delete within one push already does in the other backends.
737
+ - 96702f4: `JsonApiBackend` change hints now carry `oldValues`: each row an update or
738
+ delete touches is hinted with its value before the push, as `MemoryBackend`
739
+ does, so topic-derived fan-out (ADR-0023) pokes the windows a row moved away
740
+ from. A REST API keeps no change token, so the old value is read: each update
741
+ or delete makes one `GET /{type}/{id}` before its write, once per row per push,
742
+ on both the per-resource and the atomic path. That is one extra request per
743
+ update and delete. A row the push creates has no old value. A read that fails
744
+ fails the push, transiently or refused: a 404 or other 4xx included, so no update
745
+ or delete is sent without its old value. On the atomic path nothing has been
746
+ written by then, so the retry reads again. On the per-resource path the writes
747
+ before the failed read have already applied, so their hints are lost, as with any
748
+ transient write failure there.
749
+
750
+ A create or update's hinted value is now the server's returned document whenever
751
+ it sends one (the per-resource path used to ignore it), else the row read before
752
+ the push merged with the write, so a partial PATCH hints the full row.
753
+
754
+ Fix: a hinted create or update's relationships were the request's raw JSON:API
755
+ `{ data }` form. They now use the linkage shape `toRow` gives a server document
756
+ (`{ type, id }`, an array, or `null`), the shape listings and stored rows use, so
757
+ a hint matches the row it describes.
758
+
759
+ - 4230ed5: `JsonApiBackend` keeps its row cache current with its own writes, so `getRows` no longer serves a row a push has updated or deleted at its pre-push value. After an applied create, update or delete, the cache holds the row's value after the write (the server's returned document when it sends one), or no row after a delete. Before this, a row a listing had cached was served from the cache until the next listing.
760
+ - f1d0232: A per-resource push (no `atomic` endpoint) now refuses a mutation-id gap with a `BackendError` before it sends any write, as the atomic path already did. Before, a push could acknowledge a later mutation while an earlier one was never applied.
761
+ - 159997a: `JsonApiBackend` now fails the push when the pre-write read of an update or
762
+ delete does not return a row. A 4xx, a 404 included, fails the push the same way
763
+ a 5xx, timeout or transport error does: a `BackendError`, with nothing written on
764
+ the atomic path. A 2xx whose document names no resource fails it too. Before, a
765
+ refused read went ahead with no `oldValues` for that row. Every update or delete
766
+ now carries its old value or is not sent. A row the push creates is not read
767
+ unless the server refuses that create, so a 404 on it cannot fail an applied
768
+ create.
769
+
770
+ The cost is accepted: a row the server refuses to read stalls that client's
771
+ queue until the read can succeed or the client is reset. Deleting a row that is
772
+ already gone therefore fails with a 404 on every retry, where it used to be
773
+ acknowledged as a no-op. The failure reaches the client as a `TransportError`,
774
+ is retried by the client's retry policy, then surfaces as a `SyncFailure` with
775
+ `operation: "push"`. The in-session ledger belongs to one backend instance, so
776
+ after a restart an already-applied delete is sent again and stalls the same way.
777
+ A create the server refuses for a reason other than a conflict (a 422, say)
778
+ stalls the queue the same way when a later update or delete in the same push
779
+ touches that row, since the row is absent and its read finds a 404.
780
+
781
+ On the atomic path, a create the server refuses, followed by an update or delete
782
+ of the same row in the same batch, now reads that row once the refusal arrives,
783
+ before the batch is resent, so the later write carries the row's pre-push value.
784
+ The read follows the same rule as any other: a failed read fails the push with
785
+ nothing written.
786
+
787
+ A resource that receives updates or deletes but has no `getOne` route now fails
788
+ any push containing one of those writes (one whose args decode) before any write
789
+ in that push is sent, instead of logging a warning and proceeding without
790
+ `oldValues`. A mutation whose args do not decode is still refused at its turn, as
791
+ before: on the per-resource path the writes before it are delivered.
792
+
793
+ - 3be5abc: `JsonApiBackend` remembers the permanent rejections its in-session ledger has made, so a replayed copy of a rejected mutation lists it in `rejected` with the same reason, as Backend guarantee 1 requires (#366). Before, the copy was skipped with no outcome, so a client whose push response was lost settled the rejected mutation as applied. A push forgets the rejections below its lowest id per client, as `MemoryBackend` does. The ledger is in-session, so the memory lasts as long as the backend instance.
794
+ - b4a9656: Correct the account of what effect-jsonapi 0.15 changed in URL parsing (the
795
+ `effect-rc-floor` changeset mentioned only `filter`). Every comma-list query
796
+ parameter the façade, `JsonApiQuery.parseRead` and the REST handlers decode
797
+ now accepts the repeated-key spelling instead of answering 400: the shorthand
798
+ `filter[f]`, `sort`, and `fields[TYPE]`. Each repeated
799
+ form, and any mix of repeated and comma forms, decodes to the same read as
800
+ the single comma list (`?sort=-name&sort=id` ≡ `?sort=-name,id`,
801
+ `?fields[products]=name&fields[products]=description` ≡
802
+ `?fields[products]=name,description`). Positions that take exactly one value still reject a repeated key with a 400:
803
+ `page[...]`, a scalar operator such as `filter[f][gt]`, and the group form's
804
+ `[conjunction]` / `[path]` / `[operator]`. Encoding is unchanged: always the canonical comma
805
+ form.
806
+ - a1cea1c: Share the rejection-memory forgetting bound. `floorsOf` (a push's lowest mutation
807
+ id per client) and `forgetBelow` (drop a client's remembered rejections below its
808
+ floor) now live in a new `RejectionMemory` module, exported on
809
+ `@thomasfosterau/legation/internal` for adapter authors. `MemoryBackend` and
810
+ `JsonApiBackend` bound their rejection memory with `forgetBelow`; `SqlBackend` shares
811
+ only `floorsOf` and keeps its own forgetting loop. No behaviour changes.
812
+ - 507ccb1: Every tagged error is now a branded `Schema.TaggedError` rather than a
813
+ `Data.TaggedError`: `StoreError`, `TransportError`, `QueryError` and the rest
814
+ of the engine's errors, the JSON:API adapter's `SyncError` / `NetworkError` /
815
+ `ParseError` / `ApiError` / `ConflictError` / `ResourceBridgeError`, and
816
+ `HostNotReady`. Construction, `_tag`, `message` and `Effect.catchTag` are
817
+ unchanged; each class now carries a schema and a nominal brand, so a
818
+ structurally similar object no longer type-checks as one.
819
+ - 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.
820
+ - 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.
821
+ - Updated dependencies [2a1f63c]
822
+ - Updated dependencies [ef59fa7]
823
+ - Updated dependencies [56a2e51]
824
+ - Updated dependencies [d09dc65]
825
+ - Updated dependencies [db2366a]
826
+ - Updated dependencies [97db489]
827
+ - Updated dependencies [cc67999]
828
+ - Updated dependencies [a94df19]
829
+ - Updated dependencies [d76b04d]
830
+ - Updated dependencies [6f53fb1]
831
+ - Updated dependencies [d675d2e]
832
+ - Updated dependencies [1743c2b]
833
+ - Updated dependencies [1c56914]
834
+ - Updated dependencies [615e792]
835
+ - Updated dependencies [c8d9afc]
836
+ - Updated dependencies [4709f41]
837
+ - Updated dependencies [f0348b4]
838
+ - Updated dependencies [3e29e1b]
839
+ - Updated dependencies [a053736]
840
+ - Updated dependencies [8a8adfc]
841
+ - Updated dependencies [8995115]
842
+ - Updated dependencies [8995115]
843
+ - Updated dependencies [87fa7a5]
844
+ - Updated dependencies [822001c]
845
+ - Updated dependencies [4c59860]
846
+ - Updated dependencies [961e604]
847
+ - Updated dependencies [5937efe]
848
+ - Updated dependencies [08ac9cd]
849
+ - Updated dependencies [1fc3725]
850
+ - Updated dependencies [8995115]
851
+ - Updated dependencies [8a8adfc]
852
+ - Updated dependencies [025fabb]
853
+ - Updated dependencies [0b8fccb]
854
+ - Updated dependencies [e7369e1]
855
+ - Updated dependencies [8995115]
856
+ - Updated dependencies [c856d66]
857
+ - Updated dependencies [01a8c64]
858
+ - Updated dependencies [3362c37]
859
+ - Updated dependencies [62dcb1c]
860
+ - Updated dependencies [be4be06]
861
+ - Updated dependencies [ae18cb3]
862
+ - Updated dependencies [be0a44d]
863
+ - Updated dependencies [cc2f5b7]
864
+ - Updated dependencies [e818dc4]
865
+ - Updated dependencies [d1b01a4]
866
+ - Updated dependencies [5496ddf]
867
+ - Updated dependencies [19eb4b0]
868
+ - Updated dependencies [a874acb]
869
+ - Updated dependencies [c5d3484]
870
+ - Updated dependencies [38c3147]
871
+ - Updated dependencies [e9363c0]
872
+ - Updated dependencies [fed4634]
873
+ - Updated dependencies [fcc52eb]
874
+ - Updated dependencies [454e75c]
875
+ - Updated dependencies [2003cd6]
876
+ - Updated dependencies [52c614f]
877
+ - Updated dependencies [94d6327]
878
+ - Updated dependencies [91f07db]
879
+ - Updated dependencies [6e9e119]
880
+ - Updated dependencies [6c2c802]
881
+ - Updated dependencies [ebcc6aa]
882
+ - Updated dependencies [a1b95d4]
883
+ - Updated dependencies [c105fbf]
884
+ - Updated dependencies [e1a028a]
885
+ - Updated dependencies [dfb8759]
886
+ - Updated dependencies [9260efb]
887
+ - Updated dependencies [0644af5]
888
+ - Updated dependencies [4b05d55]
889
+ - Updated dependencies
890
+ - Updated dependencies [82911cc]
891
+ - Updated dependencies [9c9f2b0]
892
+ - Updated dependencies [7b21d11]
893
+ - Updated dependencies [6f54de8]
894
+ - Updated dependencies [ab1fb4e]
895
+ - Updated dependencies [792d8f5]
896
+ - Updated dependencies [a3b2ee4]
897
+ - Updated dependencies [eb9555a]
898
+ - Updated dependencies [c0ab703]
899
+ - Updated dependencies [8c65627]
900
+ - Updated dependencies [26ad3f3]
901
+ - Updated dependencies [d7cafd2]
902
+ - Updated dependencies [cb0a33d]
903
+ - Updated dependencies [912d95d]
904
+ - Updated dependencies [7290d2b]
905
+ - Updated dependencies [41c33e3]
906
+ - Updated dependencies [a77261c]
907
+ - Updated dependencies [c47786c]
908
+ - Updated dependencies [501b12c]
909
+ - Updated dependencies [7f1efac]
910
+ - Updated dependencies [a04918b]
911
+ - Updated dependencies [cf0b26c]
912
+ - Updated dependencies [ddb1fda]
913
+ - Updated dependencies [f54f568]
914
+ - Updated dependencies [fc880dc]
915
+ - Updated dependencies [9cbd160]
916
+ - Updated dependencies [0bebedf]
917
+ - Updated dependencies [bc4c599]
918
+ - Updated dependencies [20a6e18]
919
+ - Updated dependencies [b4d2b80]
920
+ - Updated dependencies [2201b4c]
921
+ - Updated dependencies [0cdefe7]
922
+ - Updated dependencies [66f3b5b]
923
+ - Updated dependencies [7e11e61]
924
+ - Updated dependencies [6175e38]
925
+ - Updated dependencies [caf4761]
926
+ - Updated dependencies [821b10d]
927
+ - Updated dependencies [9e74a83]
928
+ - Updated dependencies [49fd066]
929
+ - Updated dependencies [06405ed]
930
+ - Updated dependencies [e7f4871]
931
+ - Updated dependencies [916d91b]
932
+ - Updated dependencies [47f4e15]
933
+ - Updated dependencies [f829146]
934
+ - Updated dependencies [13658a7]
935
+ - Updated dependencies [31cfb2c]
936
+ - Updated dependencies [a1cea1c]
937
+ - Updated dependencies [7e94abc]
938
+ - Updated dependencies [4fbd0b7]
939
+ - Updated dependencies [9d2f21b]
940
+ - Updated dependencies [3016170]
941
+ - Updated dependencies [f4a1a60]
942
+ - Updated dependencies [507ccb1]
943
+ - Updated dependencies [c671ccf]
944
+ - Updated dependencies [16889f0]
945
+ - Updated dependencies [fb2a992]
946
+ - Updated dependencies [22f320d]
947
+ - Updated dependencies [652ae7b]
948
+ - Updated dependencies [83bcf76]
949
+ - Updated dependencies [20e57d3]
950
+ - Updated dependencies [f010749]
951
+ - Updated dependencies [8d02113]
952
+ - Updated dependencies [bf9054d]
953
+ - Updated dependencies [e40c73a]
954
+ - Updated dependencies [ca38874]
955
+ - Updated dependencies [27e1040]
956
+ - Updated dependencies [e68eb3e]
957
+ - Updated dependencies [985c4d5]
958
+ - Updated dependencies [3be5abc]
959
+ - Updated dependencies [97db489]
960
+ - Updated dependencies [4a23603]
961
+ - Updated dependencies [f441941]
962
+ - Updated dependencies [a01f43b]
963
+ - Updated dependencies [9a6426b]
964
+ - Updated dependencies [97cc5f9]
965
+ - Updated dependencies [fc880dc]
966
+ - Updated dependencies [e34bc24]
967
+ - Updated dependencies [0470e1c]
968
+ - Updated dependencies [439f449]
969
+ - Updated dependencies [3a88118]
970
+ - Updated dependencies [1b8b120]
971
+ - Updated dependencies [cf48723]
972
+ - Updated dependencies [b4489c5]
973
+ - @thomasfosterau/legation@0.1.0