@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/README.md CHANGED
@@ -1,3 +1,756 @@
1
1
  # @thomasfosterau/legation-jsonapi
2
2
 
3
- Placeholder reserved for npm trusted publishing; the first real release is 0.1.0, from https://github.com/thomasfosterau/legation.
3
+ JSON:API v1.1 collection loaders for the
4
+ [`@thomasfosterau/legation`](../effect)
5
+ sync engine, built on [Effect](https://effect.website).
6
+
7
+ In the engine's model (ADR-0008) a JSON:API **`type` is a collection**, a
8
+ resource **`id` is a row id**, and a **resource is a row value**. This package
9
+ maps JSON:API documents into engine collection rows and — through a
10
+ convention-first derivation — wires an app's existing JSON:API REST API into the
11
+ full offline/optimistic sync engine with no bespoke backend to write.
12
+
13
+ It is **read-side**: reads come from JSON:API documents, while writes are the
14
+ engine's named mutators (create/update/delete replayed as `POST`/`PATCH`/`DELETE`,
15
+ or pushed as one `atomic:operations` request when the server supports the
16
+ [Atomic Operations extension](https://jsonapi.org/ext/atomic/)), not ad-hoc
17
+ REST calls.
18
+
19
+ The JSON:API wire types/schemas and query encoding are adopted from
20
+ [`@thomasfosterau/effect-jsonapi`](https://www.npmjs.com/package/@thomasfosterau/effect-jsonapi)
21
+ (the spec-compliant source of truth); this package layers the generic,
22
+ resource-agnostic loaders on top (ADR-0016).
23
+
24
+ > This package was formerly published as `@thomasfosterau/effect-chancery`.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install @thomasfosterau/legation-jsonapi @thomasfosterau/legation effect
30
+ ```
31
+
32
+ `effect` and `@thomasfosterau/legation` are required alongside this package;
33
+ `@thomasfosterau/effect-jsonapi` is a regular dependency.
34
+
35
+ ## Effect version
36
+
37
+ Peers `effect ^4.0.0`. Tested against `4.1.2` (the version CI pins) and `4.0.0` (the floor; CI runs the tests against it on every PR); install one copy of `effect` in your app (ADR-0003).
38
+
39
+ ## The happy path: sync an existing JSON:API
40
+
41
+ `JsonApiSync` reflects an app's Effect `HttpApi`, classifies its endpoints into
42
+ JSON:API CRUD roles by convention (method + path), derives one collection and
43
+ the available CRUD mutators per resource type, and composes the whole
44
+ client-direct sync stack — journal, optimistic overlay, rebase, live queries —
45
+ over the existing REST API.
46
+
47
+ ```ts
48
+ import { Duration, Effect, Layer } from "effect";
49
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
50
+ import { Predicate } from "@thomasfosterau/legation";
51
+ import { ClientEngine, Subset } from "@thomasfosterau/legation/internal";
52
+ import { JsonApiSync } from "@thomasfosterau/legation-jsonapi";
53
+ import { api } from "./my-json-api"; // your app's Effect HttpApi
54
+
55
+ const sync = JsonApiSync.layer(api, {
56
+ baseUrl: "https://api.example.com",
57
+ clientId: "device-1",
58
+ clientGroupId: "user-42",
59
+ // Both legs are opt-in: without `pushDebounce` a journaled mutation waits
60
+ // for a reconnect, a poke-driven pull, or an explicit `push` — an
61
+ // already-connected client would sit on unflushed writes. `pullInterval`
62
+ // is the correctness backstop for when pokes don't arrive.
63
+ pullInterval: Duration.seconds(5),
64
+ pushDebounce: Duration.millis(300),
65
+ }).pipe(Layer.provide(FetchHttpClient.layer));
66
+
67
+ const program = Effect.gen(function* () {
68
+ // Run the sync loop (push-on-mutate + periodic pull; lives until the scope closes).
69
+ yield* JsonApiSync.start;
70
+
71
+ const engine = yield* ClientEngine.ClientEngine;
72
+ const open = Subset.make({
73
+ collection: "articles",
74
+ where: Predicate.eq("status", "open"),
75
+ });
76
+
77
+ yield* engine.subscribe(open);
78
+ yield* engine.pull;
79
+ return yield* engine.query(open);
80
+ }).pipe(Effect.provide(sync), Effect.scoped);
81
+
82
+ const articles = await Effect.runPromise(program);
83
+ ```
84
+
85
+ `JsonApiSync.layer` requires an `HttpClient` and returns a ready `ClientEngine`.
86
+ The CVR store and invalidation bus are in-process and the client store is
87
+ in-memory; swap in a durable `ClientStore` layer for offline persistence across
88
+ reloads.
89
+
90
+ ### Endpoint conventions
91
+
92
+ Endpoints are classified by HTTP method and path shape — no per-endpoint
93
+ annotation required. The resource `type` is the last static path segment (any
94
+ `/api` or version prefix is ignored):
95
+
96
+ | Method | Path shape | Role |
97
+ | ------------- | ------------- | -------- |
98
+ | `GET` | `/{type}` | `list` |
99
+ | `GET` | `/{type}/:id` | `getOne` |
100
+ | `POST` | `/{type}` | `create` |
101
+ | `PATCH`/`PUT` | `/{type}/:id` | `update` |
102
+ | `DELETE` | `/{type}/:id` | `delete` |
103
+
104
+ A CRUD mutator is derived only when its endpoint exists (no `POST /{type}` ⇒ no
105
+ `create` mutator). Endpoints that don't fit the table (nested routes, custom
106
+ actions) are simply not part of the derived sync surface. Use
107
+ `JsonApiSync.inspect(api)` to see the reflected resources, collections,
108
+ mutators, operations, and the atomic operations endpoint (below), if any.
109
+
110
+ ### Atomic operations
111
+
112
+ If the api advertises the JSON:API
113
+ [Atomic Operations extension](https://jsonapi.org/ext/atomic/) — an endpoint
114
+ whose request payload is an `atomic:operations` document, such as
115
+ effect-jsonapi's `Endpoint.operations([...])` (conventionally
116
+ `POST /operations`) — `JsonApiSync` detects it and pushes each batch of
117
+ journalled mutations as **one request**: `create` → `add`, `update` →
118
+ `update`, `delete` → `remove`, sent with the extension's media type. The
119
+ endpoint is recognised by its payload, not its path, so it never reads as a
120
+ phantom `operations` resource.
121
+
122
+ ```ts
123
+ JsonApiSync.layer(api, { ...options, atomic: false }); // one request per mutation
124
+ JsonApiSync.layer(api, { ...options, atomic: { path: "/batch" } }); // an explicit endpoint
125
+ ```
126
+
127
+ Delivery over the atomic endpoint is all-or-nothing per batch: nothing is
128
+ acknowledged until the server has applied every operation. A transient failure
129
+ (transport, 5xx, or a 4xx that names no operation) leaves the server holding
130
+ nothing and the client retries the whole batch. A 4xx error document whose
131
+ `source.pointer` names an operation (`/atomic:operations/{index}`) is a
132
+ permanent rejection of that mutation: it is reported as rejected, the batch is
133
+ resent without it, and every mutation in the batch is then acknowledged in
134
+ order — the poison-mutation semantics of the core contract. Creates keep
135
+ sending the client-generated `id`; `lid` is supported by the wire format but
136
+ unused until a mutator needs a server-assigned id.
137
+
138
+ Without the endpoint, each write is its own `POST`/`PATCH`/`DELETE` and
139
+ delivery is at-least-once: keep writes idempotent (client-generated ids on
140
+ create; `PATCH`/`DELETE` are naturally idempotent).
141
+
142
+ Mutators beyond the derived CRUD ones can ride the same backend: give
143
+ `JsonApiBackend.make` a `writes` map from mutator name to a function expressing
144
+ its decoded args as one JSON:API `Write` (a create, update or delete of one
145
+ resource), and both delivery paths carry it.
146
+
147
+ ### The ledger across restarts
148
+
149
+ The backend keeps a ledger for each client group: the highest acknowledged
150
+ mutation id per client, so a resent mutation that was already applied is
151
+ skipped, and the permanent rejections it has made, so a replayed copy and a
152
+ pull can report them with their reasons.
153
+
154
+ By default, with no durable store, the ledger lives in the memory of the backend
155
+ instance, and **a restart of the backend forgets it**. A resent mutation that was
156
+ already applied then looks new, guarded only by the server's own idempotency if it
157
+ has any, and a permanent rejection is forgotten, so neither a replayed copy nor a
158
+ pull can report it.
159
+
160
+ The ledger is durable when its store is. `Ledger.keyValue` keeps each group's
161
+ ledger in an Effect `KeyValueStore`, which survives a restart when that store
162
+ does, such as a file system, SQL or browser storage layer. Pass the durable
163
+ key/value store your client store is built over as `keyValueStore`, and
164
+ `JsonApiSync.layer` uses it for both: the client store defaults to
165
+ `ClientStore.layerKeyValue()` over it, and the ledger defaults to
166
+ `Ledger.keyValue` over it, so the journal and the ledger survive a reload together:
167
+
168
+ ```ts
169
+ import { JsonApiSync } from "@thomasfosterau/legation-jsonapi";
170
+
171
+ JsonApiSync.layer(api, {
172
+ ...options,
173
+ // The durable key/value store (e.g. the one from the browser wiring below).
174
+ keyValueStore,
175
+ });
176
+ ```
177
+
178
+ An explicit `store` replaces the default client store, and an explicit `ledger`
179
+ overrides the default ledger. If you pass both `store` and `keyValueStore`, build
180
+ `store` over the same key/value store: the layer cannot check that, because
181
+ `store` is an opaque layer. Pass `ledger: Ledger.keyValue(keyValueStore)` yourself
182
+ to name a store the client store does not use, and `JsonApiBackend.make` takes the
183
+ same `ledger` option for a server built by hand.
184
+
185
+ The client store and the ledger must be durable together. A durable `ledger`
186
+ with the in-memory client store loses writes silently: a reload restarts the
187
+ client's mutation ids at 1, the ledger already acknowledges them, and the
188
+ backend skips the new writes as already applied. A durable `store` with the
189
+ in-memory ledger stalls instead: a reload forgets the acknowledged ids, and the
190
+ backend refuses every later push with a mutation-id gap. Passing
191
+ `keyValueStore` covers both.
192
+
193
+ A ledger store is a `Ledger.LedgerStore` with two operations, `read` and
194
+ `write`, for one client group's ledger. Write your own to keep the ledger
195
+ somewhere else.
196
+
197
+ In the browser, `IndexedDbClientStore` keeps its key/value store internal to
198
+ `@thomasfosterau/legation-browser`, so take one from `@effect/platform-browser`
199
+ instead and provide it to the layer:
200
+
201
+ ```ts
202
+ import { Effect, Layer } from "effect";
203
+ import { BrowserKeyValueStore, IndexedDb } from "@effect/platform-browser";
204
+ import { KeyValueStore } from "effect/persistence";
205
+ import { JsonApiSync } from "@thomasfosterau/legation-jsonapi";
206
+
207
+ const syncLayer = Layer.unwrap(
208
+ Effect.gen(function* () {
209
+ const keyValueStore = yield* KeyValueStore.KeyValueStore;
210
+ return JsonApiSync.layer(api, { ...options, keyValueStore });
211
+ })
212
+ ).pipe(
213
+ Layer.provide(
214
+ BrowserKeyValueStore.layerIndexedDb({ database: "legation-jsonapi" }).pipe(
215
+ Layer.provide(IndexedDb.layerWindow)
216
+ )
217
+ )
218
+ );
219
+ ```
220
+
221
+ Name its `database`: the platform default, `effect_key_value_store`, is the
222
+ database `IndexedDbClientStore` uses by default. This store is durable, but it
223
+ is not `IndexedDbClientStore`: it has no atomic batch, so the client store
224
+ commits a pull's release through a release record that is finished on the next
225
+ open if a crash interrupts it, rather than in one IndexedDB transaction (#393).
226
+ It also opens its database at version 1, so it cannot open a database another
227
+ version has upgraded.
228
+
229
+ The ledger is keyed by `clientGroupId` under one prefix. If one key/value store
230
+ holds more than one identity's ledgers on a device, give each identity its own
231
+ prefix (`IndexedDbClientStore.layerNamespaced` does the same for its own client
232
+ store), and pass the prefixed store as `keyValueStore` so the client store and
233
+ the ledger share it:
234
+ ``KeyValueStore.prefix(kv, `legation/identity/${Identity.keySegment(identity)}/`)``,
235
+ where `identity` is the identity's `Identity.Fingerprint` (the string
236
+ `IndexedDbClientStore`'s `identity` option takes), `Identity` comes from
237
+ `@thomasfosterau/legation` and `KeyValueStore` from `effect/persistence`.
238
+ Otherwise identities that share a `clientId` and `clientGroupId` share one
239
+ ledger, and one identity's mutations can be skipped as already applied.
240
+
241
+ Each ledger change is written after the server has applied the writes it
242
+ acknowledges and before the ids are acknowledged. If that ledger write fails
243
+ after the server write succeeded, the push fails with a `BackendError`.
244
+ Mutations acknowledged earlier in the same push keep their acknowledgement; the
245
+ failed one and the rest of the push are not acknowledged, so the client's resend
246
+ re-applies them. On the atomic path a rejection recorded before the failing write
247
+ stays remembered, and the resend records it again. That is the same window as a
248
+ crash between the two writes, with or without a store, and the server's own
249
+ idempotency is what guards it. A store that fails to read also
250
+ fails the push with a `BackendError`, and fills no cache. A stored value that does
251
+ not decode fails it too, so a corrupt store is never read as an empty ledger. Run
252
+ one backend instance per server (see [Row cache](#row-cache)), so one store is
253
+ written by one instance.
254
+
255
+ ### Change hints
256
+
257
+ Each push reports the rows it wrote as change hints, which fan-out uses to poke
258
+ the subscribers whose windows those rows match. A hint carries each row's value
259
+ after the push (`values`) and, for each row an update or delete touched, its
260
+ value before the push (`oldValues`), so a row that moved out of a window still
261
+ pokes that window.
262
+
263
+ A REST API exposes no change token, so the old value is read rather than
264
+ cached: each update or delete makes one `GET /{type}/{id}` before its write,
265
+ once per row per push, on both the per-resource and the atomic path. Each such
266
+ read is an extra request. A row the push creates was absent before the push, so
267
+ it is not read and has no old value. The one exception is on the atomic path: a
268
+ create the server refuses, followed by an update or delete of the same row in
269
+ the same batch, is read once the refusal arrives, before the batch is resent, so
270
+ that later write carries the row's pre-push value.
271
+
272
+ Every update or delete carries its old value or is not sent. A read that fails
273
+ for any reason fails the push with a `BackendError`: a transport error, a 5xx, a
274
+ 408 or 429, a 4xx such as 403, a 404 (the row is absent), or a 2xx whose document
275
+ names no resource. On the atomic path nothing has been written by then, so the
276
+ retry reads again. On the per-resource path the writes before the failed read
277
+ have already applied and been acknowledged, so the retry does not resend them
278
+ and their hints are lost: that path is at-least-once, and a transient write
279
+ failure loses those hints the same way.
280
+
281
+ A refused read stalls the client's queue until the read can succeed or the
282
+ client is reset. Two consequences follow:
283
+
284
+ - Deleting a row that is already gone fails with a 404 on the read, so every
285
+ retry fails the same way. The delete used to be acknowledged as a no-op.
286
+ - A create the server refuses for a reason other than a conflict (a 422, say)
287
+ stalls the queue in the same way when a later update or delete in the same
288
+ push touches that row. The row is absent, so its read finds a 404.
289
+ - The stall reaches the application through the sync loop. The `BackendError`
290
+ arrives at the client as a `TransportError`, the client's retry policy retries
291
+ it (by default, exponential backoff from 1 second, capped at 30 seconds, for
292
+ five retries), and the push then surfaces as a `SyncFailure` with
293
+ `operation: "push"` on `syncFailures`. The loop does not retry in a tight
294
+ loop. With `syncLoop.pushDebounce` set, the next push comes with the next
295
+ local mutation; otherwise it waits for a reconnect or an explicit `push`.
296
+ - The ledger belongs to one backend instance unless it has a durable store (see
297
+ [The ledger across restarts](#the-ledger-across-restarts)). After a
298
+ restart without one, a delete that had already applied is sent again, its read
299
+ finds nothing, and the queue stalls in the same way.
300
+
301
+ A resource that receives updates or deletes must have a `getOne` route, since
302
+ that is how its rows are read. A push that contains one of its updates or
303
+ deletes (one whose args decode) fails with a `BackendError` before any write in
304
+ that push is sent, on both paths, which stalls that client's queue until the
305
+ route exists. A resource
306
+ that only receives creates needs no `getOne` route.
307
+
308
+ ### Row cache
309
+
310
+ `getRows` serves a row from the backend's cache when it holds one. The cache
311
+ takes the rows a listing (`evaluate`) returns, and the value each write this
312
+ instance applies leaves a row with; a write the server rejects or fails leaves
313
+ its row as it was. A delete this instance applies removes its row from the
314
+ cache. The cache reflects this instance's own writes only, and nothing tells it
315
+ about writes made elsewhere. So run one `JsonApiBackend` per JSON:API server,
316
+ and write that server only through it: with a second instance, or another
317
+ writer, `getRows` can serve a row that has since been changed or deleted. A row
318
+ not in the cache is fetched with `GET /{type}/{id}`, which needs a `getOne`
319
+ route.
320
+
321
+ ## Standalone collection loading
322
+
323
+ `CollectionLoader` reads JSON:API endpoints into rows directly. Every helper is
324
+ an `Effect` requiring an `HttpClient` and a `JsonApiSource`.
325
+
326
+ ```ts
327
+ import { Effect } from "effect";
328
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
329
+ import { Filter } from "@thomasfosterau/effect-jsonapi";
330
+ import { CollectionLoader } from "@thomasfosterau/legation-jsonapi";
331
+
332
+ const program = CollectionLoader.load("articles", {
333
+ include: ["author"],
334
+ sort: [{ field: "createdAt", direction: "desc" }],
335
+ filter: Filter.eq("status", "published"),
336
+ page: { number: 1, size: 20 },
337
+ }).pipe(
338
+ Effect.provide(FetchHttpClient.layer),
339
+ Effect.provideService(CollectionLoader.JsonApiSource, {
340
+ baseUrl: "https://api.example.com",
341
+ })
342
+ );
343
+
344
+ const { rows, included } = await Effect.runPromise(program);
345
+ ```
346
+
347
+ - `load(type, options?)` — `GET /{type}` → `{ rows, included }`.
348
+ - `loadOne(type, id, options?)` — `GET /{type}/{id}` → a single row, or
349
+ `undefined` if absent.
350
+ - `documentToRows(document)` — map an already-parsed `JsonApiDocument` to its
351
+ primary rows and its compound `included` rows.
352
+
353
+ The request `options` are a `JsonApiRequestOptions`: `include`, `fields`,
354
+ `sort` (the library's `{ field, direction }` term shape), `filter` (a filter
355
+ grammar AST — `Filter.eq("status", "published")`, `Filter.and(...)` — encoded
356
+ exactly through the grammar's canonical `filter[...]` form), and `page` (an
357
+ offset/limit, number/size, or cursor/size strategy from the library's
358
+ `Query.Page`).
359
+
360
+ ## Relationships
361
+
362
+ A JSON:API resource row carries relationship **linkage** (`{ type, id }`
363
+ references). Once the referenced collections are synced into the local mirror, a
364
+ relationship name resolves to the related row value(s) — a client-side join over
365
+ already-synced data, no extra request.
366
+
367
+ ```ts
368
+ import { Effect, Stream } from "effect";
369
+ import { ClientEngine, Subset } from "@thomasfosterau/legation/internal";
370
+ import { Relationships } from "@thomasfosterau/legation-jsonapi";
371
+
372
+ const program = Effect.gen(function* () {
373
+ const engine = yield* ClientEngine.ClientEngine;
374
+ const articles = Subset.make({ collection: "articles" });
375
+
376
+ // Auto-subscribes the referenced collections and re-emits on any change.
377
+ const joined = yield* Relationships.related(engine, articles, ["author", "tags"]);
378
+ yield* Stream.runForEach(joined, (rows) =>
379
+ Effect.sync(() => console.log(rows[0]?.related.author))
380
+ );
381
+ });
382
+ ```
383
+
384
+ - `related(engine, subset, names)` — auto-subscribes the base subset and the
385
+ `id IN (...)` subsets of each referenced collection, then streams the resolved
386
+ rows. Pair with the backend's `includes` config to deliver related rows in the
387
+ parent's response (no extra round trip).
388
+ - `observe(engine, subset, names)` — a live, incrementally-maintained join
389
+ (you manage the subscriptions).
390
+ - `query(engine, subset, names)` — a one-shot resolved snapshot.
391
+
392
+ ## Query encoding, URL parsing, and canonical strings
393
+
394
+ `JsonApiQuery` is the façade-boundary bridge (ADR-0029 §6) between
395
+ effect-jsonapi's **filter grammar** — a query-parameter format, decoded from
396
+ untrusted URLs, its literals typed by a resource's declared attributes — and
397
+ `Predicate`, legation's own subset language (ADR-0028). They are two
398
+ different things, not two dialects of one language; this bridge is a
399
+ projection for one job, not a general translation:
400
+
401
+ - **Filter AST ⇄ `Predicate`** — both directions are partial, and neither is
402
+ the other's inverse. `fromFilterAst(ast)` fails with a typed `QueryError`
403
+ for a literal that is not a wire scalar or a `Date` (a `Date` becomes its
404
+ ISO-8601 instant text — a load-bearing **encode**, not a copy, since
405
+ ADR-0028 rule (e) fixes instant comparison to that exact format).
406
+ `toFilterAst(predicate)` returns `None` where the grammar has no form:
407
+ `Exists` (server-only), an empty `In` / `NotIn` list, a `null` literal.
408
+ `foldConstants` folds the two definite cases away exactly (an empty `In`
409
+ is false, an empty `NotIn` true) before anything is encoded. Both
410
+ functions assume a **field-namespace precondition**: field names are
411
+ copied verbatim, correct only because every caller here pre-synthesises a
412
+ resource whose field names are already the engine's (`resourcesOf`).
413
+ - **URL → `Query.Read`** — `parseRead(nodes, url)` / `parseParams(nodes,
414
+ collection, params)` decode `include`, `fields`, `sort`, `page` and `filter`
415
+ with effect-jsonapi's `Query.schema` derived from the node graph (each
416
+ node's filterable set and edges become the codec's declaration), then map
417
+ them onto the read: `filter` → `where`, `sort` → `order`, `page[limit]` /
418
+ `page[after]` → `first` / `after`, `include` → the edge selects (single
419
+ level only). `fields[]` rides beside the read as a projection, never into
420
+ the plan. A nested include path, an undeclared field or operator, a literal
421
+ of the wrong kind, an unsortable field or a bad page fails with a
422
+ `QueryError` whose `parameter` names the offending key — the 400's
423
+ `source.parameter`.
424
+ - **`Query.Read` → canonical string** — `canonical(nodes, read)` compiles
425
+ the read and encodes its plan through effect-jsonapi's canonical encoder:
426
+ `/products?filter[categoryId]=c-audio&sort=id&page[limit]=12`. Two reads
427
+ with one plan give one string, whatever their spelling; it is the
428
+ subscription identity of ADR-0029, and `parseRead` accepts it back.
429
+
430
+ The shipped `defaultEncoder` is what `JsonApiBackend` pushes to the server:
431
+ the **whole** predicate as the grammar's canonical `filter[...]` form
432
+ (operators, `or`, `not`, `isnull`, `nin` included — nothing is dropped), plus
433
+ `orderBy` as `sort`. A subset whose predicate folds to false is answered
434
+ without a request. Order, cursor and limit stay local: `Subset.apply` still
435
+ finalises the window, now as redundancy over an exact filter rather than as
436
+ correction of a superset. Pass your own `queryEncoder` to `JsonApiSync.layer`
437
+ to match a server with a different filter contract.
438
+
439
+ ## Derived REST handlers
440
+
441
+ A JSON:API `GET` is the engine's `read` operation (ADR-0025) with a document
442
+ body, so a `list` / `get` endpoint can be derived from the node graph instead
443
+ of hand-written: the URL is parsed by the one grammar, the request's identity
444
+ becomes a `SyncContext`, and the read runs through the same filterable check,
445
+ the same deny-by-default auth filters and the same `Backend.evaluate` +
446
+ `getRows` the sync tier runs — two carriers, one evaluator, one auth path.
447
+
448
+ ```ts
449
+ import { Effect } from "effect";
450
+ import { HttpApiBuilder } from "effect/http-api";
451
+ import { RestHandlers } from "@thomasfosterau/legation-jsonapi";
452
+ import { api, NotFound, Product } from "./my-json-api";
453
+ import { backend, nodes, policy } from "./my-server";
454
+
455
+ const rest = RestHandlers.make({
456
+ nodes,
457
+ backend,
458
+ policy,
459
+ // HTTP request → SyncContext: identity from the verified session, never from a client-supplied id.
460
+ context: (request) =>
461
+ Effect.succeed({ clientGroupId: "http", identity: request.headers["x-user"] }),
462
+ prefix: "/api",
463
+ });
464
+
465
+ const productsLive = HttpApiBuilder.group(api, "products", (handlers) =>
466
+ handlers
467
+ .handle("list", rest.list(Product))
468
+ .handle("get", rest.get(Product, { notFound: () => new NotFound() }))
469
+ );
470
+ ```
471
+
472
+ What the derivation guarantees:
473
+
474
+ - **Scoping is the policy's.** A request without identity to a private
475
+ collection sees the policy's empty allow-set: an empty collection for
476
+ `list`, a 404 for `get` — exactly what the sync tier syncs. No `WHERE
477
+ userId` lives in a handler.
478
+ - **Sorting and paging are `Subset.apply`'s** (ADR-0026 / ADR-0028): code-point
479
+ text order, NULLs last, the id tie-break, `page[after]` as the cursor.
480
+ `links.next` continues after the last row when the window is full.
481
+ - **A URL the grammar rejects is the codec's 400** with `source.parameter`,
482
+ before any backend call; a collection the policy does not declare is a 403.
483
+ - **Includes resolve in two phases**, and every `Relationship.many` the
484
+ endpoint's resource declares is resolved for linkage — on the primary and
485
+ the included resources alike, since effect-jsonapi's resource schemas
486
+ require every declared relationship — while nothing the resource does not
487
+ declare is touched; `include` decides what is compounded into `included`.
488
+ A paginated node edge lists and includes under a `Relationship.many`
489
+ declaration (links plus its rows' linkage) and carries links only under
490
+ `Relationship.paginated`.
491
+ - **`fields[type]` is a projection** applied at serialisation over full rows
492
+ (ADR-0029 §5). A resource that serves sparse fieldsets declares its
493
+ attributes `Resource.attribute(schema, { resource: "optional" })`, or the
494
+ endpoint's success encoder refuses the sparse object.
495
+
496
+ Derivation is opt-in per endpoint, beside hand-written handlers on the same
497
+ api. The rows-to-document step is its own module, `DocumentWriter`: a pure
498
+ function of the node graph, the read, the resolved rows and the projection,
499
+ so the service-worker façade serving the same URL from the replica produces
500
+ the same document content (ADR-0029 §6).
501
+
502
+ ## The same documents from the replica
503
+
504
+ `ReplicaDocuments` is the other caller of that one serialiser: it runs a URL
505
+ (or a parsed read with its `fields[]` projection) over a local view — the
506
+ rows the replica holds, optimistic overlay included — and writes the document
507
+ the derived REST handler would write for the same URL. It depends on nothing
508
+ but `effect` and the engine, so it runs where the engine runs, a service
509
+ worker included; the façade (ADR-0029) is built on it.
510
+
511
+ ```ts
512
+ import { Effect } from "effect";
513
+ import { JsonApiQuery, ReplicaDocuments } from "@thomasfosterau/legation-jsonapi";
514
+ import { nodes } from "./my-domain";
515
+ import { CartItem, Product, Variant } from "./my-json-api";
516
+
517
+ const docs = ReplicaDocuments.make({
518
+ nodes,
519
+ // The resources the REST endpoints are declared with, so linkage matches them.
520
+ resources: new Map([Product, Variant, CartItem].map((resource) => [resource.type, resource])),
521
+ prefix: "/api",
522
+ });
523
+
524
+ // In the worker: serve only what the replica covers (ADR-0029 §3).
525
+ const serve = (url: string) =>
526
+ Effect.gen(function* () {
527
+ const { read } = yield* JsonApiQuery.parseRead(nodes, url);
528
+ const verdicts = yield* Effect.forEach(yield* docs.toCover(read), (needed) =>
529
+ client.coverageOf(needed)
530
+ );
531
+ if (verdicts.some((verdict) => verdict._tag === "Uncovered")) return undefined; // network
532
+ return yield* docs.fromUrl(client.view, url); // a collection or single-resource document
533
+ });
534
+ ```
535
+
536
+ - **Linkage follows the same declarations.** Pass the resources the REST
537
+ endpoints are declared with and the replica emits exactly the
538
+ relationships the derived handler emits, by construction (one linkage
539
+ derivation, two callers). Without `resources`, the node graph's synthetic
540
+ resources (`JsonApiQuery.resourcesOf(nodes)`) apply: every collection edge
541
+ resolved, paginated edges as `many`.
542
+ - **One grammar on both sides.** URLs are parsed with the node-derived codec,
543
+ so parity holds when the endpoint is declared with the same codec
544
+ (`JsonApiQuery.querySchema(nodes, collection)`, or equivalent declarations)
545
+ and collection edges are `many`. An `include` edge or a `fields[type]` type
546
+ the bound resource does not declare is refused with the codec's own
547
+ `QueryError` shape before any query runs.
548
+ - **Coverage is the caller's guard.** The writer cannot tell a missing row
549
+ from an absent one, so `toCover(read)` names the reads a request needs
550
+ covered — the read itself, plus one whole-collection read per collection
551
+ the declared linkage resolves from beyond the read's includes. Serve a URL
552
+ only when every one is covered; an uncovered URL goes to the network.
553
+ - **`fromUrl` writes the shape the path asks for**: a single-resource
554
+ document (`data: null` when the replica holds no row) for `/{type}/{id}`, a
555
+ collection document otherwise. A URL the grammar rejects, or one naming a
556
+ collection no bound resource declares, fails with a `QueryError` — never a
557
+ defect, since a URL is network input.
558
+
559
+ ## The façade: covered JSON:API `GET`s answered from the replica
560
+
561
+ `JsonApiFacade` is ADR-0029's third front over one engine: a fetch handler a
562
+ service worker composes **first** — before its HTTP cache and the network —
563
+ that answers a JSON:API `GET` from the replica when an explicit subscription
564
+ covers it, and stays out of the way otherwise. It is built from the app's
565
+ `HttpApi` (route reflection, as `JsonApiSync` does), the graph, the replica's
566
+ view and a coverage source; it lives here rather than in `@thomasfosterau/legation-browser`
567
+ because everything it needs is this package's plus the standard `Request` /
568
+ `Response` globals (ADR-0006).
569
+
570
+ ```ts
571
+ import { Effect } from "effect";
572
+ import { ClientView, Coverage } from "@thomasfosterau/legation/internal";
573
+ import { ServiceWorkerHost } from "@thomasfosterau/legation-browser";
574
+ import { JsonApiFacade, JsonApiQuery } from "@thomasfosterau/legation-jsonapi";
575
+ import { api, resources } from "./my-json-api"; // the HttpApi and its Resource declarations
576
+ import { nodes } from "./my-domain";
577
+
578
+ // In the worker, once the engine has booted:
579
+ const host = yield * ServiceWorkerHost.start({ nodes, urls: JsonApiQuery.urlReads(nodes) });
580
+ const view = ClientView.fromEngine(host.engine); // confirmed rows + optimistic overlay
581
+ const facade = JsonApiFacade.fetchHandler({
582
+ nodes,
583
+ api, // its `list` / `getOne` routes, prefix included, are what is recognised
584
+ resources, // so linkage matches the derived REST handler's
585
+ view,
586
+ coverageOf: Coverage.coverageOf(nodes, view.query, host.registrations), // the mirrors' pins
587
+ cookie: store.getCookie, // `x-legation-served-at`
588
+ });
589
+
590
+ self.addEventListener("fetch", (event) => {
591
+ // Cloned before anything reads or forwards it: `offline` may need a write's body.
592
+ const candidate = facade.matches(event.request) ? event.request.clone() : undefined;
593
+ event.respondWith(
594
+ Effect.runPromise(facade.handle(event.request))
595
+ .then((local) => local ?? httpCacheThenNetwork(event.request))
596
+ .catch(() =>
597
+ candidate ? Effect.runPromise(facade.offline(candidate)) : fetch(event.request)
598
+ )
599
+ );
600
+ });
601
+ ```
602
+
603
+ - **What it recognises.** A `GET` whose `Accept` names
604
+ `application/vnd.api+json` on one of the api's reflected `list` / `getOne`
605
+ routes whose type is a node of the graph, under `baseUrl` (default: the
606
+ page's own origin). Everything else — a server-only resource, another
607
+ path, a `POST`, a navigation — is `undefined`: not its business.
608
+ - **The parity assumption.** The api's read endpoints are declared with the
609
+ graph's codec (`JsonApiQuery.querySchema` / `singleRowSchema`) and served
610
+ by the derived handlers, so the server rules on a URL exactly as the
611
+ façade does — which is what lets a parse failure be answered locally. A
612
+ hand-written server with another grammar must not compose the façade.
613
+ - **What it answers.** A covered request: `200`, the document the one writer
614
+ produces (byte-equal in content to the derived REST handler's for the same
615
+ declarations), `Cache-Control: no-store`, and `x-legation-served-at` set to
616
+ the replica's CVR cookie. A covered request is answered only once every
617
+ window its rows link to has been pulled (a pull started after the window was
618
+ made has applied). Until then, online, it is forwarded; after a restart
619
+ nothing is pulled until the first pull lands, so offline it gets the `503`
620
+ below. A URL the grammar
621
+ rejects: the codec's `400` document, which the server would return
622
+ identically — except an `include` the graph does not declare, which the
623
+ server rules on. Uncovered and offline (`navigator.onLine === false`, or
624
+ `online()`): the `503` `offline` error document of ADR-0029 §4.
625
+ - **What it forwards** (`undefined`): an uncovered request while online, a
626
+ collection no bound resource declares, and a single-resource request whose
627
+ row the replica does not hold — the 404 is the endpoint's own error class,
628
+ which only the server can write. A forward that rejects at the transport
629
+ is the other half of "offline": the worker answers it with
630
+ `facade.offline(request)` (the same document, with the replica's cookie)
631
+ when `matches(request)` held.
632
+ - **Coverage is the ledger's.** `coverageOf` is `Client.coverageOf` over a
633
+ Client, or `Coverage.coverageOf(nodes, view.query, host.registrations)` over
634
+ the registrations an `EngineHost` holds on its mirrors' behalf — the only
635
+ ledger in a worker that knows what the tabs pinned. A registration is listed
636
+ with the pulled state of its windows, and a covered read needs the windows its
637
+ rows link to pulled (ADR-0029 amendment #426). Every read
638
+ `ReplicaDocuments.toCover` names must be covered; the façade never
639
+ subscribes on a request's behalf.
640
+ - **The overlay.** A covered response sees pending optimistic mutations as
641
+ `Client.query` does; an uncovered network response is the server's
642
+ authoritative answer and does not (ADR-0029 §4).
643
+ - **Offset pagination is covered by slicing, not by an offset window**
644
+ (#209). `page[offset]` parses beside `page[after]` — the two are different
645
+ pagination idioms, and a request naming both is a `400`. An offset read
646
+ compiles to a plain limit-only window (`limit = offset + first`, no
647
+ `cursor`), so it is covered by the ordinary rule: the subsuming
648
+ subscription is unwindowed, or its own limit is at least `offset +
649
+ first`. A deep offset past what is covered falls through exactly like any
650
+ other uncovered window — the façade never fetches more rows to satisfy
651
+ it, online or off. `links.next` / `links.prev` come back in the offset
652
+ idiom when the request used it, never switching a caller to cursors
653
+ mid-collection. See ADR-0029's amendment for the full rule and why an
654
+ offset _window_ (as opposed to an offset _read_, translated at read time)
655
+ was rejected.
656
+
657
+ ### Façade writes: the `202` write receipt (ADR-0030)
658
+
659
+ Given the engine's journal seam, the façade also intercepts writes:
660
+ `POST /{type}` (with a client-generated `data.id`), `PATCH /{type}/:id` and
661
+ `DELETE /{type}/:id` on the api's reflected routes for a type the graph
662
+ declares, with `Accept` and (for `POST` / `PATCH`) `Content-Type` naming
663
+ `application/vnd.api+json` — the atomic extension's `ext` marks the operations
664
+ endpoint, never a façade write, and a `PUT` is never intercepted.
665
+
666
+ ```ts
667
+ import { CrudMutation, Mutation } from "@thomasfosterau/legation";
668
+
669
+ // The engine's registry carries the graph-derived CRUD mutations the façade journals …
670
+ const mutators = Mutation.registry([...myMutations, ...CrudMutation.entries(nodes, { writes })]);
671
+ // … and the façade is handed the worker engine's journal and pull.
672
+ const facade = JsonApiFacade.fetchHandler({
673
+ ...readOptions,
674
+ writes, // e.g. { reviews: ["create"] }: what the server has handlers for
675
+ mutate: host.engine.mutateWithAck,
676
+ pull: host.engine.pull,
677
+ });
678
+ // After a forwarded write: `event.waitUntil(Effect.runPromise(facade.forwarded(candidate, response)))`.
679
+ ```
680
+
681
+ - **Online and unasked, a write is forwarded unchanged** and nothing is
682
+ journaled; after a `2xx`, `facade.forwarded` signals `pull` so a covered
683
+ read soon shows the server's row. `Prefer: handling=strict` is never
684
+ journaled.
685
+ - **Offline** (`navigator.onLine === false`), **after a forward that rejected
686
+ at the transport** (`facade.offline(clone)`), or **online with
687
+ `Prefer: respond-async`**, the write is journaled as the graph-derived
688
+ `{type}/create|update|delete` (`CrudMutation`, the graph row form: `one`
689
+ relationships become their `via` keys; the default **intent mapping**, which
690
+ `intentOf` replaces) and answered **`202 Accepted`**: `data` is the resource
691
+ as journaled, read back through the one writer (for a `PATCH` of a row the
692
+ replica lacks, the attributes sent), `meta.legation = { status: "pending",
693
+ mutationId, mutation }`, and `Location` naming the mutation status URL
694
+ `{prefix}/_legation/mutations/{mutationId}`; a `DELETE`'s receipt is
695
+ meta-only. `Preference-Applied: respond-async` says when it was asked for.
696
+ The façade never answers a write with `201`, `200` or `204`: those are only
697
+ ever the server's.
698
+ - **The mutation status URL.** With `mutationStates` (the engine's
699
+ `mutationStates`), the façade answers `GET {prefix}/_legation/mutations/{id}`
700
+ itself, never forwarding it: `200` with one `legation-mutations` resource —
701
+ `phase` (`pending`, `acknowledged`, `rejected`, `dropped`), `mutator`,
702
+ `journaledAt`, `settledAt` once settled, `reason` only when rejected, and
703
+ `links.self` / `links.resource` — or `404` `code: "unknown_mutation"` for
704
+ an id the map does not hold (never journaled here, evicted beyond
705
+ `mutationStateRetention.maxTerminal`, or settled before this worker
706
+ started; indistinguishable, so raise `maxTerminal` in the worker).
707
+ `Prefer: wait=N` holds a pending answer until it settles or `N` seconds
708
+ pass, capped by `maxWait` (default 30 s), with `Preference-Applied: wait=N`:
709
+ `POST`, then `GET Location` with `Prefer: wait=10`, learns the ruling in
710
+ one round trip.
711
+ - **What it cannot journal.** Online, it is forwarded untouched. Offline
712
+ (either half), `Prefer: handling=strict` gets the `503` `offline` document;
713
+ a create without a client id the same document plus `client_id_required`
714
+ (`source.pointer: "/data/id"`); a mutation the engine refuses (an intent
715
+ mapping naming an unregistered mutator) plus `not_journalable`; and a
716
+ malformed body the statuses JSON:API prescribes for it: `400`
717
+ `invalid_document` (no `data`, `data` / `attributes` / `relationships` not
718
+ objects), `409` `type_mismatch` / `id_mismatch`, `422`
719
+ `unsupported_relationship` (a to-many relationship, which has no home on the
720
+ row). Attribute values are never checked: a bad one is journaled and the
721
+ server rules on it.
722
+ - **A receipt is not a ruling.** The server rules when the journal pushes; a
723
+ permanent rejection drops the optimistic effect on the next rebase, and the
724
+ write's fate is on `Client.mutationStates`. Attribute values are not
725
+ validated by the façade.
726
+ - **The server side is the app's.** Every type and operation in `writes`
727
+ needs a server handler under the derived name, and creates that honour the
728
+ client id: a journaled name the server cannot handle stalls the push.
729
+ - **Same-origin only, offline**: a cross-origin write preflights, and the
730
+ preflight bypasses the service worker.
731
+
732
+ ## Types, schemas, and errors
733
+
734
+ JSON:API document types (`ResourceObject`, `JsonApiDocument`, `Relationship`,
735
+ `JsonApiError`, …) and their `Schema` codecs (`JsonApiSchema`) are exported for
736
+ direct use; `JsonApiSchema.decodeDocument` validates untrusted input (e.g. a
737
+ parsed response body) into a `JsonApiDocument`, failing with `ParseError`.
738
+
739
+ All errors are Effect `Data.TaggedError` types (`SyncError`, `NetworkError`,
740
+ `ParseError`, `ApiError`, `ConflictError`), so they match with
741
+ `Effect.catchTag` / `Effect.catchTags` and carry structured fields. `ApiError`
742
+ carries the JSON:API error objects returned by the server.
743
+
744
+ ```ts
745
+ import { Effect } from "effect";
746
+ import { CollectionLoader, ApiError, ParseError } from "@thomasfosterau/legation-jsonapi";
747
+
748
+ CollectionLoader.load("articles").pipe(
749
+ Effect.catchTag("ApiError", (error) => Effect.logError(error.errors)),
750
+ Effect.catchTag("ParseError", (error) => Effect.logError(error.message))
751
+ );
752
+ ```
753
+
754
+ ## License
755
+
756
+ MIT