@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 +973 -0
- package/LICENSE +21 -0
- package/README.md +754 -1
- package/dist/index.d.ts +1708 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4304 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +45 -3
package/README.md
CHANGED
|
@@ -1,3 +1,756 @@
|
|
|
1
1
|
# @thomasfosterau/legation-jsonapi
|
|
2
2
|
|
|
3
|
-
|
|
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
|