@thomasfosterau/legation-jsonapi 0.0.0-stage → 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/dist/index.js ADDED
@@ -0,0 +1,4304 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
+ import { ApiError as ApiError$1, Atomic, Document, Filter, Handlers, MEDIA_TYPE, Middleware, Query, Relationship, Resource } from "@thomasfosterau/effect-jsonapi";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Arr from "effect/Array";
6
+ import * as Context from "effect/Context";
7
+ import * as P from "effect/Predicate";
8
+ import * as R from "effect/Record";
9
+ import * as Result from "effect/Result";
10
+ import * as HttpClient from "effect/http/HttpClient";
11
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
12
+ import * as Option from "effect/Option";
13
+ import * as SchemaIssue from "effect/SchemaIssue";
14
+ import * as Str from "effect/String";
15
+ import { Authorization, AuthorizationError, Backend, BackendError, ClientStore, Collection, CrudMutation, Edge, FilterPolicy, MutationRejected, Mutator, Node, Predicate, Query as Query$1, QueryError, Transport } from "@thomasfosterau/legation";
16
+ import * as Iterable from "effect/Iterable";
17
+ import * as HttpApi from "effect/http-api/HttpApi";
18
+ import * as Ref from "effect/Ref";
19
+ import * as Semaphore from "effect/Semaphore";
20
+ import { ClientEngine, Hash, RejectionMemory, Related, Subset, SyncRoom, SyncServer, UnknownMutator } from "@thomasfosterau/legation/internal";
21
+ import * as Duration from "effect/Duration";
22
+ import * as Match from "effect/Match";
23
+ import { flow } from "effect/Function";
24
+ import * as HashSet from "effect/HashSet";
25
+ import * as HashMap from "effect/HashMap";
26
+ import * as Stream from "effect/Stream";
27
+ import * as HttpServerResponse from "effect/http/HttpServerResponse";
28
+ import * as Layer from "effect/Layer";
29
+ import { KeyValueStore } from "effect/persistence";
30
+ // src/errors.ts
31
+ /**
32
+ * Synchronizer Errors
33
+ *
34
+ * Tagged error types for the JSON:API adapter. All errors extend
35
+ * `Schema.TaggedError`, so they are yieldable inside `Effect.gen`, usable with
36
+ * `Effect.fail`, and handled with `Effect.catchTag` / `Effect.catchTags`.
37
+ * Because `Schema.TaggedError` extends `Error`, the `message` field is also
38
+ * available as `.message` on instances.
39
+ */
40
+ /**
41
+ * Base error for synchronizer operations.
42
+ */
43
+ var SyncError = class extends Schema.TaggedError()("SyncError", {
44
+ message: Schema.String,
45
+ cause: Schema.optional(Schema.Defect())
46
+ }) {};
47
+ /**
48
+ * Network-related error (connection failure, timeout, etc.).
49
+ */
50
+ var NetworkError = class extends Schema.TaggedError()("NetworkError", {
51
+ message: Schema.String,
52
+ cause: Schema.optional(Schema.Defect())
53
+ }) {};
54
+ /**
55
+ * Parsing error for JSON:API documents.
56
+ */
57
+ var ParseError = class extends Schema.TaggedError()("ParseError", {
58
+ message: Schema.String,
59
+ cause: Schema.optional(Schema.Defect())
60
+ }) {};
61
+ /**
62
+ * API error carrying the JSON:API error objects returned by the server.
63
+ */
64
+ var ApiError = class extends Schema.TaggedError()("ApiError", {
65
+ message: Schema.String,
66
+ errors: Schema.Array(Document.ErrorObject)
67
+ }) {};
68
+ /**
69
+ * Conflict error raised when local and remote resource versions mismatch.
70
+ */
71
+ var ConflictError = class extends Schema.TaggedError()("ConflictError", {
72
+ message: Schema.String,
73
+ localVersion: Schema.optional(Schema.String),
74
+ remoteVersion: Schema.optional(Schema.String)
75
+ }) {};
76
+ // src/schema.ts
77
+ /**
78
+ * JSON:API Schemas
79
+ *
80
+ * Generic codecs for the JSON:API v1.1 document types this loader decodes from
81
+ * arbitrary upstream servers, plus a `decodeDocument` helper that validates
82
+ * untrusted input (e.g. a parsed HTTP response body) into a `JsonApiDocument`.
83
+ *
84
+ * The spec primitives — link objects, the `jsonapi` member, error objects, and
85
+ * free-form `meta` — are adopted from `@thomasfosterau/effect-jsonapi`'s
86
+ * `Document` module (the canonical, spec-compliant definitions) rather than
87
+ * hand-rolled here. What stays local are the *generic, untyped* composites
88
+ * (`ResourceObject`, `Relationship`, `JsonApiDocument`): the library models
89
+ * documents per known `Resource` definition, but a collection loader reads
90
+ * resources whose `type` and attribute shapes it does not know ahead of time,
91
+ * so it needs a resource-agnostic document schema layered over those
92
+ * primitives.
93
+ *
94
+ * JSON:API is an *open* format: documents and most member objects may carry
95
+ * implementation-specific members beyond those the spec names. Effect v4
96
+ * `Schema.Struct` already drops excess properties during decoding rather than
97
+ * rejecting them, so these schemas validate the *shape* of the members we care
98
+ * about while tolerating extra members. Free-form members (`meta`,
99
+ * `attributes`) are modelled as `Document.AnyMeta`
100
+ * (`Schema.Record(Schema.String, Schema.Unknown)`).
101
+ *
102
+ * Schema failures are mapped to the package's `ParseError`, with the
103
+ * underlying `Schema` issue attached as `cause`.
104
+ */
105
+ var schema_exports = /* @__PURE__ */ __exportAll({
106
+ JsonApiDocument: () => JsonApiDocument,
107
+ JsonApiError: () => JsonApiError,
108
+ LinkObject: () => LinkObject,
109
+ Links: () => Links,
110
+ Relationship: () => Relationship$1,
111
+ ResourceIdentifier: () => ResourceIdentifier,
112
+ ResourceObject: () => ResourceObject,
113
+ decodeDocument: () => decodeDocument
114
+ });
115
+ /** Free-form `meta` / `attributes` object: arbitrary JSON values by key. */
116
+ const MetaSchema = Document.AnyMeta;
117
+ /**
118
+ * Schema for a JSON:API link object (`{ href, ... }`), adopted from the
119
+ * library's spec-compliant definition.
120
+ */
121
+ const LinkObject = Document.LinkObject;
122
+ /**
123
+ * Schema for a single link value: either a URL string or a link object.
124
+ */
125
+ const LinkValue = Document.Link;
126
+ /**
127
+ * Schema for a JSON:API `links` object. The spec allows arbitrary link names,
128
+ * so this is a record of link values; `self` / `related` are simply well-known
129
+ * keys within it.
130
+ */
131
+ const Links = Schema.Record(Schema.String, LinkValue);
132
+ /**
133
+ * Schema for a JSON:API resource identifier (`{ type, id, meta? }`).
134
+ */
135
+ const ResourceIdentifier = Schema.Struct({
136
+ type: Schema.String,
137
+ id: Schema.String,
138
+ meta: Schema.optional(MetaSchema)
139
+ });
140
+ /**
141
+ * Schema for a JSON:API relationship (`{ data?, links?, meta? }`). The
142
+ * `data` member may be a single identifier, an array of identifiers, or
143
+ * `null`.
144
+ */
145
+ const Relationship$1 = Schema.Struct({
146
+ data: Schema.optional(Schema.NullOr(Schema.Union([ResourceIdentifier, Schema.Array(ResourceIdentifier)]))),
147
+ links: Schema.optional(Links),
148
+ meta: Schema.optional(MetaSchema)
149
+ });
150
+ /**
151
+ * Schema for a JSON:API resource object (a resource identifier plus
152
+ * optional `attributes`, `relationships`, and `links`).
153
+ */
154
+ const ResourceObject = Schema.Struct({
155
+ type: Schema.String,
156
+ id: Schema.String,
157
+ meta: Schema.optional(MetaSchema),
158
+ attributes: Schema.optional(MetaSchema),
159
+ relationships: Schema.optional(Schema.Record(Schema.String, Relationship$1)),
160
+ links: Schema.optional(Links)
161
+ });
162
+ /**
163
+ * Schema for a JSON:API error object, adopted from the library's
164
+ * spec-compliant definition.
165
+ */
166
+ const JsonApiError = Document.ErrorObject;
167
+ /**
168
+ * Schema for a top-level JSON:API document.
169
+ *
170
+ * The `data` member may be a single resource, a collection of resources, a
171
+ * resource identifier (or collection thereof), or `null`. The other
172
+ * top-level members (`errors`, `meta`, `included`, `links`, `jsonapi`) are
173
+ * all optional. The `jsonapi` member reuses the library's `JsonApiObject`.
174
+ */
175
+ const JsonApiDocument = Schema.Struct({
176
+ data: Schema.optional(Schema.NullOr(Schema.Union([
177
+ ResourceObject,
178
+ Schema.Array(ResourceObject),
179
+ ResourceIdentifier,
180
+ Schema.Array(ResourceIdentifier)
181
+ ]))),
182
+ errors: Schema.optional(Schema.Array(JsonApiError)),
183
+ meta: Schema.optional(MetaSchema),
184
+ included: Schema.optional(Schema.Array(ResourceObject)),
185
+ links: Schema.optional(Links),
186
+ jsonapi: Schema.optional(Document.JsonApiObject)
187
+ });
188
+ const decodeJsonApiDocument = Schema.decodeUnknownEffect(JsonApiDocument);
189
+ /**
190
+ * Decode an unknown value (e.g. a parsed HTTP response body) into a
191
+ * `JsonApiDocument`. Schema failures become `ParseError`, with the schema
192
+ * issue attached as `cause`.
193
+ */
194
+ const decodeDocument = (input) => decodeJsonApiDocument(input).pipe(Effect.mapError((cause) => new ParseError({
195
+ message: "Invalid JSON:API document",
196
+ cause
197
+ })));
198
+ // src/JsonApiQuery.ts
199
+ /**
200
+ * JsonApiQuery
201
+ *
202
+ * The bridge between the filter grammar's URL form and the engine's plan
203
+ * (ADR-0028 §5, issue #161) — a façade-boundary projection for one job
204
+ * (ADR-0029 §6: a URL must mean the same thing answered from the replica as
205
+ * answered by the network), not a general translation between two dialects
206
+ * of one language. effect-jsonapi owns the grammar — its AST (`Filter.Ast`),
207
+ * the `filter[...]` ⇄ AST codec (`Query.Filter`), the whole query codec
208
+ * (`Query.schema`) and the canonical query string (`Query.canonical`); this
209
+ * module owns the three translations legation needs on top of them:
210
+ *
211
+ * - **Filter AST ⇄ `Predicate`** ({@link fromFilterAst}, {@link toFilterAst}):
212
+ * both directions are partial, and neither is the other's inverse.
213
+ * {@link fromFilterAst} rejects a literal that is not a wire scalar
214
+ * (`string | number | boolean`) or a `Date` (encoded to its instant text —
215
+ * see below) with a typed `QueryError`, since a grammar AST is typed by
216
+ * the attribute's **decoded** schema and that type is open-ended (issue
217
+ * #218's design note). {@link toFilterAst} returns `None` for `Exists`
218
+ * (server-only, no URL form), an empty `In` / `NotIn` list, and a `null`
219
+ * literal (the grammar's lists are non-empty and `NULL` is never a
220
+ * literal — `isnull` names it); those callers fail closed.
221
+ * {@link foldConstants} removes the two definite cases before encoding:
222
+ * an empty `In` is false and an empty `NotIn` is true under ADR-0028's
223
+ * per-operator table, so they fold away exactly. Both functions carry a
224
+ * **field-namespace precondition**: a field name is copied verbatim
225
+ * between the two ASTs, which is correct only because every caller in
226
+ * this module pre-synthesises a resource whose field names are already
227
+ * the engine's ({@link resourcesOf} — a to-one edge is named by its FK key
228
+ * field, never the relationship name). Neither function checks that a
229
+ * field it is given actually names something on the other side; a caller
230
+ * bridging an AST built over a resource with its own naming must
231
+ * translate field names first. A `Date` literal's conversion to
232
+ * `value.toISOString()` is an **encode, not a copy**: ADR-0028 rule (e)
233
+ * fixes instant comparison to that exact text, so an instant literal that
234
+ * arrives any other way (a differently-formatted string, say) will not
235
+ * compare correctly downstream — this is the one step in either direction
236
+ * that changes a value's representation rather than re-tagging it, which
237
+ * is also why {@link toFilterAst} is a separate partial function rather
238
+ * than {@link fromFilterAst}'s inverse: the type erasure `Date → string`
239
+ * cannot be undone from the `Predicate` side alone.
240
+ * - **URL → `Query.Read`** ({@link parseRead}, {@link parseParams}): a
241
+ * request URL is decoded with effect-jsonapi's `Query.schema` for the
242
+ * collection's node — `include`, `fields`, `sort`, `page` and `filter` —
243
+ * and mapped onto the declarative read: `filter` → `where`, `sort` →
244
+ * `order`, `page[limit]` / `page[after]` → `first` / `after`, `include`
245
+ * → the edge selects. `fields[]` is a serialisation-time projection
246
+ * (ADR-0029 §5) and is carried beside the read, never into the plan.
247
+ * Every rejection is a `QueryError` naming the parameter, so a façade or
248
+ * a derived REST handler answers 400 with `source.parameter` set.
249
+ * - **`Query.Read` → canonical string** ({@link canonical}): the read is
250
+ * compiled with `Query.plan` and its plan is encoded through the
251
+ * canonical encoder, so two reads with one plan have one string —
252
+ * subscription identity for `Client.subscribe(url)` (ADR-0029 §2).
253
+ * {@link urlReads} packages the parse and the canonical string as the
254
+ * `Client.UrlReads` seam a Client or an engine host is built with (#159).
255
+ *
256
+ * The query codec is derived from the **node graph**, not from an
257
+ * effect-jsonapi resource: {@link resourcesOf} synthesises one resource per
258
+ * node whose filterable attributes carry the node's filterable set (the
259
+ * operators and literal kinds of ADR-0028 rule d, #154) and whose
260
+ * relationships are the node's edges. A filter names the row field the
261
+ * engine evaluates — a `one`-edge is filtered by its key field
262
+ * (`filter[categoryId]=c-audio`, as in ADR-0029's worked examples), not by
263
+ * the edge name. Include paths are single level (decision D1 of ADR-0028):
264
+ * a read includes a node's own edges only, so `include=variants.product` is
265
+ * rejected as a parse failure.
266
+ *
267
+ * The **exact encoder** ({@link defaultEncoder}) is what `JsonApiBackend`
268
+ * pushes to the server: the whole predicate as the grammar's canonical
269
+ * `filter[...]` record (operators, `or`, `not`, `isnull` and `nin`
270
+ * included — nothing is dropped any more), plus `sort`. Order, cursor and
271
+ * limit stay local: `Subset.apply` still finalises the window, which is now
272
+ * redundancy over an exact filter rather than correction of a superset
273
+ * (ADR-0028 §6). A predicate that can never match (an empty allow-set)
274
+ * encodes to `None` and the backend skips the request.
275
+ */
276
+ var JsonApiQuery_exports = /* @__PURE__ */ __exportAll({
277
+ PageWindow: () => PageWindow,
278
+ buildQueryString: () => buildQueryString,
279
+ canonical: () => canonical,
280
+ defaultEncoder: () => defaultEncoder,
281
+ flatFilter: () => flatFilter,
282
+ foldConstants: () => foldConstants,
283
+ fromFilterAst: () => fromFilterAst,
284
+ invalidParameter: () => invalidParameter,
285
+ parseParams: () => parseParams,
286
+ parseRead: () => parseRead,
287
+ querySchema: () => querySchema,
288
+ resourcesOf: () => resourcesOf,
289
+ singleRowParams: () => singleRowParams,
290
+ singleRowSchema: () => singleRowSchema,
291
+ toFilterAst: () => toFilterAst,
292
+ toRequestOptions: () => toRequestOptions,
293
+ urlReads: () => urlReads
294
+ });
295
+ /**
296
+ * A grammar literal as a `Predicate` literal: scalars as they are, a `Date`
297
+ * as its instant text (the load-bearing encode of ADR-0028 rule e), or a
298
+ * typed `QueryError` for anything else — a grammar AST is typed by the
299
+ * attribute's decoded schema (issue #218's design note), which is
300
+ * open-ended, so this is the one place that type meets `Predicate.Literal`'s
301
+ * closed wire-scalar union and can fail.
302
+ */
303
+ const literalOf = (field, value) => {
304
+ switch (typeof value) {
305
+ case "string":
306
+ case "number":
307
+ case "boolean": return Result.succeed(value);
308
+ default: return value instanceof Date ? Result.succeed(value.toISOString()) : Result.fail(new QueryError({
309
+ message: `Filter literal ${JSON.stringify(value)} for field "${field}" is not a scalar`,
310
+ parameter: `filter[${field}]`
311
+ }));
312
+ }
313
+ };
314
+ /**
315
+ * The engine form of a filter AST, or a typed `QueryError` naming the field
316
+ * whose literal {@link literalOf} rejects: `Compare` / `In` / `NotIn` /
317
+ * `IsNull` map node for node, `And` / `Or` / `Not` recurse, and the empty
318
+ * conjunction (the grammar's "true") is `Predicate.always`. The map is
319
+ * structural — a singleton group, which the grammar's normal form keeps
320
+ * after deduplicating `Or(x, x)`, stays a singleton group rather than
321
+ * collapsing through `Predicate.or` — so {@link canonical} is idempotent.
322
+ * Literals are the codec's decoded scalars; a `Date` (a `DateFromString`
323
+ * attribute) becomes its ISO-8601 UTC text (see the module doc's encode
324
+ * note). Field names are copied verbatim — the module doc's
325
+ * field-namespace precondition.
326
+ *
327
+ * This module's own callers ({@link readOf}, {@link filterOf}) never see
328
+ * this fail: their ASTs are decoded through {@link resourcesOf}'s synthetic
329
+ * resource, whose filterable attributes admit only wire scalars, or are
330
+ * built by {@link toFilterAst} from a `Predicate.Literal`, which is one
331
+ * already. The typed failure exists for this function's public contract —
332
+ * any caller bridging an AST built over a resource with a richer decoded
333
+ * attribute type (a `Date`-valued one aside, which is handled) — not for
334
+ * either call site here.
335
+ */
336
+ const fromFilterAst = (ast) => {
337
+ switch (ast._tag) {
338
+ case "Compare": return Result.map(literalOf(ast.field, ast.value), (value) => Predicate.compare(ast.op, ast.field, value));
339
+ case "In": return Result.map(Result.all(ast.values.map((value) => literalOf(ast.field, value))), (values) => Predicate.isIn(ast.field, values));
340
+ case "NotIn": return Result.map(Result.all(ast.values.map((value) => literalOf(ast.field, value))), (values) => Predicate.notIn(ast.field, values));
341
+ case "IsNull": return Result.succeed(ast.negated ? Predicate.isNotNull(ast.field) : Predicate.isNull(ast.field));
342
+ case "And": return Arr.isReadonlyArrayEmpty(ast.members) ? Result.succeed(Predicate.always) : Result.map(Result.all(ast.members.map(fromFilterAst)), (predicates) => ({
343
+ _tag: "And",
344
+ predicates
345
+ }));
346
+ case "Or": return Result.map(Result.all(ast.members.map(fromFilterAst)), (predicates) => ({
347
+ _tag: "Or",
348
+ predicates
349
+ }));
350
+ case "Not": return Result.map(fromFilterAst(ast.member), Predicate.not);
351
+ }
352
+ };
353
+ /** A non-empty list of non-null literals, or `None` (the grammar has neither an empty list nor a `NULL` literal). */
354
+ const literalList = (values) => {
355
+ const [head, ...tail] = values;
356
+ return head === void 0 || head === null || tail.includes(null) ? Option.none() : Option.some([head, ...tail]);
357
+ };
358
+ /**
359
+ * The grammar form of a predicate, or `None` where it has no URL form:
360
+ * `Exists` (server-only, ADR-0028 §5), an empty `In` / `NotIn` list, or a
361
+ * `null` literal (the deprecated wire form of `isnull`; it evaluates to
362
+ * unknown and cannot be folded). `True` is the empty conjunction.
363
+ * `fromFilterAst ∘ toFilterAst` is the identity on every constructor-built
364
+ * predicate it accepts.
365
+ */
366
+ const toFilterAst = (predicate) => {
367
+ switch (predicate._tag) {
368
+ case "True": return Option.some(Filter.and());
369
+ case "Compare": return predicate.value === null ? Option.none() : Option.some({
370
+ _tag: "Compare",
371
+ op: predicate.op,
372
+ field: predicate.field,
373
+ value: predicate.value
374
+ });
375
+ case "In": return Option.map(literalList(predicate.values), (values) => Filter.isIn(predicate.field, values));
376
+ case "NotIn": return Option.map(literalList(predicate.values), (values) => Filter.notIn(predicate.field, values));
377
+ case "IsNull": return Option.some(Filter.isNull(predicate.field, predicate.negated === true));
378
+ case "And": return Option.map(Option.all(predicate.predicates.map(toFilterAst)), (members) => Filter.and(...members));
379
+ case "Or": return Option.map(Option.all(predicate.predicates.map(toFilterAst)), (members) => Filter.or(...members));
380
+ case "Not": return Option.map(toFilterAst(predicate.predicate), Filter.not);
381
+ case "Exists":
382
+ case "InSet": return Option.none();
383
+ }
384
+ };
385
+ /** The definite-false predicate: the empty disjunction (`Some(false)` for every row). */
386
+ const FALSE = {
387
+ _tag: "Or",
388
+ predicates: []
389
+ };
390
+ const isTrue = (predicate) => predicate._tag === "True";
391
+ const isFalse = (predicate) => predicate._tag === "Or" && Arr.isReadonlyArrayEmpty(predicate.predicates);
392
+ /** Kleene `And` over folded members: any false is false; true members vanish. */
393
+ const foldAnd = (members) => {
394
+ const folded = members.map(foldConstants);
395
+ if (folded.some(isFalse)) return FALSE;
396
+ const kept = folded.filter((member) => !isTrue(member));
397
+ return Arr.isReadonlyArrayEmpty(kept) ? Predicate.always : Predicate.and(...kept);
398
+ };
399
+ /** Kleene `Or` over folded members: any true is true; false members vanish. */
400
+ const foldOr = (members) => {
401
+ const folded = members.map(foldConstants);
402
+ if (folded.some(isTrue)) return Predicate.always;
403
+ const kept = folded.filter((member) => !isFalse(member));
404
+ return Arr.isReadonlyArrayEmpty(kept) ? FALSE : Predicate.or(...kept);
405
+ };
406
+ const foldNot = (inner) => {
407
+ const folded = foldConstants(inner);
408
+ return isTrue(folded) ? FALSE : isFalse(folded) ? Predicate.always : Predicate.not(folded);
409
+ };
410
+ /**
411
+ * Fold the definite constants out of a predicate, exactly under ADR-0028's
412
+ * three-valued semantics: an empty `In` is false and an empty `NotIn` is
413
+ * true for every row (the compilers render them `1 = 0` / `1 = 1`), a
414
+ * false conjunct or a true disjunct decides its connective, and true
415
+ * conjuncts / false disjuncts vanish. Unknown is never folded — a `null`
416
+ * literal stays where it is. The result is `Predicate.always`, the empty
417
+ * `Or` (false), or a predicate free of empty lists.
418
+ */
419
+ const foldConstants = (predicate) => {
420
+ switch (predicate._tag) {
421
+ case "In": return Arr.isReadonlyArrayEmpty(predicate.values) ? FALSE : predicate;
422
+ case "NotIn": return Arr.isReadonlyArrayEmpty(predicate.values) ? Predicate.always : predicate;
423
+ case "And": return foldAnd(predicate.predicates);
424
+ case "Or": return foldOr(predicate.predicates);
425
+ case "Not": return foldNot(predicate.predicate);
426
+ default: return predicate;
427
+ }
428
+ };
429
+ /** A check that a decoded literal is a value of the kind (`FilterPolicy.matchesKind`). */
430
+ const kindCheck = (kind) => Schema.makeFilter((value) => FilterPolicy.matchesKind(kind, value), {
431
+ identifier: `LiteralKind.${kind}`,
432
+ title: kind,
433
+ description: `a literal of kind "${kind}"`
434
+ });
435
+ /**
436
+ * The attribute schema a filter literal of the kind decodes through: the
437
+ * wire string is parsed as the scalar the schema's encoded form names, then
438
+ * checked against the kind (int4 range, the fixed instant format).
439
+ */
440
+ const literalSchema = (kind) => {
441
+ switch (kind) {
442
+ case "text": return Schema.String;
443
+ case "instant": return Schema.String.pipe(Schema.check(kindCheck("instant")));
444
+ case "integer": return Schema.Number.pipe(Schema.check(kindCheck("integer")));
445
+ case "double": return Schema.Number;
446
+ case "boolean": return Schema.Boolean;
447
+ }
448
+ };
449
+ /**
450
+ * The synthetic resource's attributes: every filterable field (`id` and the
451
+ * `one`-edge keys included) with its kind's schema and its operators
452
+ * declared through `Filter.able`; every other attribute as `Unknown`, so
453
+ * `fields[type]` still names it but no filter reaches it.
454
+ */
455
+ const attributesOf$2 = (node) => {
456
+ const filterable = R.map(node.filterable, (declared) => literalSchema(declared.kind).pipe(Filter.able(declared.operators)));
457
+ return {
458
+ ...filterable,
459
+ ...R.map(R.filter(node.attributes, (_, field) => !R.has(filterable, field)), () => Schema.Unknown)
460
+ };
461
+ };
462
+ /**
463
+ * The synthetic resource's relationships: the node's edges, targets
464
+ * resolved lazily through the graph. A paginated edge is a `many`
465
+ * relationship here: effect-jsonapi keeps `Relationship.paginated` out of
466
+ * `include` paths, whereas a read includes a paginated edge like any other
467
+ * (its per-parent window is the plan's, not the URL's — {@link canonical}
468
+ * refuses `include: { first }`).
469
+ */
470
+ /**
471
+ * The resource of node `type`. {@link resourcesOf} builds one per node and
472
+ * every edge targets a node, so a miss is a defect, never a request error.
473
+ */
474
+ const resourceIn = (resources, type) => {
475
+ const resource = resources.get(type);
476
+ if (resource === void 0) throw new QueryError({ message: `No resource for node "${type}"` });
477
+ return resource;
478
+ };
479
+ const relationshipsOf = (node, resources) => {
480
+ return R.map(node.edges, (edge) => {
481
+ const target = () => resourceIn(resources, edge.target);
482
+ return edge.kind === "one" ? Relationship.one(target) : Relationship.many(target);
483
+ });
484
+ };
485
+ const resourceGraphs = /* @__PURE__ */ new WeakMap();
486
+ /**
487
+ * The node graph as effect-jsonapi resource definitions, one per node —
488
+ * the declaration the URL codecs are derived from. Attributes are typed by
489
+ * the node's filterable set (kind and operators), relationships are its
490
+ * edges; nothing else about the resource is faithful (a document is never
491
+ * serialised through it). Memoised per registry.
492
+ */
493
+ const resourcesOf = (nodes) => {
494
+ const cached = resourceGraphs.get(nodes);
495
+ if (cached !== void 0) return cached;
496
+ const resources = /* @__PURE__ */ new Map();
497
+ nodes.nodes.forEach((node) => resources.set(node.type, Resource.make(node.type, {
498
+ attributes: attributesOf$2(node),
499
+ relationships: relationshipsOf(node, resources)
500
+ })));
501
+ resourceGraphs.set(nodes, resources);
502
+ return resources;
503
+ };
504
+ /**
505
+ * The root window's page parameters: `page[limit]` is the read's `first`
506
+ * (a positive integer), `page[after]` its `after` cursor (a row id,
507
+ * opaque on the wire), `page[offset]` its `offset` (a non-negative
508
+ * integer, #209 — every audited app's pagination idiom). `page[offset]`
509
+ * and `page[after]` are different pagination idioms; {@link parseParams}
510
+ * rejects a request naming both, and {@link decodedOf} emits `limit` then
511
+ * whichever of `after` / `offset` the read carries — the order the
512
+ * canonical string keeps.
513
+ */
514
+ const PageWindow = {
515
+ limit: Schema.optionalKey(Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)))),
516
+ after: Schema.optionalKey(Schema.String),
517
+ offset: Schema.optionalKey(Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))))
518
+ };
519
+ /** A value derived per node, memoised per registry then per node type. */
520
+ const memoised = (store, nodes, node, build) => {
521
+ let byType = store.get(nodes);
522
+ if (byType === void 0) {
523
+ byType = /* @__PURE__ */ new Map();
524
+ store.set(nodes, byType);
525
+ }
526
+ const cached = byType.get(node.type);
527
+ if (cached !== void 0) return cached;
528
+ const value = build();
529
+ byType.set(node.type, value);
530
+ return value;
531
+ };
532
+ /** The `include` option of a node's query codecs: single-level paths only (ADR-0028 D1); no edges, no `include` at all. */
533
+ const includeOption = (node) => !R.isEmptyReadonlyRecord(node.edges) ? { depth: 1 } : false;
534
+ const queryCodecs = /* @__PURE__ */ new WeakMap();
535
+ /** Build (or fetch) the query codec of one node: `Query.schema` over its synthetic resource. */
536
+ const codecOf = (nodes, node) => memoised(queryCodecs, nodes, node, () => {
537
+ const resource = resourceIn(resourcesOf(nodes), node.type);
538
+ const schema = Query.schema(resource, {
539
+ include: includeOption(node),
540
+ fields: true,
541
+ sort: node.sortable,
542
+ page: PageWindow,
543
+ filter: true
544
+ });
545
+ return {
546
+ schema,
547
+ canonical: Query.canonical(schema)
548
+ };
549
+ });
550
+ /** The node a collection names, or a `QueryError` thrown — a definition-time defect. */
551
+ const nodeOrThrow = (nodes, collection) => {
552
+ const node = nodes.nodes.get(collection);
553
+ if (node === void 0) throw new QueryError({
554
+ message: `Unknown collection: ${collection}`,
555
+ query: collection
556
+ });
557
+ return node;
558
+ };
559
+ /**
560
+ * The query codec of a collection's `list` endpoint — `include`, `fields[…]`,
561
+ * `sort`, `page[…]` and `filter[…]` exactly as the node graph declares them:
562
+ * the codec {@link parseParams} decodes with, so an effect-jsonapi endpoint
563
+ * served by a derived handler (`RestHandlers.list`) declares the grammar the
564
+ * handler parses when it is passed as `Endpoint.list(R, { query })`.
565
+ * effect-jsonapi's own `filter: true` derives a grammar from the *resource*
566
+ * instead, and the two spell a to-one relationship's filter differently: the
567
+ * resource grammar names the relationship (`filter[category]=c-audio`), the
568
+ * node graph — and every URL the engine emits ({@link canonical}) — names the
569
+ * key field (`filter[categoryId]=c-audio`, as ADR-0029's URLs do). One codec
570
+ * on both sides keeps the endpoint's contract and the handler's parse the
571
+ * same language. Every edge is includable under it, so a resource served
572
+ * with it declares its collection-valued edges `Relationship.many`, never
573
+ * `paginated` (whose `include=` only the resource grammar refuses). Throws a
574
+ * `QueryError` for an unknown collection — a definition-time defect.
575
+ */
576
+ const querySchema = (nodes, collection) => codecOf(nodes, nodeOrThrow(nodes, collection)).schema;
577
+ const singleRowCodecs = /* @__PURE__ */ new WeakMap();
578
+ /**
579
+ * The query codec of a collection's `get` endpoint — `include` and
580
+ * `fields[…]` exactly as the node graph declares them, the two parameters a
581
+ * single-resource request takes ({@link singleRowParams}) — for
582
+ * `Endpoint.get(R, { query })`, the counterpart of {@link querySchema}: the
583
+ * endpoint's contract and the derived handler's parse are one grammar on
584
+ * the single-row route too. Throws a `QueryError` for an unknown collection.
585
+ */
586
+ const singleRowSchema = (nodes, collection) => {
587
+ const node = nodeOrThrow(nodes, collection);
588
+ return memoised(singleRowCodecs, nodes, node, () => Query.schema(resourceIn(resourcesOf(nodes), node.type), {
589
+ include: includeOption(node),
590
+ fields: true
591
+ }));
592
+ };
593
+ /** The flat query record: `URLSearchParams` with repeated keys as arrays (as `UrlParams.toRecord` does). */
594
+ const recordOf$1 = (params) => {
595
+ if (!(params instanceof URLSearchParams)) return { ...params };
596
+ const record = {};
597
+ params.forEach((value, key) => {
598
+ const existing = record[key];
599
+ record[key] = existing === void 0 ? value : P.isString(existing) ? [existing, value] : [...existing, value];
600
+ });
601
+ return record;
602
+ };
603
+ /**
604
+ * The rejection of one query parameter, in the shape every check here
605
+ * raises (`parameter` names the offending key, so a caller answers 400 with
606
+ * `source.parameter`). Exported for a caller that checks a parsed URL
607
+ * against a declaration of its own — the replica-side writer against the
608
+ * resource it is bound to — and must reject the way the codec does.
609
+ */
610
+ const invalidParameter = (collection, parameter, detail) => new QueryError({
611
+ message: `Invalid query for "${collection}" (${parameter}): ${detail}`,
612
+ query: collection,
613
+ parameter
614
+ });
615
+ const invalid$1 = invalidParameter;
616
+ const nodeOf$1 = (nodes, collection) => {
617
+ const node = nodes.nodes.get(collection);
618
+ return node === void 0 ? Effect.fail(new QueryError({
619
+ message: `Unknown collection: ${collection}`,
620
+ query: collection
621
+ })) : Effect.succeed(node);
622
+ };
623
+ /** The `include` tokens of the wire value: the comma form, the repeated key, or both. */
624
+ const includeTokens = (value) => value === void 0 ? [] : (P.isString(value) ? [value] : value).flatMap((item) => item.split(","));
625
+ /**
626
+ * Check `include` before decoding: every path is one of the node's own
627
+ * edges. A nested path is rejected by decision D1; an undeclared edge is
628
+ * rejected so the caller can rule on it (a façade falls through, ADR-0029
629
+ * §1). The codec would reject both too; the check gives the reason.
630
+ */
631
+ const checkInclude = (node, value) => failFirst(includeTokens(value), (token) => token.includes(".") ? Option.some(invalid$1(node.type, "include", `nested include path "${token}" is not supported; a read includes a node's own edges only (ADR-0028 D1)`)) : node.edges[token] === void 0 ? Option.some(invalid$1(node.type, "include", `"${token}" is not an edge of "${node.type}" (edges: ${Object.keys(node.edges).join(", ")})`)) : Option.none());
632
+ /** Fail with the first item's error, in order; succeed when no item has one. */
633
+ const failFirst = (items, errorOf) => Option.match(Arr.findFirst(items, errorOf), {
634
+ onNone: () => Effect.void,
635
+ onSome: Effect.fail
636
+ });
637
+ const keyOf = (segment) => P.isObjectOrArray(segment) ? segment.key : segment;
638
+ /** The types a read on the node can carry: its own and its edge targets'. */
639
+ const carriedTypes$1 = (node) => /* @__PURE__ */ new Set([node.type, ...Object.values(node.edges).map((edge) => edge.target)]);
640
+ /**
641
+ * The rejection of a `fields[type]` projection the read cannot carry, or
642
+ * `undefined` when it can. The codec silently drops such a type; a
643
+ * projection nobody can apply is a client mistake worth a 400, and the same
644
+ * rule keeps {@link canonical} and {@link parseParams} in agreement.
645
+ */
646
+ const projectionError = (node, type) => {
647
+ const types = carriedTypes$1(node);
648
+ return types.has(type) ? void 0 : invalid$1(node.type, `fields[${type}]`, `"${type}" is not a type this read can carry (types: ${[...types].join(", ")})`);
649
+ };
650
+ /** Check every `fields[…]` key before decoding (see {@link projectionError}). */
651
+ const checkFields = (node, record) => failFirst(Object.keys(record), (key) => {
652
+ const match = /^fields\[(.*)\]$/.exec(key);
653
+ return Option.fromUndefinedOr(match === null ? void 0 : projectionError(node, match[1] ?? ""));
654
+ });
655
+ /**
656
+ * `page[offset]` and `page[after]` are different pagination idioms — one
657
+ * request names one (#209). The schema alone cannot reject the
658
+ * combination (each key decodes fine on its own), so this runs before
659
+ * decoding, the way {@link checkInclude} rejects a nested path.
660
+ */
661
+ const checkPage = (node, record) => record["page[after]"] !== void 0 && record["page[offset]"] !== void 0 ? Effect.fail(invalid$1(node.type, "page[offset]", `"page[offset]" and "page[after]" are different pagination idioms; a request names one`)) : Effect.void;
662
+ /** The query-string parameter an issue path names: the flat key itself, or `family[member]` for the nested families. */
663
+ const parameterOf = (path) => {
664
+ const [head, second] = (path ?? []).map(keyOf);
665
+ if (!P.isString(head)) return void 0;
666
+ return (head === "fields" || head === "page") && P.isString(second) ? `${head}[${second}]` : head;
667
+ };
668
+ /** A codec rejection as a `QueryError` naming the parameter (the issue's path is the flat key). */
669
+ const decodeError = (collection) => (error) => {
670
+ const issue = SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues[0];
671
+ const parameter = parameterOf(issue?.path);
672
+ return new QueryError({
673
+ message: `Invalid query for "${collection}"${parameter === void 0 ? "" : ` (${parameter})`}: ${issue?.message ?? error.message}`,
674
+ query: collection,
675
+ parameter,
676
+ cause: error
677
+ });
678
+ };
679
+ /** Sort terms as `order` tokens (`name`, `-createdAt`). */
680
+ const orderOf = (sort) => sort.map((term) => term.direction === "desc" ? `-${term.field}` : term.field);
681
+ /**
682
+ * The declarative read a decoded query describes, or the typed `QueryError`
683
+ * {@link fromFilterAst} fails with for `decoded.filter` — unreachable in
684
+ * practice (the module doc's note on this call site), carried as a `Result`
685
+ * rather than thrown so {@link parseParams}'s `Effect.Effect<_, QueryError>`
686
+ * contract holds even if that precondition is ever violated.
687
+ */
688
+ const readOf = (collection, decoded) => {
689
+ const whereResult = decoded.filter === void 0 ? Result.succeed(void 0) : fromFilterAst(decoded.filter);
690
+ return Result.map(whereResult, (where) => ({
691
+ collection,
692
+ ...where === void 0 ? {} : { where },
693
+ ...decoded.include === void 0 || Arr.isReadonlyArrayEmpty(decoded.include) ? {} : { include: Object.fromEntries(decoded.include.map((edge) => [edge, true])) },
694
+ ...decoded.sort === void 0 || Arr.isReadonlyArrayEmpty(decoded.sort) ? {} : { order: orderOf(decoded.sort) },
695
+ ...decoded.page?.limit === void 0 ? {} : { first: decoded.page.limit },
696
+ ...decoded.page?.after === void 0 ? {} : { after: decoded.page.after },
697
+ ...decoded.page?.offset === void 0 ? {} : { offset: decoded.page.offset }
698
+ }));
699
+ };
700
+ /**
701
+ * Parse a collection's query parameters into a read. `include`, `fields`,
702
+ * `sort`, `page` and `filter` are decoded with the node-derived query codec
703
+ * (unknown filter fields, undeclared operators, literals of the wrong kind,
704
+ * unsortable fields, unknown sparse fields and nested include paths all
705
+ * fail with a `QueryError` naming the parameter) and mapped onto the read;
706
+ * `fields[]` rides beside it as the projection.
707
+ */
708
+ const parseParams = (nodes, collection, params) => Effect.gen(function* () {
709
+ const node = yield* nodeOf$1(nodes, collection);
710
+ const record = recordOf$1(params);
711
+ yield* checkInclude(node, record["include"]);
712
+ yield* checkFields(node, record);
713
+ yield* checkPage(node, record);
714
+ const decoded = yield* Schema.decodeUnknownEffect(codecOf(nodes, node).schema)(record).pipe(Effect.mapError(decodeError(collection)));
715
+ return {
716
+ read: yield* Effect.fromResult(readOf(collection, decoded)),
717
+ ...decoded.fields === void 0 ? {} : { fields: decoded.fields }
718
+ };
719
+ });
720
+ /** A path segment percent-decoded, or a `QueryError` naming `id` when it is malformed. */
721
+ const decodeId = (collection, segment) => Effect.try({
722
+ try: () => decodeURIComponent(segment),
723
+ catch: () => invalid$1(collection, "id", `path segment "${segment}" is not percent-encoded`)
724
+ });
725
+ /**
726
+ * The resource a request path addresses: `…/{type}/{id}` (one row) when the
727
+ * segment before the last names a node, else `…/{type}` (a listing) when
728
+ * the last does. A node named earlier on the path — the spec's related
729
+ * resource (`/products/p-1/variants`) and relationship (`…/relationships/…`)
730
+ * URLs — is not a read this parser serves (read the related collection
731
+ * with its key filter instead), so it is rejected rather than misread as a
732
+ * whole-collection listing.
733
+ */
734
+ const locate = (nodes, url) => {
735
+ const segments = url.pathname.split("/").filter((segment) => Str.isNonEmpty(segment));
736
+ const last = segments.at(-1);
737
+ const previous = segments.at(-2);
738
+ if (previous !== void 0 && last !== void 0 && nodes.nodes.has(previous)) return Effect.map(decodeId(previous, last), (id) => ({
739
+ collection: previous,
740
+ id
741
+ }));
742
+ const message = segments.slice(0, -2).some((segment) => nodes.nodes.has(segment)) ? `Nested resource path "${url.pathname}" is not a read; filter the related collection by its key instead` : last !== void 0 && nodes.nodes.has(last) ? void 0 : `No collection on path "${url.pathname}"`;
743
+ return message === void 0 && last !== void 0 ? Effect.succeed({ collection: last }) : Effect.fail(new QueryError({
744
+ message: message ?? `No collection on path "${url.pathname}"`,
745
+ query: url.pathname
746
+ }));
747
+ };
748
+ /**
749
+ * The query of a single-row read — `filter[id]=…`, first 1 (ADR-0029 §3) —
750
+ * from a `/{type}/{id}` request's own query, which takes `include` and
751
+ * `fields[…]` only. Shared by {@link parseRead} and the derived `get`
752
+ * handler, which has the id from the route instead of the path.
753
+ */
754
+ const singleRowParams = (collection, id, params) => {
755
+ const extra = Arr.findFirst(params.keys(), (key) => key !== "include" && !key.startsWith("fields["));
756
+ if (Option.isSome(extra)) return Effect.fail(invalid$1(collection, extra.value, `a single-resource request takes "include" and "fields[…]" only`));
757
+ const widened = new URLSearchParams(params);
758
+ widened.set("filter[id]", flatFilter(Filter.eq("id", id))["filter[id]"] ?? "");
759
+ widened.set("page[limit]", "1");
760
+ return Effect.succeed(widened);
761
+ };
762
+ /**
763
+ * Parse a request URL into a read. The collection is the path's last
764
+ * segment that names a node (`/api/products?…`, any prefix), or the
765
+ * segment before a row id (`/api/products/p-1?…`, which reads as
766
+ * `filter[id]=p-1` with `first` 1). The query is then {@link parseParams}.
767
+ */
768
+ const parseRead = (nodes, url) => Effect.gen(function* () {
769
+ const parsed = P.isString(url) ? new URL(url, "http://localhost") : url;
770
+ const located = yield* locate(nodes, parsed);
771
+ const params = located.id === void 0 ? parsed.searchParams : yield* singleRowParams(located.collection, located.id, parsed.searchParams);
772
+ const result = yield* parseParams(nodes, located.collection, params);
773
+ return located.id === void 0 ? result : {
774
+ ...result,
775
+ id: located.id
776
+ };
777
+ });
778
+ const noUrlForm = (collection, detail) => new QueryError({
779
+ message: `Read on "${collection}" has no URL form: ${detail}`,
780
+ query: collection
781
+ });
782
+ /** The plan's edge selects as include paths, in code-point order (the include set is unordered). */
783
+ const includeOf = (collection, edges) => edges.map((edge) => {
784
+ if (edge.first !== void 0) throw noUrlForm(collection, `the per-edge window on "${edge.edge}" (include: { first }) has no query parameter`);
785
+ return edge.edge;
786
+ }).toSorted((a, b) => Predicate.compareText(a, b));
787
+ const astOf = (collection, predicate) => Option.getOrThrowWith(toFilterAst(predicate), () => noUrlForm(collection, `"${predicate._tag}" carries an Exists node or a null literal`));
788
+ /** Passes {@link filterOf} allows the normal form to settle in; two suffice in practice. */
789
+ const NORMAL_FORM_PASSES = 8;
790
+ /**
791
+ * The plan's root predicate as a filter AST in normal form; `undefined` for
792
+ * the whole collection. Two normalisations meet here: {@link foldConstants}
793
+ * on the engine side (constants, singleton groups) and `Filter.normalise`
794
+ * on the grammar side (member order, deduplication, literal-list order).
795
+ * Each can hand the other new work — deduplicating `Or(x, x)` leaves a
796
+ * singleton group the fold then collapses — so the result is their
797
+ * fixpoint, which makes {@link canonical} idempotent: parsing a canonical
798
+ * string and canonicalising again gives the same string.
799
+ */
800
+ const filterOf = (collection, declaration, where) => {
801
+ const settle = (predicate, pass) => {
802
+ if (pass >= NORMAL_FORM_PASSES) return Option.some(astOf(collection, predicate));
803
+ if (isTrue(predicate)) return Option.none();
804
+ const ast = Filter.normalise(astOf(collection, predicate), declaration);
805
+ const back = fromFilterAst(ast);
806
+ if (Result.isFailure(back)) throw back.failure;
807
+ const next = foldConstants(back.success);
808
+ if (JSON.stringify(next) === JSON.stringify(predicate)) return Option.some(ast);
809
+ return settle(next, pass + 1);
810
+ };
811
+ return Option.getOrUndefined(settle(foldConstants(where), 0));
812
+ };
813
+ /**
814
+ * The decoded query a compiled read denotes. The page comes from the
815
+ * **read**, not the compiled window: an offset read's `root.limit`
816
+ * absorbs `offset` (`Query.plan`'s `rootLimit`, #209), so reconstructing
817
+ * `page[limit]`/`page[after]` from `root` would leak the inflated fetch
818
+ * size into the URL instead of the request the caller actually made.
819
+ */
820
+ const decodedOf = (compiled, declaration, fields, read) => {
821
+ const { root } = compiled;
822
+ const include = includeOf(root.collection, compiled.edges);
823
+ const filter = filterOf(root.collection, declaration, root.where);
824
+ const page = read.offset === void 0 ? {
825
+ ...root.limit === void 0 ? {} : { limit: root.limit },
826
+ ...root.cursor === void 0 ? {} : { after: root.cursor }
827
+ } : {
828
+ ...read.first === void 0 ? {} : { limit: read.first },
829
+ offset: read.offset
830
+ };
831
+ return {
832
+ ...Arr.isReadonlyArrayEmpty(include) ? {} : { include },
833
+ ...fields === void 0 ? {} : { fields: projectionOf(fields) },
834
+ ...filter === void 0 ? {} : { filter },
835
+ ...Arr.isReadonlyArrayEmpty(root.orderBy) ? {} : { sort: root.orderBy },
836
+ ...R.isEmptyReadonlyRecord(page) ? {} : { page }
837
+ };
838
+ };
839
+ /** A projection with each type's field list in code-point order (a set). */
840
+ const projectionOf = (fields) => Object.fromEntries(Object.entries(fields).map(([type, names]) => [type, names.toSorted(Predicate.compareText)]));
841
+ /**
842
+ * The canonical URL of a read — `/{collection}?{query}` (no API prefix;
843
+ * `parseRead` accepts it back) — through effect-jsonapi's canonical encoder
844
+ * over the compiled plan: one string per plan, whatever spelling the read
845
+ * used (`where` as a map or a predicate, conjuncts in any order, `True`
846
+ * conjuncts). It is the subscription identity of ADR-0029 §2. Throws a
847
+ * `QueryError` for a read the plan or the grammar refuses: an unknown
848
+ * collection or edge, a field outside the filterable set, an `Exists`, a
849
+ * `null` literal, or a per-edge window. A `fields[]` projection rides
850
+ * along when given.
851
+ */
852
+ const canonical = (nodes, read, fields) => {
853
+ const compiled = Query$1.plan(nodes, read);
854
+ const node = nodes.nodes.get(read.collection);
855
+ if (node === void 0) throw new QueryError({
856
+ message: `Unknown collection: ${read.collection}`,
857
+ query: read.collection
858
+ });
859
+ Object.keys(fields ?? {}).forEach((type) => {
860
+ const error = projectionError(node, type);
861
+ if (error !== void 0) throw error;
862
+ });
863
+ const encoded = Result.try({
864
+ try: () => {
865
+ const declaration = Resource.filterable(resourceIn(resourcesOf(nodes), node.type));
866
+ return codecOf(nodes, node).canonical(decodedOf(compiled, declaration, fields, read));
867
+ },
868
+ catch: (error) => error instanceof QueryError ? error : new QueryError({
869
+ message: `Read on "${read.collection}" has no URL form: ${error instanceof Error ? error.message : String(error)}`,
870
+ query: read.collection,
871
+ cause: error
872
+ })
873
+ });
874
+ if (Result.isFailure(encoded)) throw encoded.failure;
875
+ const query = encoded.success;
876
+ return query === "" ? `/${read.collection}` : `/${read.collection}?${query}`;
877
+ };
878
+ /** {@link canonical} as an effect: the `QueryError` it throws becomes a typed failure. */
879
+ const tryCanonical = (nodes, read) => Effect.suspend(() => {
880
+ const result = Result.try(() => canonical(nodes, read));
881
+ if (Result.isSuccess(result)) return Effect.succeed(result.success);
882
+ return result.failure instanceof QueryError ? Effect.fail(result.failure) : Effect.die(result.failure);
883
+ });
884
+ /**
885
+ * The `Client.UrlReads` seam over this bridge (#159, ADR-0029 §2): pass as
886
+ * `urls` to `Client.make` / `Client.connect` / `Client.fromView` so
887
+ * `client.subscribe(url)` parses through {@link parseRead} and keys the pin
888
+ * by {@link canonical}, and to an engine host (`ServiceWorkerHost.run`) so
889
+ * a page with no Client can register by URL. The `fields[]` projection a
890
+ * URL carries is dropped: it never changes what a subscription syncs
891
+ * (ADR-0029 §5), so two URLs differing only in `fields[]` are one
892
+ * subscription.
893
+ */
894
+ const urlReads = (nodes) => ({
895
+ parse: (url) => Effect.map(parseRead(nodes, url), (parsed) => parsed.read),
896
+ canonical: (read) => tryCanonical(nodes, read)
897
+ });
898
+ /** The attribute schema of a field whose literals have the JS type. */
899
+ const scalarSchema = (type) => type === "string" ? Schema.String : type === "number" ? Schema.Number : Schema.Boolean;
900
+ /** Collect the literal JS types each field of a tree is compared with (mutates `types`). */
901
+ const collectLiteralTypes = (ast, types) => {
902
+ const add = (field, value) => {
903
+ const set = types.get(field) ?? /* @__PURE__ */ new Set();
904
+ set.add(typeof value);
905
+ types.set(field, set);
906
+ };
907
+ switch (ast._tag) {
908
+ case "Compare": return add(ast.field, ast.value);
909
+ case "In":
910
+ case "NotIn": return ast.values.forEach((value) => add(ast.field, value));
911
+ case "IsNull":
912
+ types.get(ast.field) ?? types.set(ast.field, /* @__PURE__ */ new Set());
913
+ return;
914
+ case "And":
915
+ case "Or": return ast.members.forEach((member) => collectLiteralTypes(member, types));
916
+ case "Not": return collectLiteralTypes(ast.member, types);
917
+ }
918
+ };
919
+ /** The one JS type each field of a tree is compared with (`string` for a field only null-tested). */
920
+ const literalTypesOf = (ast) => {
921
+ const types = /* @__PURE__ */ new Map();
922
+ collectLiteralTypes(ast, types);
923
+ const resolved = /* @__PURE__ */ new Map();
924
+ types.forEach((kinds, field) => {
925
+ const [kind, ...rest] = kinds;
926
+ if (Arr.isReadonlyArrayNonEmpty(rest)) throw new QueryError({
927
+ message: `Field "${field}" is compared with literals of several types (${[...kinds].join(", ")}); ADR-0028 rule (d) admits one`,
928
+ parameter: `filter[${field}]`
929
+ });
930
+ resolved.set(field, kind ?? "string");
931
+ });
932
+ return resolved;
933
+ };
934
+ const flatFilterCodecs = /* @__PURE__ */ new Map();
935
+ /**
936
+ * The codec for encoding a tree without a node graph: each field it names
937
+ * admits every operator, typed by the JS type of its literals. The wire
938
+ * form of a scalar does not depend on its declared kind, so this encodes
939
+ * exactly what the typed codec would. Memoised by the field → type
940
+ * signature, since a backend encodes the same shapes on every pull.
941
+ */
942
+ const flatFilterCodecOf = (ast) => {
943
+ const types = literalTypesOf(ast);
944
+ const signature = [...types].map(([field, type]) => `${field}:${type}`).toSorted(Predicate.compareText).join("\n");
945
+ const cached = flatFilterCodecs.get(signature);
946
+ if (cached !== void 0) return cached;
947
+ const attributes = R.fromIterableWith(types, ([field, type]) => [field, scalarSchema(type).pipe(Filter.able())]);
948
+ const codec = Query.Filter(Resource.make("query", { attributes }));
949
+ flatFilterCodecs.set(signature, codec);
950
+ return codec;
951
+ };
952
+ /**
953
+ * The canonical flat `filter[...]` record of a filter AST — the grammar's
954
+ * own encoder (`Query.Filter`), so the shorthand, the group form, escaping
955
+ * and member order are effect-jsonapi's byte for byte.
956
+ */
957
+ const flatFilter = (ast) => Schema.encodeSync(flatFilterCodecOf(ast))(ast);
958
+ /**
959
+ * Serialize sort terms into the JSON:API `sort` value (`-field,field`) through
960
+ * the library's `Query.Sort` codec, so the wire encoding matches the spec's
961
+ * descending-prefix convention by construction.
962
+ *
963
+ * Since effect-jsonapi 0.15 the codec's encoded side is `Repeatable`
964
+ * (`string | ReadonlyArray<string>`) because it also decodes the repeated-key
965
+ * spelling; its encoder emits the comma form, and an array is joined into
966
+ * that same form rather than trusted to never appear.
967
+ */
968
+ const encodeSort = (terms) => {
969
+ const encoded = Schema.encodeSync(Query.Sort(terms.map((term) => term.field)))(terms.map((term) => ({
970
+ field: term.field,
971
+ direction: term.direction
972
+ })));
973
+ return P.isString(encoded) ? encoded : encoded.join(",");
974
+ };
975
+ /**
976
+ * Build the JSON:API query string (with its leading `?`, or `` when empty)
977
+ * for `include` / `fields` / `filter` / `sort` / `page`, in the canonical
978
+ * family order and percent-encoding (`Query.serialise`). `filter` is a
979
+ * filter AST, encoded exactly through {@link flatFilter}.
980
+ */
981
+ const buildQueryString = (options) => {
982
+ if (options === void 0) return "";
983
+ const pairs = [];
984
+ if (options.include && Arr.isReadonlyArrayNonEmpty(options.include)) pairs.push(["include", options.include.join(",")]);
985
+ pairs.push(...Object.entries(options.fields ?? {}).map(([type, names]) => [`fields[${type}]`, names.join(",")]));
986
+ if (options.filter !== void 0) pairs.push(...Object.entries(flatFilter(options.filter)));
987
+ if (options.sort && Arr.isReadonlyArrayNonEmpty(options.sort)) pairs.push(["sort", encodeSort(options.sort)]);
988
+ pairs.push(...Object.entries(options.page ?? {}).flatMap(([key, value]) => value === void 0 ? [] : [[`page[${key}]`, String(value)]]));
989
+ const query = Query.serialise(pairs);
990
+ return query === "" ? "" : `?${query}`;
991
+ };
992
+ /**
993
+ * The request options a subset encodes to, or `None` when its predicate
994
+ * folds to false. The filter is the whole predicate, constants folded;
995
+ * `orderBy` becomes `sort`. Limit and cursor are not pushed: the window is
996
+ * applied locally over the exact filter. Throws a `QueryError` for a
997
+ * predicate with no URL form (`Exists`, a `null` literal).
998
+ */
999
+ const toRequestOptions = (subset) => {
1000
+ const where = foldConstants(subset.where);
1001
+ if (isFalse(where)) return Option.none();
1002
+ const filter = isTrue(where) ? void 0 : Option.getOrThrowWith(toFilterAst(where), () => noUrlForm(subset.collection, "the predicate carries an Exists node or a null literal"));
1003
+ const sort = subset.orderBy.map((term) => ({
1004
+ field: term.field,
1005
+ direction: term.direction
1006
+ }));
1007
+ return Option.some({
1008
+ ...filter === void 0 ? {} : { filter },
1009
+ ...Arr.isReadonlyArrayEmpty(sort) ? {} : { sort }
1010
+ });
1011
+ };
1012
+ /** The shipped encoder: exact under ADR-0028 (see the module doc). */
1013
+ const defaultEncoder = { encode: (subset) => Effect.try({
1014
+ try: () => Option.map(toRequestOptions(subset), buildQueryString),
1015
+ catch: (error) => error instanceof QueryError ? error : new QueryError({
1016
+ message: `Subset on "${subset.collection}" could not be encoded: ${error instanceof Error ? error.message : String(error)}`,
1017
+ query: subset.collection,
1018
+ cause: error
1019
+ })
1020
+ }) };
1021
+ // src/CollectionLoader.ts
1022
+ /**
1023
+ * CollectionLoader
1024
+ *
1025
+ * The JSON:API package's place in the v2 model (ADR-0008): a *collection
1026
+ * loader*. A JSON:API `type` is a collection, each resource's `id` is a row
1027
+ * id, and a resource maps to a row value (attributes + resolved
1028
+ * relationship references). Loaders read documents into collection rows;
1029
+ * they do not own a sync model of their own.
1030
+ *
1031
+ * This is a read-side adapter. Writes in the v2 engine are named mutators
1032
+ * (ADR-0008), not ad-hoc REST calls, so the former conflict-strategy
1033
+ * push/pull helpers are gone. To sync a JSON:API upstream, back a `Backend`
1034
+ * (or a read-only collection) with `load`/`loadOne` and emit row versions
1035
+ * from the upstream's own change token (ETag, `meta.version`, …).
1036
+ */
1037
+ var CollectionLoader_exports = /* @__PURE__ */ __exportAll({
1038
+ JsonApiSource: () => JsonApiSource,
1039
+ buildQueryString: () => buildQueryString,
1040
+ documentToRows: () => documentToRows,
1041
+ load: () => load,
1042
+ loadOne: () => loadOne,
1043
+ parseResponse: () => parseResponse,
1044
+ resourceToRow: () => resourceToRow
1045
+ });
1046
+ const identifier = (ref) => ({
1047
+ type: ref.type,
1048
+ id: ref.id
1049
+ });
1050
+ /**
1051
+ * `Array.isArray` types its guard as `any[]`, so it does not narrow the
1052
+ * `ReadonlyArray` members the schema-derived document types use. This guard
1053
+ * narrows them correctly.
1054
+ */
1055
+ const isReadonlyArray = (value) => Array.isArray(value);
1056
+ /** Map a resource (or bare identifier) to a collection row. */
1057
+ const resourceToRow = (resource) => {
1058
+ const full = resource;
1059
+ const relationships = R.filterMap(full.relationships ?? {}, ({ data }) => data === void 0 ? Result.failVoid : Result.succeed(data === null ? null : isReadonlyArray(data) ? data.map(identifier) : identifier(data)));
1060
+ return {
1061
+ collection: resource.type,
1062
+ id: resource.id,
1063
+ value: {
1064
+ type: resource.type,
1065
+ id: resource.id,
1066
+ attributes: full.attributes ?? {},
1067
+ relationships
1068
+ }
1069
+ };
1070
+ };
1071
+ const dataResources = (data) => data === void 0 || data === null ? [] : isReadonlyArray(data) ? data : [data];
1072
+ /** Map a whole document to its primary rows and its `included` rows. */
1073
+ const documentToRows = (document) => ({
1074
+ rows: dataResources(document.data).map(resourceToRow),
1075
+ included: (document.included ?? []).map(resourceToRow)
1076
+ });
1077
+ /** The {@link JsonApiSource} service loaders read from. */
1078
+ const JsonApiSource = Context.Service("@thomasfosterau/legation-jsonapi/JsonApiSource");
1079
+ const jsonApiHeaders$1 = {
1080
+ "Content-Type": "application/vnd.api+json",
1081
+ Accept: "application/vnd.api+json"
1082
+ };
1083
+ /** Validate a JSON:API response into a document (non-2xx / `errors` → ApiError). */
1084
+ const parseResponse = (response) => Effect.gen(function* () {
1085
+ if (response.status < 200 || response.status >= 300) {
1086
+ const body = yield* response.json.pipe(Effect.orElseSucceed(() => void 0));
1087
+ const candidate = P.isObjectOrArray(body) ? body.errors : void 0;
1088
+ const errors = Array.isArray(candidate) ? candidate : [];
1089
+ return yield* new ApiError({
1090
+ message: `HTTP ${response.status}`,
1091
+ errors
1092
+ });
1093
+ }
1094
+ const json = yield* response.json.pipe(Effect.mapError((cause) => new ParseError({
1095
+ message: "Failed to parse JSON body",
1096
+ cause
1097
+ })));
1098
+ const document = yield* decodeDocument(json);
1099
+ if (document.errors && Arr.isReadonlyArrayNonEmpty(document.errors)) return yield* new ApiError({
1100
+ message: "Document carried errors",
1101
+ errors: document.errors
1102
+ });
1103
+ return document;
1104
+ });
1105
+ const get = (url) => HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(jsonApiHeaders$1));
1106
+ /** Load a collection (`GET /{type}`) into rows. */
1107
+ const load = (type, options) => Effect.gen(function* () {
1108
+ const source = yield* JsonApiSource;
1109
+ const client = yield* HttpClient.HttpClient;
1110
+ const url = `${source.baseUrl}/${type}${buildQueryString(options)}`;
1111
+ const response = yield* Effect.scoped(client.execute(get(url)));
1112
+ return documentToRows(yield* parseResponse(response));
1113
+ });
1114
+ /** Load a single resource (`GET /{type}/{id}`); `undefined` if absent. */
1115
+ const loadOne = (type, id, options) => Effect.gen(function* () {
1116
+ const source = yield* JsonApiSource;
1117
+ const client = yield* HttpClient.HttpClient;
1118
+ const url = `${source.baseUrl}/${type}/${id}${buildQueryString(options)}`;
1119
+ const response = yield* Effect.scoped(client.execute(get(url)));
1120
+ return documentToRows(yield* parseResponse(response)).rows[0];
1121
+ });
1122
+ // src/Convention.ts
1123
+ /**
1124
+ * Convention
1125
+ *
1126
+ * Convention-first discovery of a JSON:API resource surface from an app's
1127
+ * Effect `HttpApi` (the happy path: "bring a JSON:API api, get sync"). We
1128
+ * reflect the api's endpoints and classify each one by HTTP method + path
1129
+ * into a JSON:API role, so no per-endpoint annotation is required:
1130
+ *
1131
+ * | method | path shape | role |
1132
+ * | ------- | --------------- | -------- |
1133
+ * | GET | `/{type}` | list |
1134
+ * | GET | `/{type}/:id` | getOne |
1135
+ * | POST | `/{type}` | create |
1136
+ * | PATCH | `/{type}/:id` | update |
1137
+ * | PUT | `/{type}/:id` | update |
1138
+ * | DELETE | `/{type}/:id` | delete |
1139
+ *
1140
+ * The resource `type` (the collection name) is the last static path segment
1141
+ * (the segment before a trailing `:id` param, or the final segment). Any
1142
+ * prefix (`/api`, version segments) is ignored. Endpoints that don't fit the
1143
+ * table — nested routes, RPCs, custom actions — are simply not part of the
1144
+ * derived CRUD surface; the app can still call them directly.
1145
+ *
1146
+ * One endpoint is recognised structurally rather than by path: an atomic
1147
+ * operations endpoint (the JSON:API Atomic Operations extension, e.g.
1148
+ * effect-jsonapi's `Endpoint.operations`, conventionally `POST /operations`)
1149
+ * is any endpoint whose request payload is an `atomic:operations` document.
1150
+ * By path shape alone it would read as `create` on a phantom `operations`
1151
+ * resource, so `reflectResources` skips it and `reflectAtomic` reports it
1152
+ * instead — the backend then pushes a whole batch as one request.
1153
+ */
1154
+ var Convention_exports = /* @__PURE__ */ __exportAll({
1155
+ classify: () => classify,
1156
+ isAtomicEndpoint: () => isAtomicEndpoint,
1157
+ isParam: () => isParam,
1158
+ reflectAtomic: () => reflectAtomic,
1159
+ reflectResources: () => reflectResources,
1160
+ segmentsOf: () => segmentsOf
1161
+ });
1162
+ /** A route template's segments, empty ones dropped (`/todos/:id` is `["todos", ":id"]`). */
1163
+ const segmentsOf = (path) => path.split("/").filter((segment) => Str.isNonEmpty(segment));
1164
+ /** Whether a template segment is a path parameter (`:id`). */
1165
+ const isParam = (segment) => segment.startsWith(":");
1166
+ /** Map an HTTP method + whether the route has a trailing id to a CRUD role. */
1167
+ const roleOf = (method, hasId) => {
1168
+ switch (method.toUpperCase()) {
1169
+ case "GET": return hasId ? "getOne" : "list";
1170
+ case "POST": return hasId ? void 0 : "create";
1171
+ case "PATCH":
1172
+ case "PUT": return hasId ? "update" : void 0;
1173
+ case "DELETE": return hasId ? "delete" : void 0;
1174
+ default: return;
1175
+ }
1176
+ };
1177
+ /**
1178
+ * Classify a (method, path) into a resource type + role, or `undefined` if
1179
+ * it isn't a conventional JSON:API CRUD route — `/{type}` or `/{type}/:id`,
1180
+ * where the type is a static segment and the only param is a trailing id
1181
+ * (nested routes like `/todos/:id/comments` are not CRUD on a single type).
1182
+ */
1183
+ const classify = (method, path) => {
1184
+ const segments = segmentsOf(path);
1185
+ const last = segments.at(-1);
1186
+ if (last === void 0) return void 0;
1187
+ const hasId = isParam(last);
1188
+ const type = hasId ? segments[segments.length - 2] : last;
1189
+ if (type === void 0 || isParam(type)) return void 0;
1190
+ const params = segments.filter(isParam);
1191
+ if (params.length > 1 || params.length === 1 && !hasId) return void 0;
1192
+ const operation = roleOf(method, hasId);
1193
+ return operation === void 0 ? void 0 : {
1194
+ type,
1195
+ operation
1196
+ };
1197
+ };
1198
+ /** The top-level member that makes a request an atomic operations document. */
1199
+ const ATOMIC_OPERATIONS_MEMBER = "atomic:operations";
1200
+ /** Depth bound for walking a payload schema's unions and suspensions. */
1201
+ const MEMBER_WALK_DEPTH = 8;
1202
+ /**
1203
+ * Whether a schema is (or, through unions and suspensions, may be) an object
1204
+ * with a top-level `atomic:operations` member.
1205
+ */
1206
+ const hasAtomicMember = (ast, depth = 0) => {
1207
+ if (depth > MEMBER_WALK_DEPTH) return false;
1208
+ switch (ast._tag) {
1209
+ case "Objects": return ast.propertySignatures.some((property) => property.name === ATOMIC_OPERATIONS_MEMBER);
1210
+ case "Union": return ast.types.some((member) => hasAtomicMember(member, depth + 1));
1211
+ case "Suspend": return hasAtomicMember(ast.thunk(), depth + 1);
1212
+ default: return false;
1213
+ }
1214
+ };
1215
+ /**
1216
+ * Whether an endpoint takes an `atomic:operations` request document: some
1217
+ * payload schema (any content type) is an object with a top-level
1218
+ * `atomic:operations` member, possibly one arm of a union. This is what
1219
+ * makes an endpoint the atomic operations endpoint — not its path, which is
1220
+ * configurable.
1221
+ */
1222
+ const isAtomicEndpoint = (endpoint) => Iterable.some(endpoint.payload.values(), ({ schemas }) => Iterable.some(schemas, (schema) => hasAtomicMember(schema.ast)));
1223
+ const refOf$1 = (group, endpoint) => ({
1224
+ groupId: group.identifier,
1225
+ name: endpoint.identifier,
1226
+ method: endpoint.method,
1227
+ path: endpoint.path
1228
+ });
1229
+ /**
1230
+ * Reflect an `HttpApi` into its JSON:API resource surface. The first endpoint
1231
+ * matching each (type, role) wins; later duplicates are ignored. An atomic
1232
+ * operations endpoint is never a resource (see `reflectAtomic`).
1233
+ */
1234
+ const reflectResources = (api) => {
1235
+ const resources = /* @__PURE__ */ new Map();
1236
+ HttpApi.reflect(api, {
1237
+ onGroup: () => {},
1238
+ onEndpoint: ({ group, endpoint }) => {
1239
+ if (isAtomicEndpoint(endpoint)) return;
1240
+ const role = classify(endpoint.method, endpoint.path);
1241
+ if (role === void 0) return;
1242
+ const existing = resources.get(role.type) ?? { type: role.type };
1243
+ if (existing[role.operation] !== void 0) return;
1244
+ existing[role.operation] = refOf$1(group, endpoint);
1245
+ resources.set(role.type, existing);
1246
+ }
1247
+ });
1248
+ return resources;
1249
+ };
1250
+ /**
1251
+ * Reflect an `HttpApi`'s atomic operations endpoint, if it declares one: the
1252
+ * first endpoint whose request payload is an `atomic:operations` document.
1253
+ * `JsonApiSync` uses it to push a batch as one all-or-nothing request.
1254
+ */
1255
+ const reflectAtomic = (api) => {
1256
+ let atomic;
1257
+ HttpApi.reflect(api, {
1258
+ onGroup: () => {},
1259
+ onEndpoint: ({ group, endpoint }) => {
1260
+ if (atomic === void 0 && isAtomicEndpoint(endpoint)) atomic = refOf$1(group, endpoint);
1261
+ }
1262
+ });
1263
+ return atomic;
1264
+ };
1265
+ // src/Derive.ts
1266
+ /**
1267
+ * Derive
1268
+ *
1269
+ * Turn a discovered JSON:API resource surface (Convention.ts) into the
1270
+ * engine's shared model: one `Collection` per resource type, and the three
1271
+ * CRUD mutators (create / update / delete) per type that has the matching
1272
+ * endpoint. Only the *client* (optimistic) handlers live here — they write
1273
+ * the overlay; the authoritative side is JsonApiBackend.ts, which replays
1274
+ * the same intents as HTTP calls.
1275
+ *
1276
+ * Rows are stored as the loader's `ResourceValue` (type, id, attributes,
1277
+ * relationship linkage), so the derived collections line up exactly with
1278
+ * what `CollectionLoader.documentToRows` produces on the read path.
1279
+ */
1280
+ var Derive_exports = /* @__PURE__ */ __exportAll({
1281
+ CreateArgs: () => CreateArgs,
1282
+ DeleteArgs: () => DeleteArgs,
1283
+ RowSchema: () => RowSchema,
1284
+ UpdateArgs: () => UpdateArgs,
1285
+ collectionFor: () => collectionFor,
1286
+ derive: () => derive,
1287
+ mutatorName: () => mutatorName,
1288
+ toRow: () => toRow
1289
+ });
1290
+ /** Flatten a loaded resource into an engine row (attributes at top level). */
1291
+ const toRow = (value) => ({
1292
+ ...value.attributes,
1293
+ id: value.id,
1294
+ relationships: value.relationships
1295
+ });
1296
+ /** Permissive codec for a row (the engine never decodes rows through it). */
1297
+ const RowSchema = Schema.Record(Schema.String, Schema.Unknown);
1298
+ /** The collection for a resource type: rows keyed by resource id. */
1299
+ const collectionFor = (type) => Collection.make({
1300
+ name: type,
1301
+ schema: RowSchema,
1302
+ id: (value) => value.id
1303
+ });
1304
+ /** The mutator name for a (type, operation): `"{type}/{operation}"`. */
1305
+ const mutatorName = (type, operation) => `${type}/${operation}`;
1306
+ /** Create: the full resource to insert (client supplies the id). */
1307
+ const CreateArgs = Schema.Struct({
1308
+ type: Schema.String,
1309
+ id: Schema.String,
1310
+ attributes: Schema.Record(Schema.String, Schema.Unknown),
1311
+ relationships: Schema.optional(Schema.Record(Schema.String, Schema.Unknown))
1312
+ });
1313
+ /** Update: a partial attribute patch, merged over the current row. */
1314
+ const UpdateArgs = Schema.Struct({
1315
+ type: Schema.String,
1316
+ id: Schema.String,
1317
+ attributes: Schema.Record(Schema.String, Schema.Unknown)
1318
+ });
1319
+ /** Delete: address one row. */
1320
+ const DeleteArgs = Schema.Struct({
1321
+ type: Schema.String,
1322
+ id: Schema.String
1323
+ });
1324
+ /**
1325
+ * Derive collections, CRUD mutators, and operation metadata from a resource
1326
+ * map. A mutator is only derived when the resource exposes the corresponding
1327
+ * write endpoint (no create endpoint ⇒ no `create` mutator).
1328
+ */
1329
+ const derive = (resources) => {
1330
+ const collections = [];
1331
+ const entries = [];
1332
+ const operations = /* @__PURE__ */ new Map();
1333
+ const add = (type, operation, args, local) => {
1334
+ const name = mutatorName(type, operation);
1335
+ entries.push({
1336
+ mutator: Mutator.make(name, args),
1337
+ local
1338
+ });
1339
+ operations.set(name, {
1340
+ operation,
1341
+ type
1342
+ });
1343
+ };
1344
+ resources.forEach((resource) => {
1345
+ const type = resource.type;
1346
+ collections.push(collectionFor(type));
1347
+ if (resource.create !== void 0) add(type, "create", CreateArgs, (args, writer) => writer.put(type, args.id, toRow({
1348
+ type,
1349
+ id: args.id,
1350
+ attributes: args.attributes,
1351
+ relationships: args.relationships ?? {}
1352
+ })));
1353
+ if (resource.update !== void 0) add(type, "update", UpdateArgs, (args, writer) => Effect.gen(function* () {
1354
+ const current = yield* writer.get(type, args.id);
1355
+ yield* writer.put(type, args.id, {
1356
+ ...current ?? {
1357
+ id: args.id,
1358
+ relationships: {}
1359
+ },
1360
+ ...args.attributes,
1361
+ id: args.id
1362
+ });
1363
+ }));
1364
+ if (resource.delete !== void 0) add(type, "delete", DeleteArgs, (args, writer) => writer.del(type, args.id));
1365
+ });
1366
+ return {
1367
+ collections,
1368
+ registry: Mutator.registry(entries),
1369
+ operations
1370
+ };
1371
+ };
1372
+ // src/DocumentWriter.ts
1373
+ /**
1374
+ * DocumentWriter
1375
+ *
1376
+ * Rows to a JSON:API compound document — the **one serialiser** ADR-0029 §6
1377
+ * binds both the derived REST handlers ({@link RestHandlers}, #163) and the
1378
+ * replica-side writer behind the service-worker façade
1379
+ * ({@link ReplicaDocuments}, #164) to: "same URL, same result". The
1380
+ * document is a pure function of the node graph, the read, the resolved
1381
+ * rows and the `fields[]` projection — no HTTP, no backend, no clock — so
1382
+ * the two sides agree byte for byte on document content whichever source
1383
+ * produced the rows.
1384
+ *
1385
+ * The mapping, node for node (CONTEXT.md: a `type` is a collection, a
1386
+ * resource `id` is a row id, a resource is a row value):
1387
+ *
1388
+ * - **Attributes** are the node's declared attributes, in declaration order,
1389
+ * read off the row value. A `one`-edge's key field (`categoryId`) is not
1390
+ * an attribute: it is linkage.
1391
+ * - **Relationships** come from two places. A `one` edge's linkage is its
1392
+ * key on the row (`{ data: { type, id } }`, or `data: null` when the key
1393
+ * is null). A collection-valued edge's linkage is the edge rows the read
1394
+ * resolved onto the row (`related[edge]`): present when the read path
1395
+ * fetched them, absent otherwise — a relationship never claims an empty
1396
+ * list it did not see. A paginated edge always carries the spec's `links`
1397
+ * (`self` / `related`, effect-jsonapi's model of an unbounded
1398
+ * relationship) and, when the read resolved its rows (`include=reviews`
1399
+ * includes a paginated edge like any other; only a per-edge window has no
1400
+ * URL form), their linkage beside the links.
1401
+ * - **`included`** holds the rows of every edge the read `include`s, in
1402
+ * code-point order of edge name then root-row order, deduplicated by
1403
+ * `(type, id)` with full linkage checked (`Handlers.buildIncluded`: an
1404
+ * included row no resource identifier in the document references is
1405
+ * thrown at, never emitted). An included resource carries its own
1406
+ * `one`-edge linkage and paginated links, and the collection-valued
1407
+ * linkage the caller resolved for it ({@link Input.linkage} — the third
1408
+ * batch, keyed by the included rows); an edge with no resolved rows is
1409
+ * omitted. The member itself is present exactly when the read has an
1410
+ * `include`, as an empty array when nothing resolved (JSON:API 1.1
1411
+ * §Fetching Includes: a response to a request carrying `include` is a
1412
+ * compound document).
1413
+ * - **`fields[type]`** is applied last, to attributes only, on primary and
1414
+ * included resources alike (ADR-0029 §5: a serialisation-time projection
1415
+ * over full rows). Linkage is never projected away.
1416
+ * - **`links.self`** is the read's canonical string (`JsonApiQuery.canonical`)
1417
+ * — one string per plan, whatever the request's spelling — and a
1418
+ * collection whose window is full (`first` rows came back) carries
1419
+ * `links.next`: the same canonical query with `page[after]` at the last
1420
+ * row's id, the cursor `Subset.apply` continues from. A short page has no
1421
+ * `next`. An optional `prefix` (the api's mount path) is prepended to
1422
+ * every link; ADR-0029 §6 lets the host differ, nothing else.
1423
+ *
1424
+ * Only **node rows** are written (a row value carrying the node's attributes
1425
+ * and its `one`-edge key fields, as `Node.row` decodes and `Query.run`
1426
+ * resolves). The `JsonApiRow` shape of `Derive.ts` (linkage under
1427
+ * `relationships`, no node graph) is not a second strategy here: its
1428
+ * collections declare no graph to plan against, so nothing can write them
1429
+ * until a bridge (`NodeBridge` over the app's resources) gives them one —
1430
+ * the seam is {@link relationshipOf}, the one place a row's linkage is read.
1431
+ */
1432
+ var DocumentWriter_exports = /* @__PURE__ */ __exportAll({
1433
+ collection: () => collection,
1434
+ data: () => data,
1435
+ idOf: () => idOf$1,
1436
+ relatedValuesOf: () => relatedValuesOf
1437
+ });
1438
+ /** A row value as a record (a node row always is one). */
1439
+ const recordOf = (value) => P.isObjectOrArray(value) ? value : {};
1440
+ /** The row id a row value carries (node rows carry `id` in the value). */
1441
+ const idOf$1 = (value) => String(recordOf(value)["id"]);
1442
+ /** A resolved edge's value as its rows: a list as it is, a single row alone, `null` / `undefined` as none. */
1443
+ const relatedValuesOf = (value) => Array.isArray(value) ? value : value === null || value === void 0 ? [] : [value];
1444
+ const envOf = (input) => ({
1445
+ nodes: input.nodes,
1446
+ linkage: input.linkage,
1447
+ prefix: input.prefix ?? ""
1448
+ });
1449
+ /** The node's declared attributes off the row value, in declaration order; absent ones are left out. */
1450
+ const attributesOf$1 = (node, value) => {
1451
+ return R.filterMap(node.attributes, (_, field) => {
1452
+ const attribute = value[field];
1453
+ return attribute === void 0 ? Result.failVoid : Result.succeed(attribute);
1454
+ });
1455
+ };
1456
+ /** The resolved rows of one collection-valued edge of a row: `related` first, then the caller's linkage. */
1457
+ const edgeRowsOf = (env, node, row, name) => {
1458
+ const related = row.related?.[name];
1459
+ return Array.isArray(related) ? related : env.linkage?.get(node.type)?.get(row.id)?.[name];
1460
+ };
1461
+ /**
1462
+ * The relationship object of one edge, or `undefined` when nothing is known
1463
+ * about it. This is where a row's linkage is read: a `one` edge's key off
1464
+ * its `via` field, a collection-valued edge's identifiers off its resolved
1465
+ * rows.
1466
+ */
1467
+ const relationshipOf = (env, node, row, name) => {
1468
+ const edge = node.edges[name];
1469
+ if (edge === void 0) return void 0;
1470
+ if (edge.kind === "one") {
1471
+ const key = recordOf(row.value)[edge.via];
1472
+ return { data: key === void 0 || key === null ? null : {
1473
+ type: edge.target,
1474
+ id: String(key)
1475
+ } };
1476
+ }
1477
+ const rows = edgeRowsOf(env, node, row, name);
1478
+ const data = rows === void 0 ? void 0 : { data: rows.map((value) => ({
1479
+ type: edge.target,
1480
+ id: idOf$1(value)
1481
+ })) };
1482
+ if (edge.kind === "many") return data;
1483
+ return {
1484
+ links: {
1485
+ self: `${env.prefix}${Handlers.relationshipLink(node.type, row.id, name)}`,
1486
+ related: `${env.prefix}${Handlers.relatedLink(node.type, row.id, name)}`
1487
+ },
1488
+ ...data
1489
+ };
1490
+ };
1491
+ /** The resource object of one row of a node. */
1492
+ const resourceOf = (env, node, row) => {
1493
+ const relationships = R.filterMap(node.edges, (_, name) => {
1494
+ const relationship = relationshipOf(env, node, row, name);
1495
+ return relationship === void 0 ? Result.failVoid : Result.succeed(relationship);
1496
+ });
1497
+ return {
1498
+ type: node.type,
1499
+ id: row.id,
1500
+ attributes: attributesOf$1(node, recordOf(row.value)),
1501
+ ...R.isEmptyReadonlyRecord(relationships) ? {} : { relationships }
1502
+ };
1503
+ };
1504
+ /** The edge rows of one included edge across the root rows, as resources of the target node. */
1505
+ const includedOf = (env, node, rows, name) => {
1506
+ const edge = node.edges[name];
1507
+ const target = edge === void 0 ? void 0 : env.nodes.nodes.get(edge.target);
1508
+ if (target === void 0) return [];
1509
+ return rows.flatMap((row) => relatedValuesOf(row.related[name]).map((related) => resourceOf(env, target, {
1510
+ id: idOf$1(related),
1511
+ value: related
1512
+ })));
1513
+ };
1514
+ /** The resource's attributes narrowed to its type's `fields[]` entry, when one is given. */
1515
+ const project = (resource, fields) => {
1516
+ const names = fields?.[resource.type];
1517
+ if (names === void 0) return resource;
1518
+ const attributes = R.filter(resource.attributes, (_, field) => names.includes(field));
1519
+ return {
1520
+ ...resource,
1521
+ attributes
1522
+ };
1523
+ };
1524
+ /** The node a read addresses; a read on an unknown collection is a definition defect. */
1525
+ const nodeOf = (nodes, read) => {
1526
+ const node = nodes.nodes.get(read.collection);
1527
+ if (node === void 0) throw new Error(`DocumentWriter: unknown collection "${read.collection}"`);
1528
+ return node;
1529
+ };
1530
+ /**
1531
+ * The primary resources and the compound `included`, both projected.
1532
+ * `included` is `undefined` for a read with no `include`, so the member is
1533
+ * omitted; a read with one gets the array even when it is empty, because a
1534
+ * response to a request carrying `include` is a compound document (JSON:API
1535
+ * 1.1 §Fetching Includes).
1536
+ */
1537
+ const assemble = (input, node) => {
1538
+ const env = envOf(input);
1539
+ const data = input.rows.map((row) => project(resourceOf(env, node, row), input.fields));
1540
+ const include = input.read.include;
1541
+ return {
1542
+ data,
1543
+ included: include === void 0 ? void 0 : Object.keys(include).toSorted(Predicate.compareText).flatMap((name) => includedOf(env, node, input.rows, name)).map((resource) => project(resource, input.fields))
1544
+ };
1545
+ };
1546
+ /** `links.self`: the read's canonical string behind the prefix. */
1547
+ const selfLink = (input) => `${input.prefix ?? ""}${canonical(input.nodes, input.read, input.fields)}`;
1548
+ /**
1549
+ * The read's resolved rows sliced to the page it actually asked for
1550
+ * (#209). An offset read's root window was compiled with
1551
+ * `limit = offset + first` (`Query.plan`'s `rootLimit`) so a covering
1552
+ * subscription could be judged by the ordinary limit-prefix rule and the
1553
+ * window fetched in one shot; `input.rows` therefore holds up to
1554
+ * `offset + first` rows in window order, and this is where they are
1555
+ * sliced back down to the `first` rows starting at `offset` — the one
1556
+ * place both the derived REST handler and the replica-side writer apply
1557
+ * the slice, since both call this module. A cursor-paginated read has no
1558
+ * `offset`, so its rows are its page already.
1559
+ */
1560
+ const pageOf = (input) => {
1561
+ const { offset, first } = input.read;
1562
+ if (offset === void 0) return input.rows;
1563
+ return input.rows.slice(offset, first === void 0 ? void 0 : offset + first);
1564
+ };
1565
+ /**
1566
+ * `links.next` for a full page: an offset read continues at `offset +
1567
+ * first` in the same idiom it was asked in; a cursor read continues after
1568
+ * the page's last row (`page[after]`), the cursor `Subset.apply` resumes
1569
+ * from. A short page — fewer rows than `first`, or no window at all — has
1570
+ * no next; switching a cursor request to `page[after]`-of-offset (or vice
1571
+ * versa) is never done — pagination links stay in the idiom the request
1572
+ * used (#209).
1573
+ */
1574
+ const nextLink = (input, page) => {
1575
+ const { first, offset } = input.read;
1576
+ if (first === void 0 || page.length < first) return void 0;
1577
+ const link = (read) => `${input.prefix ?? ""}${canonical(input.nodes, read, input.fields)}`;
1578
+ if (offset !== void 0) return link({
1579
+ ...input.read,
1580
+ offset: offset + first
1581
+ });
1582
+ const last = page.at(-1);
1583
+ return last === void 0 ? void 0 : link({
1584
+ ...input.read,
1585
+ after: last.id
1586
+ });
1587
+ };
1588
+ /**
1589
+ * `links.prev` for an offset page past the first: the same query at
1590
+ * `max(0, offset - first)`. Cursor pagination has no `prev` — a forward
1591
+ * cursor names no way back, and ADR-0029's model is unbounded-forward only
1592
+ * (#209 adds `prev` for the offset idiom alone).
1593
+ */
1594
+ const prevLink = (input) => {
1595
+ const { first, offset } = input.read;
1596
+ if (offset === void 0 || offset <= 0 || first === void 0) return void 0;
1597
+ const read = {
1598
+ ...input.read,
1599
+ offset: Math.max(0, offset - first)
1600
+ };
1601
+ return `${input.prefix ?? ""}${canonical(input.nodes, read, input.fields)}`;
1602
+ };
1603
+ /**
1604
+ * The collection document of a read: every row of its page as primary
1605
+ * data, the included edges compounded, `links.self` canonical and
1606
+ * `links.next` (and, for an offset read past the first page, `links.prev`)
1607
+ * when the page is full. Throws for a read the canonical encoder refuses
1608
+ * (a per-edge window, an `Exists`) — such a read has no URL, so it is
1609
+ * never one a URL produced.
1610
+ */
1611
+ const collection = (input) => {
1612
+ const page = pageOf(input);
1613
+ const { data, included } = assemble({
1614
+ ...input,
1615
+ rows: page
1616
+ }, nodeOf(input.nodes, input.read));
1617
+ const next = nextLink(input, page);
1618
+ const prev = prevLink(input);
1619
+ return Handlers.collection(data, {
1620
+ ...included === void 0 ? {} : { included },
1621
+ links: {
1622
+ self: selfLink(input),
1623
+ ...next === void 0 ? {} : { next },
1624
+ ...prev === void 0 ? {} : { prev }
1625
+ }
1626
+ });
1627
+ };
1628
+ /**
1629
+ * The single-resource document of a read: the first row as primary data
1630
+ * (`null` when the read matched nothing — the caller decides the 404), the
1631
+ * included edges compounded, `links.self` canonical.
1632
+ */
1633
+ const data = (input) => {
1634
+ const first = input.rows[0];
1635
+ const { data: primary, included } = assemble({
1636
+ ...input,
1637
+ rows: first === void 0 ? [] : [first]
1638
+ }, nodeOf(input.nodes, input.read));
1639
+ return Handlers.data(primary[0] ?? null, {
1640
+ ...included === void 0 ? {} : { included },
1641
+ links: { self: selfLink(input) }
1642
+ });
1643
+ };
1644
+ // src/Ledger.ts
1645
+ /**
1646
+ * Ledger
1647
+ *
1648
+ * The ledger a `JsonApiBackend` keeps for each client group (#468): the highest
1649
+ * acknowledged mutation id per client (`lastMutationIds`) and the permanent
1650
+ * rejections it still remembers, each with its reason (#366, #365). A
1651
+ * `LedgerStore` is where that state is read from and written to. The default,
1652
+ * {@link memory}, keeps nothing, so the backend's own in-memory copy is the
1653
+ * ledger and a restart forgets it. {@link keyValue} stores each group's ledger
1654
+ * in an Effect `KeyValueStore`, which survives a restart when that store does.
1655
+ */
1656
+ var Ledger_exports = /* @__PURE__ */ __exportAll({
1657
+ emptyGroup: () => emptyGroup,
1658
+ keyValue: () => keyValue,
1659
+ memory: () => memory
1660
+ });
1661
+ /** An empty group ledger: nothing acknowledged, nothing remembered. */
1662
+ const emptyGroup = {
1663
+ lastMutationIds: {},
1664
+ rejections: []
1665
+ };
1666
+ /**
1667
+ * The in-memory ledger store: it persists nothing, so the backend keeps each
1668
+ * group's ledger in its own memory for the life of the instance. A restart of
1669
+ * the backend forgets every acknowledged id and remembered rejection.
1670
+ */
1671
+ const memory = {
1672
+ read: () => Effect.succeed(emptyGroup),
1673
+ write: () => Effect.void
1674
+ };
1675
+ const Rejection = Schema.Struct({
1676
+ clientId: Schema.String,
1677
+ mutationId: Schema.Number,
1678
+ reason: Schema.String
1679
+ });
1680
+ const GroupLedger = Schema.Struct({
1681
+ lastMutationIds: Schema.Record(Schema.String, Schema.Number),
1682
+ rejections: Schema.Array(Rejection)
1683
+ });
1684
+ const decodeGroup = Schema.decodeUnknownEffect(GroupLedger);
1685
+ /**
1686
+ * A ledger store over an Effect `KeyValueStore`: each client group's ledger is
1687
+ * one JSON value under `${prefix}${clientGroupId}`. Use a durable key/value
1688
+ * store (a file system, SQL or browser storage layer) for it to survive a
1689
+ * restart. A stored value that does not decode is a `BackendError`, not an
1690
+ * empty ledger, so a corrupt store is never mistaken for a fresh one.
1691
+ */
1692
+ const keyValue = (store, options = {}) => {
1693
+ const keyOf = (clientGroupId) => `${options.prefix ?? "legation-jsonapi:ledger:"}${clientGroupId}`;
1694
+ return {
1695
+ read: (clientGroupId) => store.get(keyOf(clientGroupId)).pipe(Effect.mapError((cause) => new BackendError({
1696
+ message: "Ledger read failed",
1697
+ cause
1698
+ })), Effect.flatMap((raw) => raw === void 0 ? Effect.succeed(emptyGroup) : Effect.try({
1699
+ try: () => JSON.parse(raw),
1700
+ catch: (cause) => new BackendError({
1701
+ message: "Ledger is not JSON",
1702
+ cause
1703
+ })
1704
+ }).pipe(Effect.flatMap((value) => decodeGroup(value).pipe(Effect.mapError((cause) => new BackendError({
1705
+ message: "Ledger does not decode",
1706
+ cause
1707
+ }))))))),
1708
+ write: (clientGroupId, ledger) => store.set(keyOf(clientGroupId), JSON.stringify(ledger)).pipe(Effect.mapError((cause) => new BackendError({
1709
+ message: "Ledger write failed",
1710
+ cause
1711
+ })))
1712
+ };
1713
+ };
1714
+ // src/JsonApiBackend.ts
1715
+ /**
1716
+ * JsonApiBackend
1717
+ *
1718
+ * An implementation of the core `Backend` contract (ADR-0009) backed by a
1719
+ * JSON:API HTTP surface instead of app-owned storage. This is what lets the
1720
+ * library "figure out the sync story" from a plain REST API: the sync server
1721
+ * (in-process for the client-direct happy path) drives this backend's three
1722
+ * operations, and the existing engine supplies journal, overlay, rebase, and
1723
+ * CVR diffing on top.
1724
+ *
1725
+ * Mapping:
1726
+ * - `evaluate` lists the collection (`GET /{type}`), pushing the whole
1727
+ * predicate to the server as the filter grammar's canonical `filter[...]`
1728
+ * form via the `QueryEncoder` (exact under ADR-0028; a predicate that can
1729
+ * never match skips the request) and applying predicate, order and limit
1730
+ * locally with `Subset.apply` — the window is local, and re-running the
1731
+ * filter is redundancy over an exact server answer, not correction.
1732
+ * With `includes`, it adds `?include=...` and harvests the compound document's
1733
+ * related rows, serving auto-subscribed `id IN (...)` subsets from that cache.
1734
+ * - `getRows` serves row values from the cache `evaluate` populates and this
1735
+ * backend's own writes keep current (the sync server always evaluates before
1736
+ * fetching put rows in the same pull), falling back to `GET /{type}/{id}`.
1737
+ * The cache reflects this instance's own writes only, so a deployment runs
1738
+ * one JsonApiBackend per server and writes the server only through it
1739
+ * (#447): another writer's changes go unseen, and `getRows` can serve a
1740
+ * superseded row.
1741
+ * - `applyMutations` replays the batch as HTTP writes. Each mutation is one
1742
+ * JSON:API write (`Write`: a create, update or delete of one resource —
1743
+ * derived CRUD mutators map directly, and `writes` expresses custom
1744
+ * mutators). With `atomic` configured the whole batch goes as one
1745
+ * `atomic:operations` request (`create` → `add`, `update` → `update`,
1746
+ * `delete` → `remove`, sent with the extension's media type); otherwise
1747
+ * each write is its own `POST`/`PATCH`/`DELETE`.
1748
+ *
1749
+ * **Versioning.** A plain REST API exposes no row-version token, so versions
1750
+ * are a content hash of the resource value: the hash changes iff the row
1751
+ * changed, which is exactly what CVR diffing needs.
1752
+ *
1753
+ * **Change hints.** A push's hints carry each touched row's pre-push value as
1754
+ * `oldValues`, as MemoryBackend's do, so topic-derived fan-out (ADR-0023) pokes
1755
+ * the windows a row moved away from. A REST API keeps no change token, and
1756
+ * nothing here says whether a cached row is still current (a write made
1757
+ * elsewhere goes unseen, #447; this backend's own writes do keep its cache
1758
+ * current, #395), so the old value is always read: each update or delete does one
1759
+ * `GET /{type}/{id}` before its write, once per row per push. A row the push
1760
+ * creates was absent before the push, so it is not read (`enterBatchCreate`),
1761
+ * unless the server refuses the create and a later write of the row needs its
1762
+ * value (`readRefusedCreates`, #402). Every failed read fails the push with
1763
+ * BackendError, a 4xx and a 404 included, and so does a 2xx with no resource, so
1764
+ * an update or delete is either sent with its old value or not sent. On the
1765
+ * atomic path nothing has been written by then, so the retry reads again. On the
1766
+ * per-resource path the writes before the failed read have already applied and
1767
+ * been acknowledged, so the retry does not resend them
1768
+ * and their hints are lost: the at-least-once delivery described below, as for
1769
+ * any transient write failure there. A read the server refuses (a 4xx) stalls
1770
+ * that client's queue until the refusal is resolved. A resource that receives
1771
+ * updates or deletes needs a `getOne` route: without one each of its updates
1772
+ * and deletes fails its push, before anything is written. The new value of a
1773
+ * create or update is the server's returned document when it sends one, else
1774
+ * the row read before the push merged with the write (so a partial PATCH hints
1775
+ * the full row), else the written value.
1776
+ *
1777
+ * **Delivery semantics.** Without a cooperating server there is no
1778
+ * server-side mutation-id ledger, so this backend keeps one per client group:
1779
+ * the highest acknowledged id per client, and the rejections it remembers.
1780
+ * The ledger lives in this instance's memory by default (`Ledger.memory`), so
1781
+ * a restart of the backend forgets it: a resent mutation that was already
1782
+ * applied looks new, guarded only by the server's own idempotency, if any, and
1783
+ * a replayed copy or a pull can no longer report an earlier rejection. A
1784
+ * deployment that restarts its backend passes a durable `Ledger.LedgerStore`
1785
+ * (`ledger`, e.g. `Ledger.keyValue` over an Effect `KeyValueStore`) to keep
1786
+ * both across restarts (#468). Each ledger change is written after the server
1787
+ * has applied its writes and before the ids are acknowledged, in the order the
1788
+ * push already uses. If that ledger write fails after the server write
1789
+ * succeeded, the push fails with a `BackendError`. Mutations acknowledged earlier
1790
+ * in the same push keep their acknowledgement; the failed one and the rest of the
1791
+ * push are not acknowledged, so the client's resend re-applies them. On the atomic
1792
+ * path a rejection recorded before the failing write stays remembered, and the
1793
+ * resend records it again. That is the same window as a crash between the two
1794
+ * writes, and with or without a store the server's own idempotency is what guards
1795
+ * it.
1796
+ *
1797
+ * Over an atomic operations endpoint (`atomic` set) a push is all-or-nothing
1798
+ * per batch: one request, and the ledger advances only once the server has
1799
+ * applied every operation in it. A transient failure — transport, 5xx, or a
1800
+ * 4xx naming no operation — leaves the server holding nothing and the ledger
1801
+ * unmoved, so the client's retry resends the same batch. A 4xx error document
1802
+ * whose `source.pointer` names an operation (`Atomic.operationPointer(i)`) is
1803
+ * a permanent rejection of that mutation (ADR-0008's poison-mutation
1804
+ * semantics): it is recorded as rejected and the batch is resent without it,
1805
+ * until the remainder applies or nothing is left; every mutation in the batch
1806
+ * is then acknowledged in order (one round trip per rejection, bounded by the
1807
+ * batch size). `lid` is supported by the wire format but unused: derived
1808
+ * creates carry the client-generated `id`, and no mutator yet needs a
1809
+ * server-assigned one.
1810
+ *
1811
+ * The ledger also remembers the rejections it has made (#366), so a
1812
+ * replayed copy of a rejected mutation lists it in `rejected` with the same reason,
1813
+ * as MemoryBackend does. A push forgets the rejections below its lowest id per
1814
+ * client. The memory is bounded per client: a client that never pushes again keeps
1815
+ * its last push's rejections. It assumes one journal per `clientId`, which backend
1816
+ * contiguity already requires.
1817
+ *
1818
+ * Over per-resource endpoints (no `atomic`) delivery is *at-least-once*:
1819
+ * make writes idempotent (client-generated ids on create; PATCH/DELETE are
1820
+ * naturally idempotent) and retries are safe.
1821
+ */
1822
+ var JsonApiBackend_exports = /* @__PURE__ */ __exportAll({
1823
+ make: () => make$2,
1824
+ versionOf: () => versionOf
1825
+ });
1826
+ const jsonApiHeaders = {
1827
+ "Content-Type": "application/vnd.api+json",
1828
+ Accept: "application/vnd.api+json"
1829
+ };
1830
+ /** The extension media type an atomic operations request carries both ways. */
1831
+ const atomicHeaders = {
1832
+ "Content-Type": Atomic.MEDIA_TYPE,
1833
+ Accept: Atomic.MEDIA_TYPE
1834
+ };
1835
+ const trimSlashes = (s) => s.replace(/\/+$/, "");
1836
+ /** Resolve a route template against the base url, substituting the id param. */
1837
+ const urlFor = (baseUrl, path, id) => trimSlashes(baseUrl) + path.replace(/:[^/]+/g, () => encodeURIComponent(id ?? ""));
1838
+ /** A content-hash row version: a plain REST API exposes no version token. */
1839
+ const versionOf = Hash.contentHash;
1840
+ const backendError = (message) => (cause) => new BackendError({
1841
+ message,
1842
+ cause
1843
+ });
1844
+ /** The refusal for a mutation past its client's next expected id (Backend.ts guarantee 1). */
1845
+ const mutationGap = (clientId, expected, got) => new BackendError({ message: `Mutation-id gap for client "${clientId}": expected ${expected}, got ${got}` });
1846
+ /** Append `?`/`&`-joined params to an existing query string. */
1847
+ const appendQuery = (query, extra) => extra === "" ? query : query === "" ? `?${extra}` : `${query}&${extra}`;
1848
+ /**
1849
+ * The set of ids a subset pins via an `id IN (...)` / `id = ...` filter, or
1850
+ * `undefined` if it isn't id-pinned. Used to recognize auto-subscribed
1851
+ * relationship subsets that the include fast-path can serve from cache.
1852
+ */
1853
+ const pinnedIds = (predicate) => {
1854
+ switch (predicate._tag) {
1855
+ case "In": return predicate.field === "id" ? new Set(predicate.values.map((value) => String(value))) : void 0;
1856
+ case "Compare": return predicate.op === "eq" && predicate.field === "id" ? /* @__PURE__ */ new Set([String(predicate.value)]) : void 0;
1857
+ case "And": return Option.getOrUndefined(Arr.findFirst(predicate.predicates, (inner) => Option.fromUndefinedOr(pinnedIds(inner))));
1858
+ default: return;
1859
+ }
1860
+ };
1861
+ /**
1862
+ * Fold one applied write's effect into a push's PUT values (mutates `puts`). A
1863
+ * put records the row's value. A delete drops an earlier write's value for the
1864
+ * row, so a later delete in the same push leaves the hint naming it as a
1865
+ * delete (#369).
1866
+ */
1867
+ const recordEffect = (puts, collection, id, effect) => {
1868
+ if (effect._tag === "delete") puts.get(collection)?.delete(id);
1869
+ else puts.set(collection, (puts.get(collection) ?? /* @__PURE__ */ new Map()).set(id, effect.value));
1870
+ };
1871
+ /** Record a touched id under its collection (mutates `touched`). */
1872
+ const recordTouched = (touched, collection, id) => {
1873
+ touched.set(collection, (touched.get(collection) ?? /* @__PURE__ */ new Set()).add(id));
1874
+ };
1875
+ /** The row entry for (collection, id), if this push has entered the row. */
1876
+ const entryOf = (acc, collection, id) => acc.rows.get(collection)?.get(id);
1877
+ /** Enter a row under (collection, id) (mutates `acc`). */
1878
+ const setEntry = (acc, collection, id, entry) => {
1879
+ acc.rows.set(collection, (acc.rows.get(collection) ?? /* @__PURE__ */ new Map()).set(id, entry));
1880
+ };
1881
+ /**
1882
+ * The pre-push values this push read, as the hints' `olds`: the same snapshot
1883
+ * MemoryBackend takes before it applies a batch.
1884
+ */
1885
+ const oldsOf = (acc) => {
1886
+ const olds = /* @__PURE__ */ new Map();
1887
+ acc.rows.forEach((entries, collection) => entries.forEach((entry, id) => {
1888
+ if (Option.isSome(entry.before)) olds.set(collection, (olds.get(collection) ?? /* @__PURE__ */ new Map()).set(id, entry.before.value));
1889
+ }));
1890
+ return olds;
1891
+ };
1892
+ /**
1893
+ * Fold one applied write into the push: the row's value after it, and the
1894
+ * write's effect on the hint. A row the push has not entered was absent before
1895
+ * the push wrote it (a create), so it starts with no value.
1896
+ */
1897
+ const recordApplied = (acc, write, document) => {
1898
+ const entry = entryOf(acc, write.type, write.id) ?? {
1899
+ before: Option.none(),
1900
+ current: Option.none()
1901
+ };
1902
+ setEntry(acc, write.type, write.id, entry);
1903
+ entry.current = write.operation === "delete" ? Option.none() : Option.some(rowAfter(write, entry.current, document));
1904
+ recordTouched(acc.touched, write.type, write.id);
1905
+ recordEffect(acc.puts, write.type, write.id, Option.isSome(entry.current) ? {
1906
+ _tag: "put",
1907
+ value: entry.current.value
1908
+ } : { _tag: "delete" });
1909
+ };
1910
+ /** Build a JSON:API write body (`{ data: { type, id, attributes, ... } }`). */
1911
+ const writeBody = (type, id, attributes, relationships) => ({ data: {
1912
+ type,
1913
+ id,
1914
+ attributes,
1915
+ ...relationships !== void 0 ? { relationships } : {}
1916
+ } });
1917
+ /** A write's relationships in the row's linkage shape, as `toRow` gives a server document. */
1918
+ const linkageOf$1 = (write) => resourceToRow({
1919
+ type: write.type,
1920
+ id: write.id,
1921
+ relationships: write.relationships ?? {}
1922
+ }).value.relationships;
1923
+ /**
1924
+ * The value a create/update writes, as the hint carries it — the same shape
1925
+ * `toRow` gives a resource the server returned, so a hint looks alike
1926
+ * whichever path produced it and whatever the server answered.
1927
+ */
1928
+ const writtenValue = (write) => ({
1929
+ ...write.attributes,
1930
+ id: write.id,
1931
+ relationships: linkageOf$1(write)
1932
+ });
1933
+ /** The row after a partial update: the row read before the push, with the write laid over it. */
1934
+ const mergeWrite = (read, write) => ({
1935
+ ...read,
1936
+ ...write.attributes,
1937
+ id: write.id,
1938
+ relationships: {
1939
+ ...read.relationships,
1940
+ ...linkageOf$1(write)
1941
+ }
1942
+ });
1943
+ /**
1944
+ * The value a create or update leaves its row with: the server's document when
1945
+ * it returned one; otherwise the row's value so far merged with the write; or
1946
+ * the written value alone when the push does not know the row.
1947
+ */
1948
+ const rowAfter = (write, current, document) => document ?? (Option.isSome(current) ? mergeWrite(current.value, write) : writtenValue(write));
1949
+ /** Express a derived CRUD mutator's decoded args as one JSON:API write. */
1950
+ const writeOf$1 = (operation, args) => {
1951
+ switch (operation.operation) {
1952
+ case "create": {
1953
+ const a = args;
1954
+ return Effect.succeed({
1955
+ operation: "create",
1956
+ type: operation.type,
1957
+ id: a.id,
1958
+ attributes: a.attributes,
1959
+ ...a.relationships !== void 0 ? { relationships: a.relationships } : {}
1960
+ });
1961
+ }
1962
+ case "update": {
1963
+ const a = args;
1964
+ return Effect.succeed({
1965
+ operation: "update",
1966
+ type: operation.type,
1967
+ id: a.id,
1968
+ attributes: a.attributes
1969
+ });
1970
+ }
1971
+ case "delete": {
1972
+ const a = args;
1973
+ return Effect.succeed({
1974
+ operation: "delete",
1975
+ type: operation.type,
1976
+ id: a.id
1977
+ });
1978
+ }
1979
+ default: return Effect.fail(new BackendError({ message: `Operation "${operation.operation}" is not a write` }));
1980
+ }
1981
+ };
1982
+ /** One write as an atomic operation object (`add` / `update` / `remove`). */
1983
+ const operationOf = (write) => {
1984
+ switch (write.operation) {
1985
+ case "create": return {
1986
+ op: "add",
1987
+ data: {
1988
+ type: write.type,
1989
+ id: write.id,
1990
+ attributes: write.attributes ?? {},
1991
+ ...write.relationships !== void 0 ? { relationships: write.relationships } : {}
1992
+ }
1993
+ };
1994
+ case "update": return {
1995
+ op: "update",
1996
+ data: {
1997
+ type: write.type,
1998
+ id: write.id,
1999
+ attributes: write.attributes ?? {},
2000
+ ...write.relationships !== void 0 ? { relationships: write.relationships } : {}
2001
+ }
2002
+ };
2003
+ case "delete": return {
2004
+ op: "remove",
2005
+ ref: {
2006
+ type: write.type,
2007
+ id: write.id
2008
+ }
2009
+ };
2010
+ }
2011
+ };
2012
+ /**
2013
+ * The index of the operation an error's `source.pointer` names — the
2014
+ * operation itself (`Atomic.operationPointer(i)`) or a member below it — or
2015
+ * `undefined` when the pointer names none of the `count` operations sent.
2016
+ */
2017
+ const operationIndex = (pointer, count) => {
2018
+ const match = /^\/atomic:operations\/(\d+)(?:\/|$)/.exec(pointer);
2019
+ if (match === null) return void 0;
2020
+ const index = Number(match[1]);
2021
+ return index < count && Atomic.operationPointer(index) === `/atomic:operations/${index}` ? index : void 0;
2022
+ };
2023
+ /** The per-operation rejections an error document carries, by operation index. */
2024
+ const rejectionsOf = (body, count, fallbackReason) => {
2025
+ const errors = P.isObjectOrArray(body) && Array.isArray(body.errors) ? body.errors : [];
2026
+ const byIndex = /* @__PURE__ */ new Map();
2027
+ errors.forEach((error) => {
2028
+ const pointer = error?.source?.pointer;
2029
+ if (!P.isString(pointer)) return;
2030
+ const index = operationIndex(pointer, count);
2031
+ if (index === void 0 || byIndex.has(index)) return;
2032
+ byIndex.set(index, error.detail ?? error.title ?? fallbackReason);
2033
+ });
2034
+ return [...byIndex].map(([index, reason]) => ({
2035
+ index,
2036
+ reason
2037
+ }));
2038
+ };
2039
+ /** The row an `atomic:results` entry carries, if it returned a resource. */
2040
+ const resultRow = (result) => {
2041
+ const data = P.isObjectOrArray(result) ? result.data : void 0;
2042
+ const resource = Schema.decodeUnknownOption(ResourceObject)(data);
2043
+ return Option.isSome(resource) ? toRow(resourceToRow(resource.value).value) : void 0;
2044
+ };
2045
+ /**
2046
+ * Enter a row an atomic batch creates, before the batch's one request. The row
2047
+ * was absent before the push, so it needs no read: a later update or delete of
2048
+ * it in the same batch must not GET it, since that 404 would fail the push. An
2049
+ * entry already present (an earlier delete in the batch read the row) is kept.
2050
+ * A create the server refuses is the one case where the row may exist; the
2051
+ * resend reads it then (`readRefusedCreates`).
2052
+ */
2053
+ const enterBatchCreate = (acc, write) => {
2054
+ if (entryOf(acc, write.type, write.id) === void 0) setEntry(acc, write.type, write.id, {
2055
+ before: Option.none(),
2056
+ current: Option.none()
2057
+ });
2058
+ };
2059
+ /** A stored group ledger, as the backend holds it. */
2060
+ const groupOf = (ledger) => {
2061
+ const rejections = /* @__PURE__ */ new Map();
2062
+ ledger.rejections.forEach((entry) => {
2063
+ const ids = rejections.get(entry.clientId) ?? /* @__PURE__ */ new Map();
2064
+ ids.set(entry.mutationId, entry.reason);
2065
+ rejections.set(entry.clientId, ids);
2066
+ });
2067
+ return {
2068
+ acknowledged: new Map(Object.entries(ledger.lastMutationIds)),
2069
+ rejections
2070
+ };
2071
+ };
2072
+ /** A group as the store holds it. */
2073
+ const ledgerOf = (group) => ({
2074
+ lastMutationIds: Object.fromEntries(group.acknowledged),
2075
+ rejections: [...group.rejections].flatMap(([clientId, ids]) => [...ids].map(([mutationId, reason]) => ({
2076
+ clientId,
2077
+ mutationId,
2078
+ reason
2079
+ })))
2080
+ });
2081
+ /** Build the JsonApiBackend from its config; requires an `HttpClient`. */
2082
+ const make$2 = (config) => Effect.gen(function* () {
2083
+ const client = yield* HttpClient.HttpClient;
2084
+ const encoder = config.query ?? defaultEncoder;
2085
+ const includes = config.includes ?? {};
2086
+ const cache = yield* Ref.make(/* @__PURE__ */ new Map());
2087
+ const includedIds = yield* Ref.make(/* @__PURE__ */ new Map());
2088
+ const groups = yield* Ref.make(/* @__PURE__ */ new Map());
2089
+ const ledgerStore = config.ledger ?? memory;
2090
+ const ledgerLock = yield* Semaphore.make(1);
2091
+ const getJson = (url) => Effect.scoped(client.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(jsonApiHeaders))));
2092
+ const transientStatuses = /* @__PURE__ */ new Set([408, 429]);
2093
+ const isPermanentStatus = (status) => status >= 400 && status < 500 && !transientStatuses.has(status);
2094
+ /** Send one write; the resource document it answers with, if it answers with one. */
2095
+ const sendWrite = (request) => Effect.scoped(client.execute(request)).pipe(Effect.mapError(backendError("JSON:API write failed")), Effect.flatMap((response) => {
2096
+ if (response.status >= 200 && response.status < 300) return response.json.pipe(Effect.orElseSucceed(() => void 0), Effect.map(resultRow));
2097
+ const message = `JSON:API write returned HTTP ${response.status}`;
2098
+ return isPermanentStatus(response.status) ? Effect.fail(new MutationRejected({ reason: message })) : Effect.fail(new BackendError({ message }));
2099
+ }));
2100
+ /**
2101
+ * The `getOne` route an update or delete reads its row through. A resource
2102
+ * with no such route cannot be written by update or delete, so the write
2103
+ * fails with BackendError before anything is sent.
2104
+ */
2105
+ const getOneFor = (type) => {
2106
+ const getOne = config.resources.get(type)?.getOne;
2107
+ return getOne === void 0 ? Effect.fail(new BackendError({ message: `"${type}" has no getOne route, so its updates and deletes cannot read the row before writing it` })) : Effect.succeed(getOne);
2108
+ };
2109
+ /**
2110
+ * Read a row before the push writes it: the value the server holds now. Every
2111
+ * failed read fails the push with BackendError, a 404 and any other 4xx
2112
+ * included, and so does a 2xx whose document carries no resource: an update
2113
+ * or delete that went out without its old value would hint no `oldValues`, so
2114
+ * it is not sent instead. On the atomic path every read precedes the one
2115
+ * request, so nothing is written on a read the push cannot complete. On the
2116
+ * per-resource path the writes before the failed read have already applied,
2117
+ * and their hints are lost (see the module header's delivery note).
2118
+ *
2119
+ * The cache is never consulted here: it has no freshness signal for a write
2120
+ * made elsewhere (a version token would be needed), and this backend's own
2121
+ * writes keep it current for `getRows` only (#395). So each push reads the row.
2122
+ */
2123
+ const readBefore = (type, id) => Effect.gen(function* () {
2124
+ const getOne = yield* getOneFor(type);
2125
+ const response = yield* getJson(urlFor(config.baseUrl, getOne.path, id)).pipe(Effect.mapError(backendError(`Failed to read ${type}/${id} before writing it`)));
2126
+ if (response.status < 200 || response.status >= 300) return yield* new BackendError({ message: `JSON:API read of ${type}/${id} returned HTTP ${response.status}` });
2127
+ const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API resource ${type}/${id}`)));
2128
+ const resource = documentToRows(document).rows[0]?.value;
2129
+ if (resource === void 0) return yield* new BackendError({ message: `JSON:API read of ${type}/${id} returned no resource` });
2130
+ return toRow(resource);
2131
+ });
2132
+ /**
2133
+ * Enter a row the write touches. An update or delete reads the row on the
2134
+ * push's first write to it, so the pre-push value is kept for the hint (the
2135
+ * read is `readBefore`, where a cache hit would replace it, #395). A row
2136
+ * already entered is not read again. A create reads nothing and enters no
2137
+ * row: its row is entered when the create applies, or up front for the
2138
+ * atomic path's batch (`enterBatchCreate`).
2139
+ */
2140
+ const enterRow = (acc, write) => {
2141
+ if (write.operation === "create" || entryOf(acc, write.type, write.id) !== void 0) return Effect.void;
2142
+ return readBefore(write.type, write.id).pipe(Effect.map((before) => setEntry(acc, write.type, write.id, {
2143
+ before: Option.some(before),
2144
+ current: Option.some(before)
2145
+ })));
2146
+ };
2147
+ /**
2148
+ * Refuse a write that cannot be read before it is sent: an update or delete
2149
+ * of a resource with no `getOne` route. Checked for a whole push before its
2150
+ * first write, so a push that cannot be read sends nothing on either path.
2151
+ */
2152
+ const checkReadable = (write) => write.operation === "create" ? Effect.void : getOneFor(write.type).pipe(Effect.asVoid);
2153
+ /** Enter one write of an atomic batch before its request: a create as absent, others by reading the row. */
2154
+ const enterBatchWrite = (acc, write) => write.operation === "create" ? Effect.sync(() => enterBatchCreate(acc, write)) : enterRow(acc, write);
2155
+ /**
2156
+ * Read the rows the batch's refused creates would have made absent (#402).
2157
+ * The batch entered each row a create touches as absent, which is right only
2158
+ * while the create applies. The server refused these creates, so the row may
2159
+ * exist: when a later update or delete of it is still to be sent, read it
2160
+ * now, before the resend, so that write carries its pre-push value. Each row
2161
+ * is read at most once, and a row no remaining update or delete touches is
2162
+ * not read, so a create that applies never makes a read that can fail the push.
2163
+ */
2164
+ const readRefusedCreates = (acc, refused, remaining) => Effect.forEach(refused.filter((entry) => entry.write.operation === "create"), (create) => {
2165
+ const { type, id } = create.write;
2166
+ const laterWrite = remaining.some((entry) => entry.index > create.index && entry.write.operation !== "create" && entry.write.type === type && entry.write.id === id);
2167
+ const entry = entryOf(acc, type, id);
2168
+ if (!laterWrite || entry !== void 0 && Option.isSome(entry.before)) return Effect.void;
2169
+ return readBefore(type, id).pipe(Effect.map((row) => setEntry(acc, type, id, {
2170
+ before: Option.some(row),
2171
+ current: Option.some(row)
2172
+ })));
2173
+ }, { discard: true });
2174
+ const cacheRows = (collection, rows) => Ref.update(cache, (current) => {
2175
+ const next = new Map(current);
2176
+ const map = new Map(next.get(collection) ?? []);
2177
+ rows.forEach((value) => map.set(value.id, value));
2178
+ next.set(collection, map);
2179
+ return next;
2180
+ });
2181
+ /**
2182
+ * Bring the cache into line with a write the server has applied (#395): the
2183
+ * row's value after the push's writes so far, or no row once a delete has
2184
+ * applied, so `getRows` never serves a value the push has superseded.
2185
+ */
2186
+ const cacheApplied = (acc, write) => {
2187
+ const current = entryOf(acc, write.type, write.id)?.current ?? Option.none();
2188
+ return Ref.update(cache, (rows) => {
2189
+ const next = new Map(rows);
2190
+ const map = new Map(next.get(write.type) ?? []);
2191
+ if (Option.isSome(current)) map.set(write.id, current.value);
2192
+ else map.delete(write.id);
2193
+ next.set(write.type, map);
2194
+ return next;
2195
+ });
2196
+ };
2197
+ /** Harvest a compound document's `included` rows; record their ids complete. */
2198
+ const harvestIncluded = (included) => Effect.gen(function* () {
2199
+ const byCollection = Arr.groupBy(included, (row) => row.$collection);
2200
+ yield* Effect.forEach(R.toEntries(byCollection), ([collection, rows]) => cacheRows(collection, rows).pipe(Effect.andThen(Ref.update(includedIds, (current) => {
2201
+ const next = new Map(current);
2202
+ const ids = new Set(next.get(collection) ?? []);
2203
+ rows.forEach((row) => ids.add(row.id));
2204
+ next.set(collection, ids);
2205
+ return next;
2206
+ }))), {
2207
+ concurrency: 1,
2208
+ discard: true
2209
+ });
2210
+ });
2211
+ const entriesFrom = (values, subset) => Subset.apply(values.map((value) => ({
2212
+ id: value.id,
2213
+ value
2214
+ })), subset).map((row) => ({
2215
+ id: row.id,
2216
+ version: versionOf(row.value)
2217
+ }));
2218
+ const evaluate = (_context, subset) => Effect.gen(function* () {
2219
+ const list = config.resources.get(subset.collection)?.list;
2220
+ if (list === void 0) return [];
2221
+ if (Predicate.hasExists(subset.where)) return yield* new BackendError({ message: `JSON:API backend does not support Exists predicates on "${subset.collection}"` });
2222
+ const ids = pinnedIds(subset.where);
2223
+ if (ids !== void 0) {
2224
+ const complete = (yield* Ref.get(includedIds)).get(subset.collection);
2225
+ if (complete !== void 0 && [...ids].every((id) => complete.has(id))) {
2226
+ const cached = (yield* Ref.get(cache)).get(subset.collection);
2227
+ if (cached !== void 0 && [...ids].every((id) => cached.has(id))) return entriesFrom([...cached.values()], subset);
2228
+ }
2229
+ }
2230
+ const encoded = yield* encoder.encode(subset).pipe(Effect.mapError(backendError(`Subset on "${subset.collection}" has no URL form`)));
2231
+ if (Option.isNone(encoded)) return [];
2232
+ const includeParam = (includes[subset.collection] ?? []).join(",");
2233
+ const query = appendQuery(encoded.value, includeParam === "" ? "" : `include=${encodeURIComponent(includeParam)}`);
2234
+ const url = urlFor(config.baseUrl, list.path) + query;
2235
+ const response = yield* getJson(url).pipe(Effect.mapError(backendError(`Failed to list "${subset.collection}"`)));
2236
+ const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API listing "${subset.collection}"`)));
2237
+ const loaded = documentToRows(document);
2238
+ const values = loaded.rows.map((row) => toRow(row.value));
2239
+ yield* cacheRows(subset.collection, values);
2240
+ yield* harvestIncluded(loaded.included.map((row) => ({
2241
+ ...toRow(row.value),
2242
+ $collection: row.collection
2243
+ })));
2244
+ return entriesFrom(values, subset);
2245
+ });
2246
+ const getRows = (_context, collection, ids) => Effect.gen(function* () {
2247
+ const cached = (yield* Ref.get(cache)).get(collection);
2248
+ const getOne = config.resources.get(collection)?.getOne;
2249
+ return (yield* Effect.forEach(ids, (id) => Effect.gen(function* () {
2250
+ const hit = cached?.get(id);
2251
+ if (hit !== void 0) return [{
2252
+ id,
2253
+ version: versionOf(hit),
2254
+ value: hit
2255
+ }];
2256
+ if (getOne === void 0) return [];
2257
+ const response = yield* getJson(urlFor(config.baseUrl, getOne.path, id)).pipe(Effect.mapError(backendError(`Failed to fetch ${collection}/${id}`)));
2258
+ if (response.status === 404) return [];
2259
+ const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API resource ${collection}/${id}`)));
2260
+ const resource = documentToRows(document).rows[0]?.value;
2261
+ if (resource === void 0) return [];
2262
+ const value = toRow(resource);
2263
+ return [{
2264
+ id,
2265
+ version: versionOf(value),
2266
+ value
2267
+ }];
2268
+ }), { concurrency: 1 })).flat();
2269
+ });
2270
+ /** Decode a pushed mutation's args and express it as one JSON:API write. */
2271
+ const writeFor = (mutation) => Effect.gen(function* () {
2272
+ const definition = config.mutators.get(mutation.mutator);
2273
+ const custom = config.writes?.get(mutation.mutator);
2274
+ const operation = config.operations.get(mutation.mutator);
2275
+ if (definition === void 0 || custom === void 0 && operation === void 0) return yield* UnknownMutator.make(mutation.mutator);
2276
+ const args = yield* Schema.decodeUnknownEffect(definition.args)(mutation.args).pipe(Effect.mapError(backendError(`Invalid args for mutator "${mutation.mutator}"`)));
2277
+ if (custom !== void 0) return yield* Effect.try({
2278
+ try: () => custom(args),
2279
+ catch: backendError(`Invalid args for mutator "${mutation.mutator}"`)
2280
+ });
2281
+ if (operation === void 0) return yield* UnknownMutator.make(mutation.mutator);
2282
+ return yield* writeOf$1(operation, args);
2283
+ });
2284
+ const applyOne = (write) => Effect.gen(function* () {
2285
+ const resource = config.resources.get(write.type);
2286
+ switch (write.operation) {
2287
+ case "create": {
2288
+ const ref = resource?.create;
2289
+ if (ref === void 0) return yield* new BackendError({ message: `No create endpoint for "${write.type}"` });
2290
+ return yield* sendWrite(HttpClientRequest.post(urlFor(config.baseUrl, ref.path)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders), HttpClientRequest.bodyJsonUnsafe(writeBody(write.type, write.id, write.attributes ?? {}, write.relationships))));
2291
+ }
2292
+ case "update": {
2293
+ const ref = resource?.update;
2294
+ if (ref === void 0) return yield* new BackendError({ message: `No update endpoint for "${write.type}"` });
2295
+ return yield* sendWrite(HttpClientRequest.patch(urlFor(config.baseUrl, ref.path, write.id)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders), HttpClientRequest.bodyJsonUnsafe(writeBody(write.type, write.id, write.attributes ?? {}, write.relationships))));
2296
+ }
2297
+ case "delete": {
2298
+ const ref = resource?.delete;
2299
+ if (ref === void 0) return yield* new BackendError({ message: `No delete endpoint for "${write.type}"` });
2300
+ yield* sendWrite(HttpClientRequest.make("DELETE")(urlFor(config.baseUrl, ref.path, write.id)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders)));
2301
+ return;
2302
+ }
2303
+ }
2304
+ });
2305
+ /** The group's ledger, filled from the store on first use. Callers hold `ledgerLock`. */
2306
+ const fillGroup = (clientGroupId) => Effect.gen(function* () {
2307
+ const cached = (yield* Ref.get(groups)).get(clientGroupId);
2308
+ if (cached !== void 0) return cached;
2309
+ const group = groupOf(yield* ledgerStore.read(clientGroupId));
2310
+ yield* Ref.update(groups, (current) => new Map(current).set(clientGroupId, group));
2311
+ return group;
2312
+ });
2313
+ /** A client group's ledger, read under `ledgerLock` so a first read cannot race an update. */
2314
+ const readGroup = (clientGroupId) => Semaphore.withPermit(ledgerLock, fillGroup(clientGroupId));
2315
+ /**
2316
+ * Change a client group's ledger. `change` returns the group it becomes, or
2317
+ * the group it was when nothing changes, and then nothing is written. The
2318
+ * store is written before the new group is kept, so a failed write leaves the
2319
+ * store and this instance agreeing on the old group.
2320
+ */
2321
+ const updateGroup = (clientGroupId, change) => Semaphore.withPermit(ledgerLock, Effect.gen(function* () {
2322
+ const current = yield* fillGroup(clientGroupId);
2323
+ const next = change(current);
2324
+ if (next === current) return;
2325
+ yield* ledgerStore.write(clientGroupId, ledgerOf(next));
2326
+ yield* Ref.update(groups, (all) => new Map(all).set(clientGroupId, next));
2327
+ }));
2328
+ /** Acknowledge mutations in the ledger, in the order given, in one update. */
2329
+ const bumpLedger = (context, mutations) => Arr.isReadonlyArrayEmpty(mutations) ? Effect.void : updateGroup(context.clientGroupId, (group) => {
2330
+ const acknowledged = new Map(group.acknowledged);
2331
+ mutations.forEach((mutation) => acknowledged.set(mutation.clientId, mutation.mutationId));
2332
+ return {
2333
+ ...group,
2334
+ acknowledged
2335
+ };
2336
+ });
2337
+ /** Remember permanent rejections, so a replayed copy of them reports the same outcome (#366). */
2338
+ const rememberRejections = (context, rejected) => Arr.isReadonlyArrayEmpty(rejected) ? Effect.void : updateGroup(context.clientGroupId, (group) => {
2339
+ const rejections = new Map(group.rejections);
2340
+ rejected.forEach((entry) => {
2341
+ const ids = new Map(rejections.get(entry.clientId) ?? []);
2342
+ ids.set(entry.mutationId, entry.reason);
2343
+ rejections.set(entry.clientId, ids);
2344
+ });
2345
+ return {
2346
+ ...group,
2347
+ rejections
2348
+ };
2349
+ });
2350
+ /**
2351
+ * Forget the rejections this push's floors have moved past (#355), so the
2352
+ * memory is bounded per client, as MemoryBackend's is.
2353
+ */
2354
+ const forgetRejections = (context, mutations) => updateGroup(context.clientGroupId, (group) => {
2355
+ const rejections = RejectionMemory.forgetBelow(group.rejections, RejectionMemory.floorsOf(mutations));
2356
+ return rejections === group.rejections ? group : {
2357
+ ...group,
2358
+ rejections
2359
+ };
2360
+ });
2361
+ /**
2362
+ * The rejections a push's replayed copies were committed with (an id the ledger
2363
+ * already covers, with a remembered rejection). They are reported again (#366).
2364
+ */
2365
+ const replayedRejections = (context, mutations) => Effect.gen(function* () {
2366
+ const group = yield* readGroup(context.clientGroupId);
2367
+ return mutations.flatMap((mutation) => {
2368
+ if (mutation.mutationId > (group.acknowledged.get(mutation.clientId) ?? 0)) return [];
2369
+ const reason = group.rejections.get(mutation.clientId)?.get(mutation.mutationId);
2370
+ return reason === void 0 ? [] : [{
2371
+ clientId: mutation.clientId,
2372
+ mutationId: mutation.mutationId,
2373
+ reason
2374
+ }];
2375
+ });
2376
+ });
2377
+ const lastMutationIdsOf = (context) => readGroup(context.clientGroupId).pipe(Effect.map((group) => Object.fromEntries(group.acknowledged)));
2378
+ /** The group's remembered rejections, each with its reason (#365). A pull reports those its watermark covers. */
2379
+ const rememberedRejections = (context) => readGroup(context.clientGroupId).pipe(Effect.map((group) => [...group.rejections].flatMap(([clientId, ids]) => [...ids].map(([mutationId, reason]) => ({
2380
+ clientId,
2381
+ mutationId,
2382
+ reason
2383
+ })))));
2384
+ /**
2385
+ * The mutations a push still has to send, in order, each with its write. A
2386
+ * mutation at or below its client's acknowledged id was already applied, so it
2387
+ * is skipped (idempotent, acknowledged). With `strict`, a mutation past the next
2388
+ * expected id is a gap the push cannot apply, so the push is refused.
2389
+ */
2390
+ const unacknowledged = (context, mutations, strict) => Effect.gen(function* () {
2391
+ const group = yield* readGroup(context.clientGroupId);
2392
+ const expected = new Map(group.acknowledged);
2393
+ const batch = [];
2394
+ yield* Effect.forEach(mutations, (mutation) => {
2395
+ const last = expected.get(mutation.clientId) ?? 0;
2396
+ if (mutation.mutationId <= last) return Effect.void;
2397
+ if (strict && mutation.mutationId > last + 1) return Effect.fail(mutationGap(mutation.clientId, last + 1, mutation.mutationId));
2398
+ expected.set(mutation.clientId, mutation.mutationId);
2399
+ return Effect.map(writeFor(mutation), (write) => {
2400
+ batch.push({
2401
+ mutation,
2402
+ write
2403
+ });
2404
+ });
2405
+ }, {
2406
+ concurrency: 1,
2407
+ discard: true
2408
+ });
2409
+ return batch;
2410
+ });
2411
+ /**
2412
+ * The mutations a per-resource push still has to send, in order. A mutation
2413
+ * at or below its client's acknowledged id was already applied, so it is
2414
+ * skipped (idempotent, acknowledged). A mutation past the next expected id is a
2415
+ * gap, refused with a `BackendError` before any write of the push is sent, as
2416
+ * the atomic path refuses it (`unacknowledged`, strict). Unlike
2417
+ * `unacknowledged`, nothing is decoded here: the send loop decodes each write
2418
+ * in turn, so an undecodable mutation still fails at its own place in the push.
2419
+ */
2420
+ const pendingEach = (context, mutations) => Effect.gen(function* () {
2421
+ const expected = new Map((yield* readGroup(context.clientGroupId)).acknowledged);
2422
+ const pending = [];
2423
+ yield* Effect.forEach(mutations, (mutation) => {
2424
+ const last = expected.get(mutation.clientId) ?? 0;
2425
+ if (mutation.mutationId <= last) return Effect.void;
2426
+ if (mutation.mutationId > last + 1) return Effect.fail(mutationGap(mutation.clientId, last + 1, mutation.mutationId));
2427
+ expected.set(mutation.clientId, mutation.mutationId);
2428
+ pending.push(mutation);
2429
+ return Effect.void;
2430
+ }, {
2431
+ concurrency: 1,
2432
+ discard: true
2433
+ });
2434
+ return pending;
2435
+ });
2436
+ /**
2437
+ * Replay each write as its own request (at-least-once, in order). Before the
2438
+ * first request, each pending mutation that decodes is checked for a `getOne`
2439
+ * route, so a write that cannot be read sends nothing. A mutation whose args
2440
+ * do not decode is not checked: the loop fails the push at it, after the
2441
+ * writes before it, as it always has.
2442
+ */
2443
+ const applyEach = (context, mutations) => Effect.gen(function* () {
2444
+ const acc = {
2445
+ touched: /* @__PURE__ */ new Map(),
2446
+ puts: /* @__PURE__ */ new Map(),
2447
+ rejected: [],
2448
+ rows: /* @__PURE__ */ new Map()
2449
+ };
2450
+ const replays = yield* replayedRejections(context, mutations);
2451
+ const pending = yield* pendingEach(context, mutations);
2452
+ const decoded = yield* Effect.forEach(pending, (mutation) => Effect.gen(function* () {
2453
+ const write = yield* writeFor(mutation).pipe(Effect.option);
2454
+ if (Option.isSome(write)) yield* checkReadable(write.value);
2455
+ return {
2456
+ mutation,
2457
+ write
2458
+ };
2459
+ }));
2460
+ yield* Effect.forEach(decoded, ({ mutation, write: decodedWrite }) => Effect.gen(function* () {
2461
+ const write = Option.isSome(decodedWrite) ? decodedWrite.value : yield* writeFor(mutation);
2462
+ yield* enterRow(acc, write);
2463
+ const outcome = yield* applyOne(write).pipe(Effect.map((document) => ({
2464
+ _tag: "applied",
2465
+ document
2466
+ })), Effect.catchTag("MutationRejected", (error) => Effect.succeed({
2467
+ _tag: "rejected",
2468
+ reason: error.reason
2469
+ })));
2470
+ if (outcome._tag === "applied") {
2471
+ recordApplied(acc, write, outcome.document);
2472
+ yield* cacheApplied(acc, write);
2473
+ } else {
2474
+ const rejection = {
2475
+ clientId: mutation.clientId,
2476
+ mutationId: mutation.mutationId,
2477
+ reason: outcome.reason
2478
+ };
2479
+ acc.rejected.push(rejection);
2480
+ yield* rememberRejections(context, [rejection]);
2481
+ }
2482
+ yield* bumpLedger(context, [mutation]);
2483
+ }), {
2484
+ concurrency: 1,
2485
+ discard: true
2486
+ });
2487
+ const rejected = [...replays, ...acc.rejected];
2488
+ return {
2489
+ lastMutationIds: yield* lastMutationIdsOf(context),
2490
+ hints: Backend.buildChangeHints(acc.touched, acc.puts, oldsOf(acc)),
2491
+ ...Arr.isReadonlyArrayNonEmpty(rejected) ? { rejected } : {}
2492
+ };
2493
+ });
2494
+ /** Send one `atomic:operations` request and classify the server's answer. */
2495
+ const sendAtomic = (path, operations) => Effect.gen(function* () {
2496
+ const response = yield* Effect.scoped(client.execute(HttpClientRequest.post(urlFor(config.baseUrl, path)).pipe(HttpClientRequest.bodyText(JSON.stringify({ "atomic:operations": operations }), Atomic.MEDIA_TYPE), HttpClientRequest.setHeaders(atomicHeaders)))).pipe(Effect.mapError(backendError("JSON:API atomic:operations request failed")));
2497
+ const status = response.status;
2498
+ const message = `JSON:API atomic:operations returned HTTP ${status}`;
2499
+ const body = yield* response.json.pipe(Effect.orElseSucceed(() => void 0));
2500
+ if (status >= 200 && status < 300) {
2501
+ const results = P.isObjectOrArray(body) ? body["atomic:results"] : void 0;
2502
+ return {
2503
+ _tag: "applied",
2504
+ results: Array.isArray(results) ? results : []
2505
+ };
2506
+ }
2507
+ if (isPermanentStatus(status)) {
2508
+ const rejections = rejectionsOf(body, operations.length, message);
2509
+ if (Arr.isReadonlyArrayNonEmpty(rejections)) return {
2510
+ _tag: "rejected",
2511
+ rejections
2512
+ };
2513
+ }
2514
+ return yield* new BackendError({ message });
2515
+ });
2516
+ /**
2517
+ * Push the batch as one `atomic:operations` request. Nothing is
2518
+ * acknowledged until the server has applied the whole (remaining) batch;
2519
+ * permanently rejected operations are dropped and the rest resent.
2520
+ */
2521
+ const applyAtomic = (context, mutations, atomic) => Effect.gen(function* () {
2522
+ const replays = yield* replayedRejections(context, mutations);
2523
+ const batch = yield* unacknowledged(context, mutations, true);
2524
+ const acc = {
2525
+ touched: /* @__PURE__ */ new Map(),
2526
+ puts: /* @__PURE__ */ new Map(),
2527
+ rejected: [],
2528
+ rows: /* @__PURE__ */ new Map()
2529
+ };
2530
+ yield* Effect.forEach(batch, ({ write }) => enterBatchWrite(acc, write), {
2531
+ concurrency: 1,
2532
+ discard: true
2533
+ });
2534
+ const rejectedAt = /* @__PURE__ */ new Map();
2535
+ const round = (pending) => Effect.gen(function* () {
2536
+ if (Arr.isReadonlyArrayEmpty(pending)) return {
2537
+ pending,
2538
+ results: []
2539
+ };
2540
+ const outcome = yield* sendAtomic(atomic.path, pending.map((entry) => operationOf(entry.write)));
2541
+ if (outcome._tag === "applied") return {
2542
+ pending,
2543
+ results: outcome.results
2544
+ };
2545
+ const refused = outcome.rejections.flatMap(({ index }) => {
2546
+ const entry = pending[index];
2547
+ return entry === void 0 ? [] : [entry];
2548
+ });
2549
+ outcome.rejections.forEach(({ index, reason }) => {
2550
+ const entry = pending[index];
2551
+ if (entry !== void 0) rejectedAt.set(entry.index, reason);
2552
+ });
2553
+ const next = pending.filter((entry) => !rejectedAt.has(entry.index));
2554
+ yield* readRefusedCreates(acc, refused, next);
2555
+ return yield* round(next);
2556
+ });
2557
+ const { pending, results } = yield* round(batch.map((entry, index) => ({
2558
+ ...entry,
2559
+ index
2560
+ })));
2561
+ yield* Effect.forEach(pending, ({ write }, position) => {
2562
+ recordApplied(acc, write, resultRow(results[position]));
2563
+ return cacheApplied(acc, write);
2564
+ }, {
2565
+ concurrency: 1,
2566
+ discard: true
2567
+ });
2568
+ const rejected = [...rejectedAt].toSorted(([a], [b]) => a - b).flatMap(([index, reason]) => {
2569
+ const entry = batch[index];
2570
+ return entry === void 0 ? [] : [{
2571
+ clientId: entry.mutation.clientId,
2572
+ mutationId: entry.mutation.mutationId,
2573
+ reason
2574
+ }];
2575
+ });
2576
+ yield* rememberRejections(context, rejected);
2577
+ yield* bumpLedger(context, batch.map(({ mutation }) => mutation));
2578
+ const reported = [...replays, ...rejected];
2579
+ return {
2580
+ lastMutationIds: yield* lastMutationIdsOf(context),
2581
+ hints: Backend.buildChangeHints(acc.touched, acc.puts, oldsOf(acc)),
2582
+ ...Arr.isReadonlyArrayNonEmpty(reported) ? { rejected: reported } : {}
2583
+ };
2584
+ });
2585
+ const applyMutations = (context, mutations) => Effect.gen(function* () {
2586
+ yield* forgetRejections(context, mutations);
2587
+ return config.atomic === void 0 ? yield* applyEach(context, mutations) : yield* applyAtomic(context, mutations, config.atomic);
2588
+ });
2589
+ return Backend.Backend.of({
2590
+ applyMutations,
2591
+ lastMutationIds: lastMutationIdsOf,
2592
+ rejections: rememberedRejections,
2593
+ evaluate,
2594
+ getRows
2595
+ });
2596
+ });
2597
+ // src/FacadeWrites.ts
2598
+ /**
2599
+ * FacadeWrites
2600
+ *
2601
+ * The pieces of a façade write (ADR-0030) that do not depend on the fetch
2602
+ * handler: reading the `Prefer` header (RFC 7240), decoding a write's body
2603
+ * **structurally** into a `JsonApiBackend.Write`, and the default **intent
2604
+ * mapping** from a `Write` to the graph-derived CRUD mutation the engine
2605
+ * journals (`CrudMutation`, graph row form). `JsonApiFacade` composes them.
2606
+ *
2607
+ * Structural means exactly ADR-0030 §5's list: `data` is an object whose
2608
+ * `type` is the route's, whose `id` (when present) is the path's for a
2609
+ * `PATCH`, whose `attributes` / `relationships` are objects when present, and
2610
+ * whose relationships are `one` edges of the node with `{ type, id }` or
2611
+ * `null` linkage. Attribute values are never checked: the server rules on
2612
+ * them, and a value it refuses surfaces as a permanent rejection.
2613
+ */
2614
+ var FacadeWrites_exports = /* @__PURE__ */ __exportAll({
2615
+ InvalidWriteArgs: () => InvalidWriteArgs,
2616
+ decodeWrite: () => decodeWrite,
2617
+ graphIntentOf: () => graphIntentOf,
2618
+ preferencesOf: () => preferencesOf,
2619
+ writesOf: () => writesOf
2620
+ });
2621
+ /** Read the `Prefer` headers of a request (several headers and comma-separated lists alike). */
2622
+ const preferencesOf = (headers) => {
2623
+ const pairs = Arr.map((headers.get("prefer") ?? "").split(","), (part) => {
2624
+ const [token = ""] = part.split(";");
2625
+ const [rawName = "", ...rest] = token.split("=");
2626
+ const value = rest.join("=").trim().replace(/^"(.*)"$/, "$1").toLowerCase();
2627
+ return [rawName.trim().toLowerCase(), value];
2628
+ });
2629
+ const has = (name, value) => Arr.some(pairs, ([n, v]) => n === name && (value === void 0 || v === value));
2630
+ const wait = Arr.findLast(pairs, ([n, v]) => n === "wait" && /^\d+$/.test(v));
2631
+ const preferences = {
2632
+ respondAsync: has("respond-async"),
2633
+ strict: has("handling", "strict")
2634
+ };
2635
+ return Option.match(wait, {
2636
+ onNone: () => preferences,
2637
+ onSome: ([, v]) => ({
2638
+ ...preferences,
2639
+ wait: Number(v)
2640
+ })
2641
+ });
2642
+ };
2643
+ const refuse = (refusal) => Effect.fail(refusal);
2644
+ const invalid = (detail, pointer) => refuse({
2645
+ status: 400,
2646
+ code: "invalid_document",
2647
+ title: "Invalid document",
2648
+ detail,
2649
+ pointer
2650
+ });
2651
+ const isRecord = (value) => P.isObject(value) && !Array.isArray(value);
2652
+ const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
2653
+ /** The linkage of one relationship: a related id, `null`, or a refusal. */
2654
+ const linkageOf = (name, member, node) => {
2655
+ const pointer = `/data/relationships/${name}`;
2656
+ const edge = node.edges[name];
2657
+ if (edge === void 0 || edge.kind !== "one") return refuse({
2658
+ status: 422,
2659
+ code: "unsupported_relationship",
2660
+ title: "Unsupported relationship",
2661
+ detail: edge === void 0 ? `"${name}" is not a relationship of ${node.type}` : `"${name}" is a to-many relationship; its linkage lives on the related resources, not on ${node.type}`,
2662
+ pointer
2663
+ });
2664
+ if (!isRecord(member) || !("data" in member)) return invalid(`relationship "${name}" must be an object with a "data" member`, pointer);
2665
+ const data = member["data"];
2666
+ if (data === null) return Effect.succeed(null);
2667
+ if (!isRecord(data) || !P.isString(data["id"]) || data["type"] !== edge.target) return invalid(`relationship "${name}" must link one "${edge.target}" by type and id, or be null`, `${pointer}/data`);
2668
+ return Effect.succeed(data["id"]);
2669
+ };
2670
+ /** The body's `data` member as an object, or the refusal for a body that has none. */
2671
+ const dataOf = (body) => Effect.gen(function* () {
2672
+ const text = yield* body.pipe(Effect.catch(() => invalid("the request body could not be read", "")));
2673
+ const document = yield* decodeJson(text).pipe(Effect.catch(() => invalid("the request body is not JSON", "")));
2674
+ if (!isRecord(document) || !("data" in document)) return yield* invalid("a write document needs a \"data\" member", "");
2675
+ const data = document["data"];
2676
+ return isRecord(data) ? data : yield* invalid("\"data\" must be a resource object", "/data");
2677
+ });
2678
+ const clientIdRequired = {
2679
+ status: 503,
2680
+ code: "client_id_required",
2681
+ title: "Client id required",
2682
+ detail: "A create the façade journals offline needs a client-generated id in data.id.",
2683
+ pointer: "/data/id"
2684
+ };
2685
+ /** The resource id a create or an update addresses, checked against the route's type and path. */
2686
+ const idOf = (target, data) => {
2687
+ if (data["type"] !== target.type) return refuse({
2688
+ status: 409,
2689
+ code: "type_mismatch",
2690
+ title: "Type mismatch",
2691
+ detail: `this endpoint writes "${target.type}" resources`,
2692
+ pointer: "/data/type"
2693
+ });
2694
+ const id = data["id"];
2695
+ if (id !== void 0 && !P.isString(id)) return invalid("\"data.id\" must be a string", "/data/id");
2696
+ if (target.operation === "create") return id === void 0 || id === "" ? refuse(clientIdRequired) : Effect.succeed(id);
2697
+ if (id === void 0) return invalid("an update's data needs the id the path names", "/data/id");
2698
+ return id === target.id ? Effect.succeed(id) : refuse({
2699
+ status: 409,
2700
+ code: "id_mismatch",
2701
+ title: "Id mismatch",
2702
+ detail: `"data.id" must be the id the path names ("${target.id}")`,
2703
+ pointer: "/data/id"
2704
+ });
2705
+ };
2706
+ /** An optional object member of `data` (`attributes`, `relationships`): `{}` when absent. */
2707
+ const memberOf = (data, name) => {
2708
+ const member = data[name] ?? {};
2709
+ return isRecord(member) ? Effect.succeed(member) : invalid(`"data.${name}" must be an object`, `/data/${name}`);
2710
+ };
2711
+ /**
2712
+ * Decode a write's body structurally into a `Write` whose `relationships`
2713
+ * are flattened into `one`-edge ids by relationship name (`null` clears).
2714
+ * A `DELETE` reads no body. Fails with the {@link Refusal} the offline façade
2715
+ * answers with; a create without a client id is refused only once the rest
2716
+ * of its body has passed.
2717
+ */
2718
+ const decodeWrite = (node, target, body) => {
2719
+ if (target.operation === "delete") return Effect.succeed({
2720
+ operation: "delete",
2721
+ type: target.type,
2722
+ id: target.id
2723
+ });
2724
+ return Effect.gen(function* () {
2725
+ const data = yield* dataOf(body);
2726
+ const checked = yield* Effect.result(idOf(target, data));
2727
+ if (Result.isFailure(checked) && checked.failure.code !== "client_id_required") return yield* refuse(checked.failure);
2728
+ const attributes = yield* memberOf(data, "attributes");
2729
+ const relationships = yield* memberOf(data, "relationships");
2730
+ const linkage = yield* Effect.forEach(R.toEntries(relationships), ([name, member]) => Effect.map(linkageOf(name, member, node), (related) => [name, related]), { concurrency: 1 });
2731
+ return {
2732
+ operation: target.operation,
2733
+ type: target.type,
2734
+ id: yield* Result.match(checked, {
2735
+ onFailure: refuse,
2736
+ onSuccess: Effect.succeed
2737
+ }),
2738
+ attributes,
2739
+ relationships: R.fromEntries(linkage)
2740
+ };
2741
+ });
2742
+ };
2743
+ /**
2744
+ * The default intent mapping: the graph-derived CRUD mutations
2745
+ * (`CrudMutation`), args in the graph row form — the attributes plus, per
2746
+ * `one` edge the payload links, its `via` key.
2747
+ */
2748
+ const graphIntentOf = (nodes) => (write) => {
2749
+ const node = nodes.nodes.get(write.type);
2750
+ if (node === void 0) return void 0;
2751
+ const mutator = CrudMutation.name(write.type, write.operation);
2752
+ if (write.operation === "delete") return {
2753
+ mutator,
2754
+ args: {
2755
+ type: write.type,
2756
+ id: write.id
2757
+ }
2758
+ };
2759
+ const linked = Arr.map(R.toEntries(write.relationships ?? {}), ([name, related]) => {
2760
+ const edge = node.edges[name];
2761
+ return edge?.kind === "one" ? Option.some([edge.via, related]) : Option.none();
2762
+ });
2763
+ const vias = Option.all(linked);
2764
+ if (Option.isNone(vias)) return void 0;
2765
+ const attributes = {
2766
+ ...write.attributes,
2767
+ ...R.fromEntries(vias.value)
2768
+ };
2769
+ return {
2770
+ mutator,
2771
+ args: {
2772
+ type: write.type,
2773
+ id: write.id,
2774
+ attributes
2775
+ }
2776
+ };
2777
+ };
2778
+ const decodeWriteArgs = Schema.decodeUnknownSync(CrudMutation.WriteArgs);
2779
+ const decodeDeleteArgs = Schema.decodeUnknownSync(CrudMutation.DeleteArgs);
2780
+ /**
2781
+ * One graph-row write as a `JsonApiBackend` write: each `one` edge's `via` key
2782
+ * moves out of the attributes into `relationships[edge].data` (`null` linkage
2783
+ * when the key is `null`). A key the args do not carry leaves the relationship
2784
+ * out, as a payload that did not link it.
2785
+ */
2786
+ const writeOf = (node, operation, args) => {
2787
+ const oneEdges = R.toEntries(node.edges).flatMap(([name, edge]) => edge.kind === "one" ? [[name, edge]] : []);
2788
+ const vias = HashSet.fromIterable(oneEdges.map(([, edge]) => edge.via));
2789
+ const relationships = R.fromEntries(oneEdges.flatMap(([name, edge]) => Object.hasOwn(args.attributes, edge.via) ? [[name, { data: P.isString(args.attributes[edge.via]) ? {
2790
+ type: edge.target,
2791
+ id: args.attributes[edge.via]
2792
+ } : null }]] : []));
2793
+ return {
2794
+ operation,
2795
+ type: node.type,
2796
+ id: args.id,
2797
+ attributes: R.filter(args.attributes, (_, field) => !HashSet.has(vias, field)),
2798
+ relationships
2799
+ };
2800
+ };
2801
+ /**
2802
+ * Refuse args whose `type` is not the mutation's own (#529): a `notes/create`
2803
+ * naming `secrets` would otherwise write a type the mutation's name, which the
2804
+ * server authorised, never covered. `JsonApiBackend` reports the refusal as
2805
+ * invalid args, as it does args that do not decode.
2806
+ */
2807
+ const sameType = (type, mutator) => (args) => {
2808
+ if (args.type !== type) throw new InvalidWriteArgs({ message: `"${mutator}" writes "${type}", but its args name "${args.type}"` });
2809
+ return args;
2810
+ };
2811
+ /** Graph-derived CRUD args that do not fit the mutation they were journaled under. */
2812
+ var InvalidWriteArgs = class extends Schema.TaggedError()("InvalidWriteArgs", { message: Schema.String }) {};
2813
+ /**
2814
+ * The inverse of {@link graphIntentOf} (ADR-0030 §5): for every graph-derived
2815
+ * CRUD mutation (`CrudMutation`, filtered by `options.writes` as the registry
2816
+ * is), how its args replay as one JSON:API write. Pass it as a
2817
+ * `JsonApiBackend`'s `writes`, so a node-graph app's journaled `{type}/create`,
2818
+ * `{type}/update` and `{type}/delete` reach the server as `POST`, `PATCH` and
2819
+ * `DELETE` (or atomic operations), with each `one` edge's `via` key rebuilt as
2820
+ * the relationship's linkage. The write's `type` is always the mutation's own:
2821
+ * args naming another type are refused ({@link InvalidWriteArgs}).
2822
+ */
2823
+ const writesOf = (nodes, options) => new Map(CrudMutation.entries(nodes, options).flatMap((entry) => {
2824
+ const node = nodes.nodes.get(entry.type);
2825
+ if (node === void 0) return [];
2826
+ const name = entry.mutator.name;
2827
+ const operation = entry.operation;
2828
+ return [[name, operation === "delete" ? flow(decodeDeleteArgs, sameType(entry.type, name), (args) => ({
2829
+ operation,
2830
+ type: entry.type,
2831
+ id: args.id
2832
+ })) : flow(decodeWriteArgs, sameType(entry.type, name), (args) => writeOf(node, operation, args))]];
2833
+ }));
2834
+ // src/MutationStatus.ts
2835
+ var MutationStatus_exports = /* @__PURE__ */ __exportAll({
2836
+ current: () => current,
2837
+ defaultMaxWait: () => defaultMaxWait,
2838
+ document: () => document,
2839
+ parseId: () => parseId,
2840
+ resourceType: () => resourceType,
2841
+ segment: () => segment,
2842
+ settled: () => settled,
2843
+ unknownDocument: () => unknownDocument
2844
+ });
2845
+ /** The JSON:API type of a status resource; `_legation` and this type are never an app's. */
2846
+ const resourceType = "legation-mutations";
2847
+ /** The path segments of the status route after the api prefix: `/_legation/mutations/{id}`. */
2848
+ const segment = "_legation";
2849
+ /** The default cap on `Prefer: wait`: a `respondWith` promise must not outlive the worker's patience. */
2850
+ const defaultMaxWait = "30 seconds";
2851
+ /**
2852
+ * The link to the resource a graph-derived CRUD mutation (`{type}/create|update|delete`,
2853
+ * args `{ type, id, … }`) addresses: `{prefix}/{type}/{id}`. `undefined` for any other mutation.
2854
+ */
2855
+ const resourceLink = (nodes, prefix, state) => {
2856
+ const parts = state.mutator.split("/");
2857
+ const [type = "", operation = ""] = parts;
2858
+ if (parts.length !== 2 || !nodes.nodes.has(type)) return void 0;
2859
+ if (operation !== "create" && operation !== "update" && operation !== "delete") return void 0;
2860
+ const args = state.args;
2861
+ if (!P.isObject(args) || !("id" in args) || !P.isString(args.id)) return void 0;
2862
+ return `${prefix}/${type}/${encodeURIComponent(args.id)}`;
2863
+ };
2864
+ /** The status document of a tracked mutation; `prefix` is where the api is mounted, as links see it. */
2865
+ const document = (nodes, prefix, state) => {
2866
+ const self = `${prefix}/${segment}/mutations/${state.mutationId}`;
2867
+ const resource = resourceLink(nodes, prefix, state);
2868
+ const base = {
2869
+ phase: state.phase,
2870
+ mutator: state.mutator,
2871
+ journaledAt: state.journaledAt
2872
+ };
2873
+ const settledAt = state.settledAt;
2874
+ const timed = settledAt === void 0 ? base : {
2875
+ ...base,
2876
+ settledAt
2877
+ };
2878
+ const reason = state.phase === "rejected" ? state.reason : void 0;
2879
+ return { data: {
2880
+ type: resourceType,
2881
+ id: String(state.mutationId),
2882
+ attributes: reason === void 0 ? timed : {
2883
+ ...timed,
2884
+ reason
2885
+ },
2886
+ links: resource === void 0 ? { self } : {
2887
+ self,
2888
+ resource
2889
+ }
2890
+ } };
2891
+ };
2892
+ /** The `404 unknown_mutation` error document. */
2893
+ const unknownDocument = (id) => ({ errors: [{
2894
+ status: "404",
2895
+ code: "unknown_mutation",
2896
+ title: "Unknown mutation",
2897
+ detail: `No mutation ${id} is tracked here. It was never journaled by this worker, or it settled and was evicted beyond the retention window, or it settled before this worker started; the three cannot be told apart.`
2898
+ }] });
2899
+ /** A mutation id as the route carries it: a positive integer, or none. */
2900
+ const parseId = (raw) => /^[1-9]\d*$/.test(raw) ? Option.some(Number(raw)) : Option.none();
2901
+ /** One mutation's state now, from the engine's map stream (it emits the current map first). */
2902
+ const current = (states, mutationId) => Stream.runHead(states).pipe(Effect.map(Option.flatMap((map) => HashMap.get(map, mutationId))));
2903
+ /**
2904
+ * The mutation's state once it leaves `pending` (or is no longer tracked),
2905
+ * or as it is when `wait` elapses first.
2906
+ */
2907
+ const settled = (states, mutationId, wait) => states.pipe(Stream.map((map) => HashMap.get(map, mutationId)), Stream.filter((state) => Option.isNone(state) || state.value.phase !== "pending"), Stream.runHead, Effect.map(Option.flatten), Effect.timeoutOption(wait), Effect.flatMap(Option.match({
2908
+ onNone: () => current(states, mutationId),
2909
+ onSome: Effect.succeed
2910
+ })));
2911
+ // src/Linkage.ts
2912
+ /**
2913
+ * Linkage
2914
+ *
2915
+ * What a document's relationships are resolved from beside the URL's
2916
+ * includes — shared by the derived REST handlers ({@link RestHandlers}) and
2917
+ * the replica-side writer ({@link ReplicaDocuments}), so both sides resolve
2918
+ * the same edges for the same declarations and the one serialiser
2919
+ * ({@link DocumentWriter}) writes the same bytes (ADR-0029 §6).
2920
+ *
2921
+ * **Linkage follows the resource's declaration.** effect-jsonapi's resource
2922
+ * schemas require every declared relationship on every resource object, so
2923
+ * a read resolves, beside its includes, the rows of every `Relationship.many`
2924
+ * the resource declares — on the primary resource, and on each included
2925
+ * resource's own declaration — and writes them as linkage, not `included`.
2926
+ * A `one` relationship's linkage is the edge's key field and costs nothing.
2927
+ * The primary resource's `many` relationships ride in the read's second
2928
+ * phase (they are selected beside its includes, {@link withLinkage}); the
2929
+ * included resources' ride in one more batch keyed by the included rows
2930
+ * ({@link resolve}). Nothing the resource does not declare is resolved, so a
2931
+ * collection the URL never named is never touched — neither for its cost
2932
+ * nor for its policy verdict. A paginated node edge is served under either
2933
+ * declaration: as `Relationship.many` it carries the spec's `links` and its
2934
+ * rows' linkage (the edge's declared order) and can be `include`d; as
2935
+ * `Relationship.paginated` it carries `links` only.
2936
+ *
2937
+ * A declaration the node graph cannot serve (a relationship that is no
2938
+ * edge, or of the other kind, or targeting another type) is a
2939
+ * definition-time defect ({@link planOf} throws).
2940
+ *
2941
+ * The rows come from a {@link Fetch}: the server's authorized batch
2942
+ * evaluation on the REST side (`SyncServer.readSubsets`), the replica's
2943
+ * local view on the worker side. One plan, one join, whichever source
2944
+ * answers (ADR-0025).
2945
+ *
2946
+ * Package-internal: the two callers import it directly; nothing outside
2947
+ * `@thomasfosterau/legation-jsonapi` derives linkage.
2948
+ */
2949
+ const declared = (resource, name) => `Linkage: relationship "${name}" of resource "${resource.type}"`;
2950
+ const descriptorsOf$1 = (resource) => Object.entries(resource.relationships);
2951
+ /** One declared relationship checked against the node's edge of that name: same name, same arity, same target. */
2952
+ const checkDeclaration = (resource, node, name, descriptor) => {
2953
+ const edge = node.edges[name];
2954
+ if (edge === void 0) throw new Error(`${declared(resource, name)} is not an edge of the "${node.type}" node`);
2955
+ if ((descriptor.kind === "many" || descriptor.kind === "paginated") === (edge.kind === "one")) throw new Error(`${declared(resource, name)} is "${descriptor.kind}" but the node edge is "${edge.kind}"`);
2956
+ if (descriptor.ref().type !== edge.target) throw new Error(`${declared(resource, name)} targets "${descriptor.ref().type}" but the node edge targets "${edge.target}"`);
2957
+ };
2958
+ /**
2959
+ * The resource's relationships as node edges, each checked against the
2960
+ * node's edge of that name. Returns the `Relationship.many` names — the
2961
+ * edges resolved for linkage.
2962
+ */
2963
+ const manyEdgesOf = (resource, node) => {
2964
+ const descriptors = descriptorsOf$1(resource);
2965
+ descriptors.forEach(([name, descriptor]) => checkDeclaration(resource, node, name, descriptor));
2966
+ return descriptors.flatMap(([name, descriptor]) => descriptor.kind === "many" ? [name] : []);
2967
+ };
2968
+ /** The linkage plan of a resource over its node (a definition-time check of the whole declaration). */
2969
+ const planOf = (nodes, resource, node) => {
2970
+ return {
2971
+ root: manyEdgesOf(resource, node),
2972
+ included: new Map(descriptorsOf$1(resource).flatMap(([name, descriptor]) => {
2973
+ if (descriptor.kind === "paginated") return [];
2974
+ const target = descriptor.ref();
2975
+ const targetNode = nodes.nodes.get(target.type);
2976
+ return targetNode === void 0 ? [] : [[name, manyEdgesOf(target, targetNode)]];
2977
+ }))
2978
+ };
2979
+ };
2980
+ /** The read to run: the URL's read with the root linkage edges selected beside its includes. */
2981
+ const withLinkage = (plan, read) => {
2982
+ const include = {
2983
+ ...R.fromIterableWith(plan.root, (name) => [name, true]),
2984
+ ...read.include
2985
+ };
2986
+ return R.isEmptyReadonlyRecord(include) ? read : {
2987
+ ...read,
2988
+ include
2989
+ };
2990
+ };
2991
+ /**
2992
+ * The collections the declared linkage is resolved from beyond the read's
2993
+ * own includes: the targets of the root's declared `many` edges the read
2994
+ * does not include, and of every declared `many` edge of each included
2995
+ * type. A replica that holds each of these whole answers the linkage
2996
+ * exactly as the server does — the coverage requirement
2997
+ * `ReplicaDocuments.toCover` states.
2998
+ */
2999
+ const collectionsOf = (nodes, node, plan, read) => {
3000
+ const included = read.include ?? {};
3001
+ const roots = plan.root.flatMap((name) => {
3002
+ const edge = node.edges[name];
3003
+ return included[name] === void 0 && edge !== void 0 ? [edge.target] : [];
3004
+ });
3005
+ const nested = Object.keys(included).flatMap((name) => includedTargets(nodes, node, plan, name));
3006
+ return [.../* @__PURE__ */ new Set([...roots, ...nested])];
3007
+ };
3008
+ /** The targets of the declared `many` edges of included edge `name`'s type. */
3009
+ const includedTargets = (nodes, node, plan, name) => {
3010
+ const via = node.edges[name];
3011
+ const target = via === void 0 ? void 0 : nodes.nodes.get(via.target);
3012
+ if (target === void 0) return [];
3013
+ return (plan.included.get(name) ?? []).flatMap((edge) => {
3014
+ const next = target.edges[edge];
3015
+ return next === void 0 ? [] : [next.target];
3016
+ });
3017
+ };
3018
+ /** The ids of the rows one included edge resolved across the root rows. */
3019
+ const includedIds = (rows, name) => {
3020
+ return new Set(rows.flatMap((row) => relatedValuesOf(row.related[name]).map(idOf$1)));
3021
+ };
3022
+ /** Add an included type's `many` edge to the batch for a set of parents, merging into the entry the batch already has. */
3023
+ const addLinkageQuery = (nodes, queries, type, edge, parents) => {
3024
+ const key = `${type}\0${edge}`;
3025
+ const existing = queries.get(key);
3026
+ if (existing !== void 0) {
3027
+ parents.forEach((id) => existing.parents.add(id));
3028
+ return;
3029
+ }
3030
+ const relationship = Collection.relationshipsOf(nodes.collections, type).get(edge);
3031
+ if (relationship === void 0) return;
3032
+ queries.set(key, {
3033
+ type,
3034
+ edge,
3035
+ collection: relationship.collection,
3036
+ field: relationship.foreignField,
3037
+ order: relationship.order ?? [],
3038
+ parents: new Set(parents)
3039
+ });
3040
+ };
3041
+ /** The included-linkage batch a read needs: one subset per (included type, `many` edge), parents merged. */
3042
+ const linkageQueries = (nodes, plan, node, read, rows) => {
3043
+ const queries = /* @__PURE__ */ new Map();
3044
+ Object.keys(read.include ?? {}).forEach((name) => {
3045
+ const edges = plan.included.get(name) ?? [];
3046
+ const parents = includedIds(rows, name);
3047
+ if (Arr.isReadonlyArrayEmpty(edges) || parents.size === 0) return;
3048
+ const type = node.edges[name]?.target;
3049
+ if (type === void 0) return;
3050
+ edges.forEach((edge) => addLinkageQuery(nodes, queries, type, edge, parents));
3051
+ });
3052
+ return [...queries.values()];
3053
+ };
3054
+ /** The fetched rows of the batch, assigned to their parents: every parent gets every edge, empty or not. */
3055
+ const assignLinkage = (queries, fetched) => {
3056
+ const linkage = /* @__PURE__ */ new Map();
3057
+ const bucket = (type, parent, edge) => {
3058
+ const byId = linkage.get(type) ?? /* @__PURE__ */ new Map();
3059
+ linkage.set(type, byId);
3060
+ const edges = byId.get(parent) ?? {};
3061
+ byId.set(parent, edges);
3062
+ return edges[edge] ??= [];
3063
+ };
3064
+ queries.forEach((query) => {
3065
+ query.parents.forEach((parent) => bucket(query.type, parent, query.edge));
3066
+ fetched.forEach((row) => {
3067
+ if (row.collection !== query.collection) return;
3068
+ const parent = String(row.value[query.field]);
3069
+ if (query.parents.has(parent)) bucket(query.type, parent, query.edge).push(row.value);
3070
+ });
3071
+ });
3072
+ return linkage;
3073
+ };
3074
+ /**
3075
+ * Run a read over a row source with the linkage its resource declares:
3076
+ * the read's two phases (`Query.runVia`, the root linkage edges selected
3077
+ * beside the includes), then the included resources' `many` edges in one
3078
+ * more batch, keyed by the included rows. The rows and the linkage are
3079
+ * exactly what {@link DocumentWriter} takes.
3080
+ */
3081
+ const resolve$1 = (fetchRows, nodes, node, plan, read) => Effect.gen(function* () {
3082
+ const rows = yield* Query$1.runVia(fetchRows, nodes, withLinkage(plan, read));
3083
+ const queries = linkageQueries(nodes, plan, node, read, rows);
3084
+ const fetched = Arr.isReadonlyArrayEmpty(queries) ? [] : yield* fetchRows(queries.map((query) => Subset.make({
3085
+ collection: query.collection,
3086
+ where: Predicate.isIn(query.field, [...query.parents]),
3087
+ orderBy: query.order
3088
+ })));
3089
+ return {
3090
+ rows,
3091
+ linkage: assignLinkage(queries, fetched)
3092
+ };
3093
+ });
3094
+ // src/ReplicaDocuments.ts
3095
+ /**
3096
+ * ReplicaDocuments
3097
+ *
3098
+ * JSON:API documents written from the **replica** (issue #164): the
3099
+ * worker-side caller of {@link DocumentWriter}, the counterpart of the
3100
+ * derived REST handlers ({@link RestHandlers}) that write the same
3101
+ * documents from the backend. Given the node graph and a local view, a URL
3102
+ * (or a parsed read with its `fields[]` projection) runs as a read over the
3103
+ * rows the replica holds — `Query.runVia` over the view, the two-phase
3104
+ * include of ADR-0025 with the plan's edge windows applied — and its rows
3105
+ * are serialised by the one serialiser, so the same URL yields the same
3106
+ * document content on either side (ADR-0029 §6). The document sees the
3107
+ * optimistic overlay exactly as `Client.query` does (ADR-0029 §4); it
3108
+ * depends on nothing but `effect` and the two legation packages, so it runs
3109
+ * where the engine runs — a service worker included.
3110
+ *
3111
+ * **How the replica learns which relationships to emit.** The REST tier
3112
+ * derives its linkage from the endpoint's effect-jsonapi resource: every
3113
+ * `Relationship.many` the resource declares is resolved beside the URL's
3114
+ * includes and written as linkage ({@link Linkage}). The replica takes the
3115
+ * same input — a resource declaration per type ({@link Options.resources})
3116
+ * — and derives its plan through the same function, so for the same
3117
+ * declarations both sides resolve the same edges and write the same bytes
3118
+ * by construction; the façade (#166) is built from the app's `HttpApi` and
3119
+ * has these declarations at hand. An app that has only the node graph
3120
+ * passes nothing and gets `JsonApiQuery.resourcesOf(nodes)`: the synthetic
3121
+ * resources whose relationships are the node's edges — every collection
3122
+ * edge (paginated ones as `many`) resolved for linkage — which a REST
3123
+ * handler built over the same synthetic resources reproduces. A resource
3124
+ * the node graph cannot serve is a definition-time defect here as there.
3125
+ *
3126
+ * **One grammar on both sides.** A URL is parsed with the node-derived
3127
+ * codec (`JsonApiQuery.parseRead`), so parity holds when the endpoint is
3128
+ * declared with the same codec — `JsonApiQuery.querySchema(nodes,
3129
+ * collection)`, or declarations equivalent to it: a filter names the edge's
3130
+ * key field (`filter[categoryId]`), `sort` admits the node's sortable set
3131
+ * and `id`, and collection edges are `many` (a `paginated` declaration
3132
+ * refuses `include=` the node grammar accepts). Where the bound resource
3133
+ * declares less than the node — an edge it leaves out, a type it never
3134
+ * reaches — the endpoint cannot emit what the URL asks for, so the replica
3135
+ * refuses it the same way the codec would: an `include` edge or a
3136
+ * `fields[type]` type the resource does not declare is a `QueryError`
3137
+ * naming the parameter, raised before the read runs.
3138
+ *
3139
+ * **What the replica must hold.** The writer resolves everything from the
3140
+ * local view and cannot tell a missing row from an absent one, so a
3141
+ * document is faithful only when the replica holds every row the read and
3142
+ * its linkage need. That is the caller's coverage check (ADR-0029 §3,
3143
+ * `Client.coverageOf`): {@link ReplicaDocuments.toCover} names the reads a
3144
+ * request needs covered — the URL's read as stated, plus one
3145
+ * whole-collection read per collection the declared linkage resolves from
3146
+ * beyond the read's includes (the root's declared `many` edges the URL did
3147
+ * not include, and every declared `many` edge of each included type). A
3148
+ * replica holding each of those whole (under the same auth filter the
3149
+ * server applies) answers the linkage exactly as the server does. The
3150
+ * requirement is sound and conservative: a pin that holds an edge window
3151
+ * without the whole collection is not credited, and a read that is
3152
+ * uncovered goes to the network unchanged (ADR-0029 §4). The façade serves
3153
+ * a URL only when every read `toCover` lists is covered; this module never
3154
+ * decides that itself.
3155
+ *
3156
+ * A URL is network input, so nothing about it is a defect: a collection no
3157
+ * bound resource declares fails every entry point with a `QueryError`, and
3158
+ * the façade routes the request to the network.
3159
+ */
3160
+ var ReplicaDocuments_exports = /* @__PURE__ */ __exportAll({
3161
+ fetchOf: () => fetchOf,
3162
+ make: () => make$1
3163
+ });
3164
+ /**
3165
+ * The local view as a row source: each subset evaluated over the replica
3166
+ * (confirmed rows with the optimistic overlay), rows tagged with their
3167
+ * collection, batches concatenated in subset order — the shape
3168
+ * `SyncServer.readSubsets` gives the REST tier. The subsets of a batch are
3169
+ * independent, so they run concurrently. A store failure is a defect, as
3170
+ * `Query.run` treats it.
3171
+ */
3172
+ const fetchOf = (view) => (subsets) => Effect.forEach(subsets, (subset) => view.query(subset).pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => ({
3173
+ collection: subset.collection,
3174
+ id: row.id,
3175
+ value: row.value
3176
+ })))), { concurrency: "unbounded" }).pipe(Effect.map((batches) => batches.flat()));
3177
+ /** Every resource bound to its node (a definition-time check of each declaration). */
3178
+ const bindAll = (nodes, resources) => {
3179
+ const bindings = /* @__PURE__ */ new Map();
3180
+ resources.forEach((resource) => {
3181
+ const node = nodes.nodes.get(resource.type);
3182
+ if (node === void 0) throw new Error(`ReplicaDocuments: no node "${resource.type}" in the graph for the resource`);
3183
+ bindings.set(resource.type, {
3184
+ node,
3185
+ resource,
3186
+ plan: planOf(nodes, resource, node)
3187
+ });
3188
+ });
3189
+ return bindings;
3190
+ };
3191
+ const descriptorsOf = (resource) => resource.relationships;
3192
+ /** The types a resource's document can carry: its own and its relationships' targets. */
3193
+ const carriedTypes = (resource) => [resource.type, ...Object.values(descriptorsOf(resource)).map((descriptor) => descriptor.ref().type)];
3194
+ /**
3195
+ * The rejection of what the bound resource does not declare, in the codec's
3196
+ * shape: an `include` edge that is no relationship of the resource, or a
3197
+ * `fields[type]` type its document cannot carry. Checked before the read
3198
+ * runs, so a refused request costs no query.
3199
+ */
3200
+ const undeclared = (binding, parsed) => {
3201
+ const { resource } = binding;
3202
+ const relationships = descriptorsOf(resource);
3203
+ const name = Object.keys(parsed.read.include ?? {}).find((included) => relationships[included] === void 0);
3204
+ if (name !== void 0) return invalidParameter(resource.type, "include", `"${name}" is not a relationship the "${resource.type}" resource declares (relationships: ${Object.keys(relationships).join(", ")})`);
3205
+ const types = carriedTypes(resource);
3206
+ const type = Object.keys(parsed.fields ?? {}).find((projected) => !types.includes(projected));
3207
+ if (type !== void 0) return invalidParameter(resource.type, `fields[${type}]`, `"${type}" is not a type the "${resource.type}" resource can carry (types: ${types.join(", ")})`);
3208
+ };
3209
+ /** Bind the replica-side writer to a graph and its declarations (a definition-time check of every declaration). */
3210
+ const make$1 = (options) => {
3211
+ const { nodes, prefix } = options;
3212
+ const bindings = bindAll(nodes, options.resources ?? resourcesOf(nodes));
3213
+ /** The binding of a read's collection; a type nothing declares is the URL's failure, not a defect. */
3214
+ const bound = (read) => {
3215
+ const binding = bindings.get(read.collection);
3216
+ return binding === void 0 ? Effect.fail(new QueryError({
3217
+ message: `No resource declares "${read.collection}"; the replica cannot serve it`,
3218
+ query: read.collection
3219
+ })) : Effect.succeed(binding);
3220
+ };
3221
+ /** The binding of a parsed read, with what the resource does not declare refused. */
3222
+ const checked = (parsed) => Effect.flatMap(bound(parsed.read), (binding) => {
3223
+ const error = undeclared(binding, parsed);
3224
+ return error === void 0 ? Effect.succeed(binding) : Effect.fail(error);
3225
+ });
3226
+ /** The writer's input for a parsed read's rows off the view. */
3227
+ const input = (view, parsed) => Effect.flatMap(checked(parsed), ({ node, plan }) => resolve$1(fetchOf(view), nodes, node, plan, parsed.read).pipe(Effect.map((resolved) => ({
3228
+ nodes,
3229
+ read: parsed.read,
3230
+ rows: resolved.rows,
3231
+ linkage: resolved.linkage,
3232
+ ...parsed.fields === void 0 ? {} : { fields: parsed.fields },
3233
+ ...prefix === void 0 ? {} : { prefix }
3234
+ }))));
3235
+ const collection$1 = (view, parsed) => Effect.map(input(view, parsed), collection);
3236
+ const data$1 = (view, parsed) => Effect.map(input(view, parsed), data);
3237
+ const fromUrl = (view, url) => parseRead(nodes, url).pipe(Effect.flatMap((parsed) => parsed.id === void 0 ? collection$1(view, parsed) : data$1(view, parsed)));
3238
+ const toCover = (read) => Effect.map(bound(read), ({ node, plan }) => [read, ...collectionsOf(nodes, node, plan, read).map((name) => ({ collection: name }))]);
3239
+ return {
3240
+ collection: collection$1,
3241
+ data: data$1,
3242
+ fromUrl,
3243
+ toCover
3244
+ };
3245
+ };
3246
+ // src/RestHandlers.ts
3247
+ /**
3248
+ * RestHandlers
3249
+ *
3250
+ * Derived JSON:API `list` / `get` handlers over the sync engine's read path
3251
+ * (issue #163). A JSON:API `GET` is ADR-0025's `read` operation with a
3252
+ * document body: the URL is parsed into a `Query.Read` by the one grammar
3253
+ * (`JsonApiQuery.parseParams`), the request's identity becomes a
3254
+ * `SyncContext`, the read runs through `Query.plan` → the filterable check
3255
+ * and the deny-by-default auth filters (`Authorization.authorize`) →
3256
+ * `Backend.evaluate` + `getRows` with ADR-0025's two-phase include
3257
+ * resolution (`Query.runVia` over `SyncServer.readSubsets` — the very
3258
+ * function the protocol's `handleRead` runs), and the rows are serialised
3259
+ * by {@link DocumentWriter}. Two carriers, one evaluator, one auth path:
3260
+ * what the sync tier syncs for a window is what the REST tier answers for
3261
+ * the same URL, so a façade serving that URL from the replica (ADR-0029 §6)
3262
+ * can match it.
3263
+ *
3264
+ * Scoping is the auth policy's, never the handler's: a request without
3265
+ * identity to a private collection sees the policy's empty allow-set —
3266
+ * an empty collection for `list`, a 404 for `get` — exactly as the sync
3267
+ * tier's `isIn(field, [])` behaves. No `WHERE userId` lives here.
3268
+ *
3269
+ * Sorting and paging are `Subset.apply`'s (ADR-0026 / ADR-0028): code-point
3270
+ * text order, NULLs last, the id tie-break, `page[after]` as the cursor —
3271
+ * the REST tier inherits them by construction.
3272
+ *
3273
+ * **Linkage follows the resource's declaration** ({@link Linkage}, shared
3274
+ * with the replica-side writer): beside the URL's includes, the rows of
3275
+ * every `Relationship.many` the endpoint's resource declares are resolved —
3276
+ * on the primary resource, and on each included resource's own declaration
3277
+ * — and written as linkage, not `included`; nothing the resource does not
3278
+ * declare is touched. A paginated node edge under `Relationship.paginated`
3279
+ * carries `links` only, and the endpoint's own codec refuses `include=`
3280
+ * for it. A declaration the node graph cannot serve is a definition-time
3281
+ * defect, thrown when the handler is built.
3282
+ *
3283
+ * **Outcomes the derivation owns.** A URL the grammar rejects is answered
3284
+ * with the codec's 400 error document, `source.parameter` naming the
3285
+ * offending key, before any backend call; a collection the policy denies
3286
+ * (no auth filter declared) is a 403, and within a batch every subset is
3287
+ * authorized before any is evaluated. Both are written as JSON:API error
3288
+ * responses by the handler — effect-jsonapi's `ApiError` classes carry no
3289
+ * `source` member, and every endpoint already declares the 400. A `get`
3290
+ * that matches no row fails with the error the caller declares on the
3291
+ * endpoint (`notFound`), so the api's own 404 class is what the client
3292
+ * decodes. A backend failure is a defect (500), as the hand-written
3293
+ * handlers' `orDie` made it.
3294
+ *
3295
+ * Derivation is opt-in per endpoint: `handlers.handle("list", rest.list(Product))`
3296
+ * beside hand-written handlers on the same api.
3297
+ */
3298
+ var RestHandlers_exports = /* @__PURE__ */ __exportAll({
3299
+ make: () => make,
3300
+ queryErrorDocument: () => queryErrorDocument
3301
+ });
3302
+ /**
3303
+ * The 400 error document of a URL the grammar rejects: the codec's own
3304
+ * document (`Middleware.schemaErrorDocument`, one error object per issue,
3305
+ * each with `source.parameter`) when the rejection came from decoding; the
3306
+ * same shape around the `QueryError`'s message and `parameter` when it came
3307
+ * from the graph checks that run before decoding (an undeclared include, a
3308
+ * `fields[]` type the read cannot carry). The façade answers a parse failure
3309
+ * with this document too (ADR-0029 §1).
3310
+ */
3311
+ const queryErrorDocument = (error) => {
3312
+ if (Schema.isSchemaError(error.cause)) return Middleware.schemaErrorDocument("Query", error.cause);
3313
+ return { errors: [{
3314
+ status: String(ApiError$1.BadRequest.status),
3315
+ code: ApiError$1.BadRequest.code,
3316
+ ...ApiError$1.BadRequest.title === void 0 ? {} : { title: ApiError$1.BadRequest.title },
3317
+ detail: error.message,
3318
+ ...error.parameter === void 0 ? {} : { source: { parameter: error.parameter } }
3319
+ }] };
3320
+ };
3321
+ /** The 403 error document of a collection the policy denies. */
3322
+ const forbiddenDocument = (error) => ({ errors: [{
3323
+ status: String(ApiError$1.Forbidden.status),
3324
+ code: ApiError$1.Forbidden.code,
3325
+ ...ApiError$1.Forbidden.title === void 0 ? {} : { title: ApiError$1.Forbidden.title },
3326
+ detail: error.message
3327
+ }] });
3328
+ /** An error document as a JSON:API response. */
3329
+ const errorResponse = (status, document) => HttpServerResponse.jsonUnsafe(document, {
3330
+ status,
3331
+ contentType: MEDIA_TYPE
3332
+ });
3333
+ /** `HttpServerRequest.url` is path + query; any origin makes it parseable. */
3334
+ const searchParamsOf = (request) => new URL(request.url, "http://localhost").searchParams;
3335
+ /** An outcome the derivation owns, as a response; a backend failure is a defect. */
3336
+ const respondToFailures = (effect) => Effect.catch(effect, (error) => {
3337
+ if (error instanceof QueryError) return Effect.succeed(errorResponse(ApiError$1.BadRequest.status, queryErrorDocument(error)));
3338
+ if (error instanceof AuthorizationError) return Effect.succeed(errorResponse(ApiError$1.Forbidden.status, forbiddenDocument(error)));
3339
+ if (error instanceof BackendError) return Effect.die(error);
3340
+ return Effect.fail(error);
3341
+ });
3342
+ /**
3343
+ * Bind the derived handlers to a graph and a read path. The node of a
3344
+ * resource is looked up by its `type` when the handler is built and the
3345
+ * resource's relationships are checked against the node's edges then, so a
3346
+ * declaration the graph cannot serve is a definition-time defect, never a
3347
+ * 500.
3348
+ */
3349
+ const make = (options) => {
3350
+ const { nodes, backend, policy, middleware, prefix } = options;
3351
+ const deps = {
3352
+ backend,
3353
+ authPolicy: policy,
3354
+ ...middleware && { middleware }
3355
+ };
3356
+ const nodeOf = (resource) => {
3357
+ const node = nodes.nodes.get(resource.type);
3358
+ if (node === void 0) throw new Error(`RestHandlers: no node "${resource.type}" in the graph for the resource`);
3359
+ return node;
3360
+ };
3361
+ const run = (context, node, plan, read) => resolve$1((subsets) => SyncServer.readSubsets(deps, context, {
3362
+ clientGroupId: context.clientGroupId,
3363
+ subsets
3364
+ }), nodes, node, plan, read);
3365
+ /** The writer's input for a read's rows (the projection and prefix only when given). */
3366
+ const input = (read, resolved, fields) => ({
3367
+ nodes,
3368
+ read,
3369
+ rows: resolved.rows,
3370
+ linkage: resolved.linkage,
3371
+ ...fields === void 0 ? {} : { fields },
3372
+ ...prefix === void 0 ? {} : { prefix }
3373
+ });
3374
+ const list = (resource) => {
3375
+ const node = nodeOf(resource);
3376
+ const plan = planOf(nodes, resource, node);
3377
+ return ({ request }) => respondToFailures(Effect.gen(function* () {
3378
+ const context = yield* options.context(request);
3379
+ const { read, fields } = yield* parseParams(nodes, node.type, searchParamsOf(request));
3380
+ const resolved = yield* run(context, node, plan, read);
3381
+ return collection(input(read, resolved, fields));
3382
+ }));
3383
+ };
3384
+ const get = (resource, { notFound }) => {
3385
+ const node = nodeOf(resource);
3386
+ const plan = planOf(nodes, resource, node);
3387
+ return ({ params, request }) => respondToFailures(Effect.gen(function* () {
3388
+ const context = yield* options.context(request);
3389
+ const single = yield* singleRowParams(node.type, params.id, searchParamsOf(request));
3390
+ const { read, fields } = yield* parseParams(nodes, node.type, single);
3391
+ const resolved = yield* run(context, node, plan, read);
3392
+ if (Arr.isReadonlyArrayEmpty(resolved.rows)) return yield* Effect.fail(notFound());
3393
+ return data(input(read, resolved, fields));
3394
+ }));
3395
+ };
3396
+ return {
3397
+ list,
3398
+ get
3399
+ };
3400
+ };
3401
+ // src/JsonApiFacade.ts
3402
+ /**
3403
+ * JsonApiFacade
3404
+ *
3405
+ * The façade of ADR-0029 (issue #166): a fetch handler that answers a
3406
+ * JSON:API `GET` from the replica when an explicit subscription covers it,
3407
+ * and stays out of the way otherwise. A service worker composes it into its
3408
+ * `fetch` listener **first**, before the HTTP cache and the network, so a
3409
+ * cached copy never becomes a second source of truth for a locally
3410
+ * answerable document (ADR-0029 §1).
3411
+ *
3412
+ * The handler is built from the app's `HttpApi` and the replica it reads:
3413
+ *
3414
+ * 1. **Route reflection.** `Convention.reflectResources(api)` gives the
3415
+ * `list` and `getOne` routes per type; those whose type is a node of the
3416
+ * graph are kept, so a route the replica cannot read (a server-only
3417
+ * resource) is never the façade's. A request is its business iff its
3418
+ * method is `GET`, its `Accept` names `application/vnd.api+json`, and its
3419
+ * path — under {@link Options.baseUrl} — matches one of those route
3420
+ * templates segment for segment. Anything else yields `undefined` and
3421
+ * the caller composes what comes next.
3422
+ * 2. **Parse.** The URL becomes a `Query.Read` through the one grammar
3423
+ * (`JsonApiQuery.parseRead`). A URL the grammar rejects is answered with
3424
+ * the codec's 400 error document (`RestHandlers.queryErrorDocument`),
3425
+ * whether or not the request is covered, because the server rejects it
3426
+ * identically — except an `include` the graph does not declare, which
3427
+ * goes to the network where the server rules on it (ADR-0029 §1), and a
3428
+ * path the graph cannot place, likewise.
3429
+ * 3. **Coverage.** Every read `ReplicaDocuments.toCover` names for the
3430
+ * request — the read itself and the whole collections its declared
3431
+ * linkage resolves from — is checked with {@link Options.coverageOf}, the
3432
+ * ledger's verdict (`Client.coverageOf`, or `Coverage.coverageOf` over
3433
+ * the registrations an `EngineHost` holds on mirrors' behalf). One
3434
+ * uncovered read sends the request to the network unchanged.
3435
+ * 4. **Answer.** A covered read runs over the view — confirmed rows with the
3436
+ * optimistic overlay, exactly what `Client.query` sees for a covered read
3437
+ * — and is serialised by the one writer, so the document's content is
3438
+ * byte-equal to the derived REST handler's for the same declarations
3439
+ * (ADR-0029 §6). The response is `200` `application/vnd.api+json` with
3440
+ * `Cache-Control: no-store` (the replica is the cache) and
3441
+ * `x-legation-served-at: <cookie>`, the replica's CVR cookie at the time
3442
+ * of the answer, so devtools and tests can
3443
+ * tell a local answer from a network one.
3444
+ * 5. **Offline.** An uncovered request while the network is unreachable —
3445
+ * `navigator.onLine === false` here, or a forwarded fetch that rejected
3446
+ * at the transport in the caller ({@link FetchHandler.offline}) — is
3447
+ * answered with the 503 `offline` error document, the one error the
3448
+ * façade synthesises that the server never would (ADR-0029 §4). A status
3449
+ * code from the server is never offline, and a covered request is never
3450
+ * affected by connectivity.
3451
+ *
3452
+ * **The parity assumption.** Answering a parse failure locally, and a
3453
+ * covered request at all, presumes the server rules on the same URL the
3454
+ * same way: its read endpoints are declared with the graph's own codec
3455
+ * (`JsonApiQuery.querySchema` for a `list`, `singleRowSchema` for a `get`)
3456
+ * and served by the derived handlers (`RestHandlers`), as the commerce
3457
+ * example does. A hand-written server with another grammar — a filter key
3458
+ * the node does not declare, a `sort` the codec refuses, a different `page`
3459
+ * vocabulary — must not compose the façade: its 400s and its documents
3460
+ * would diverge from the server's.
3461
+ *
3462
+ * 6. **Writes** (ADR-0030, with {@link Options.mutate} set). A `POST` /
3463
+ * `PATCH` / `DELETE` on a reflected write route of a type the graph
3464
+ * declares (and {@link Options.writes} allows) is forwarded unchanged
3465
+ * while online and unasked; offline, after a forward that rejected at the
3466
+ * transport, or on `Prefer: respond-async`, it is decoded structurally
3467
+ * (`FacadeWrites`), mapped to a mutation by the intent mapping (default:
3468
+ * the graph-derived CRUD mutations, `CrudMutation`), journaled, and
3469
+ * answered with a `202 Accepted` **write receipt**: the resource as
3470
+ * journaled, read back through the one writer, `meta.legation.status:
3471
+ * "pending"` and a `Location` naming the mutation status URL. Never `201`,
3472
+ * `200` or `204`: those are the server's words for "applied". A write it
3473
+ * cannot journal is forwarded online; offline it gets ADR-0030 §3's error
3474
+ * documents: `400 invalid_document`, `409 type_mismatch` / `id_mismatch`
3475
+ * or `422 unsupported_relationship` for a malformed body, and the `503`
3476
+ * `offline` document otherwise, with `client_id_required` appended for a
3477
+ * create without an id and `not_journalable` for a mutation the engine
3478
+ * refuses. Attribute values are never the façade's to refuse.
3479
+ *
3480
+ * 7. **The mutation status URL** (ADR-0030 §2, with
3481
+ * {@link Options.mutationStates} set): `GET {prefix}/_legation/mutations/{id}`
3482
+ * beside every reflected prefix is answered from the engine's
3483
+ * mutation-state map and never forwarded (`MutationStatus`): `200` with
3484
+ * the mutation's phase, `404 unknown_mutation` for an id the map does not
3485
+ * hold, `Prefer: wait=N` holding a pending answer up to
3486
+ * {@link Options.maxWait}.
3487
+ *
3488
+ * What it never does: register a subscription because a request arrived,
3489
+ * journal a write online unless asked, or rewrite a request it forwards. A single-resource
3490
+ * request whose row the covered replica does not hold is forwarded too: the
3491
+ * 404 is the endpoint's own error class, which only the server can write.
3492
+ *
3493
+ * It lives in `@thomasfosterau/legation-jsonapi` rather than `@thomasfosterau/legation-browser` by
3494
+ * ADR-0006's rule: its dependencies are this package's (the codec, the
3495
+ * writer, route reflection) plus the standard `Request` / `Response`
3496
+ * globals, and the one browser-only reading — `navigator.onLine` — is an
3497
+ * option with a guarded default. `@thomasfosterau/legation-browser` therefore stays free of
3498
+ * effect-jsonapi; its `ServiceWorkerHost.start` hands a worker what this
3499
+ * handler needs from the host.
3500
+ */
3501
+ var JsonApiFacade_exports = /* @__PURE__ */ __exportAll({
3502
+ fetchHandler: () => fetchHandler,
3503
+ offlineDocument: () => offlineDocument,
3504
+ offlineResponse: () => offlineResponse,
3505
+ servedAtHeader: () => servedAtHeader
3506
+ });
3507
+ /** The header a locally served response carries: the replica's CVR cookie at the time of the answer. */
3508
+ const servedAtHeader = "x-legation-served-at";
3509
+ /** The 503 error document of an uncovered request while offline (ADR-0029 §4). */
3510
+ const offlineDocument = { errors: [{
3511
+ status: "503",
3512
+ code: "offline",
3513
+ title: "Service unavailable",
3514
+ detail: "The request is not covered by a local subscription and the network is unreachable."
3515
+ }] };
3516
+ /** Every response the façade synthesises: JSON:API, never cached, marked as served locally. */
3517
+ const respondWith = (status, body, cookie, headers = {}) => new Response(JSON.stringify(body), {
3518
+ status,
3519
+ headers: {
3520
+ ...headers,
3521
+ "content-type": MEDIA_TYPE,
3522
+ "cache-control": "no-store",
3523
+ [servedAtHeader]: cookie ?? ""
3524
+ }
3525
+ });
3526
+ /** One error object of a façade-synthesised write error, `source.pointer` when it names one. */
3527
+ const errorObject = (refusal) => {
3528
+ const error = {
3529
+ status: String(refusal.status),
3530
+ code: refusal.code,
3531
+ title: refusal.title,
3532
+ detail: refusal.detail
3533
+ };
3534
+ return refusal.pointer === void 0 ? error : {
3535
+ ...error,
3536
+ source: { pointer: refusal.pointer }
3537
+ };
3538
+ };
3539
+ /** The 503 `offline` document with a second error object saying why the write was not journaled. */
3540
+ const offlineWith = (reason) => ({ errors: [...offlineDocument.errors, errorObject(reason)] });
3541
+ /**
3542
+ * The offline answer to a write it could not journal (ADR-0030 §3): a
3543
+ * structural refusal is its own `400` / `409` / `422` document — the statuses
3544
+ * JSON:API prescribes for the same faults, so no divergence from the server —
3545
+ * except a create without a client id, which is the `503` `offline` document
3546
+ * with `client_id_required` appended; an engine refusal is `503` `offline`
3547
+ * with `not_journalable`; anything else the plain `503` `offline`.
3548
+ */
3549
+ const unjournaledDocument = (failure) => Match.valueTags(failure, {
3550
+ Refused: ({ refusal }) => refusal.status === 503 ? [503, offlineWith(refusal)] : [refusal.status, { errors: [errorObject(refusal)] }],
3551
+ NotJournalable: ({ detail }) => [503, offlineWith({
3552
+ status: 503,
3553
+ code: "not_journalable",
3554
+ title: "Not journalable",
3555
+ detail
3556
+ })],
3557
+ Unmapped: () => [503, offlineDocument]
3558
+ });
3559
+ /** The 503 response of an uncovered request while offline, marked with the cookie when known. */
3560
+ const offlineResponse = (cookie) => respondWith(503, offlineDocument, cookie);
3561
+ const routeOf = (type, template, single) => {
3562
+ const segments = segmentsOf(template);
3563
+ const prefix = segments.slice(0, segments.length - (single ? 2 : 1));
3564
+ return {
3565
+ type,
3566
+ segments,
3567
+ prefix: Arr.isReadonlyArrayEmpty(prefix) ? "" : `/${prefix.join("/")}`
3568
+ };
3569
+ };
3570
+ /** The read routes of the api whose type the graph declares: a `list` and a `getOne` per such resource. */
3571
+ const routesOf = (api, nodes) => {
3572
+ return [...reflectResources(api).values()].flatMap((resource) => {
3573
+ if (!nodes.nodes.has(resource.type)) return [];
3574
+ const list = resource.list === void 0 ? [] : [routeOf(resource.type, resource.list.path, false)];
3575
+ const one = resource.getOne === void 0 ? [] : [routeOf(resource.type, resource.getOne.path, true)];
3576
+ return list.concat(one);
3577
+ });
3578
+ };
3579
+ const writeMethods = {
3580
+ create: "POST",
3581
+ update: "PATCH",
3582
+ delete: "DELETE"
3583
+ };
3584
+ /** The write routes of the api whose type the graph declares and `writes` allows. */
3585
+ const writeRoutesOf = (api, nodes, writes) => Arr.flatMap([...reflectResources(api).values()], (resource) => {
3586
+ const node = nodes.nodes.get(resource.type);
3587
+ if (node === void 0) return [];
3588
+ return Arr.filterMap([
3589
+ "create",
3590
+ "update",
3591
+ "delete"
3592
+ ], (operation) => {
3593
+ const endpoint = resource[operation];
3594
+ const method = writeMethods[operation];
3595
+ const allowed = writes === void 0 || (writes[resource.type] ?? []).includes(operation);
3596
+ return endpoint === void 0 || endpoint.method.toUpperCase() !== method || !allowed ? Result.failVoid : Result.succeed({
3597
+ ...routeOf(resource.type, endpoint.path, operation !== "create"),
3598
+ operation,
3599
+ node,
3600
+ method
3601
+ });
3602
+ });
3603
+ });
3604
+ const matchesTemplate = (route, segments) => route.segments.length === segments.length && route.segments.every((template, index) => isParam(template) ? true : template === segments[index]);
3605
+ const baseOf = (baseUrl) => {
3606
+ if (baseUrl === "" || baseUrl.startsWith("/")) return { path: baseUrl.replace(/\/+$/, "") };
3607
+ const url = new URL(baseUrl);
3608
+ return {
3609
+ origin: url.origin,
3610
+ path: url.pathname.replace(/\/+$/, "")
3611
+ };
3612
+ };
3613
+ /**
3614
+ * The request path relative to the base, or `undefined` when it lies
3615
+ * elsewhere. A trailing slash is left to the server: the route templates
3616
+ * carry none, and whether `/products/` is `/products` is its ruling.
3617
+ */
3618
+ const pathUnder = (base, url) => {
3619
+ if (base.origin !== void 0 && url.origin !== base.origin) return void 0;
3620
+ if (!url.pathname.startsWith(base.path) || url.pathname.endsWith("/")) return void 0;
3621
+ const rest = url.pathname.slice(base.path.length);
3622
+ return rest.startsWith("/") ? rest : void 0;
3623
+ };
3624
+ /** Whether an `Accept` header names the JSON:API media type (parameters aside). */
3625
+ const acceptsJsonApi = (header) => header !== null && header.split(",").some((part) => (part.trim().split(";")[0] ?? "").trim().toLowerCase() === MEDIA_TYPE);
3626
+ /**
3627
+ * Whether a `Content-Type` is the JSON:API media type without the atomic
3628
+ * extension: an `ext` parameter naming atomic marks the operations endpoint,
3629
+ * never a façade write (ADR-0030 §5).
3630
+ */
3631
+ const sendsJsonApi = (header) => {
3632
+ if (header === null) return false;
3633
+ const [type = "", ...parameters] = header.split(";");
3634
+ if (type.trim().toLowerCase() !== MEDIA_TYPE) return false;
3635
+ return !parameters.some((parameter) => {
3636
+ const [name = "", value = ""] = parameter.split("=");
3637
+ return name.trim().toLowerCase() === "ext" && value.toLowerCase().includes("atomic");
3638
+ });
3639
+ };
3640
+ const navigatorOnline = () => {
3641
+ return globalThis.navigator?.onLine !== false;
3642
+ };
3643
+ const forward = { _tag: "Forward" };
3644
+ const respond = (response) => ({
3645
+ _tag: "Respond",
3646
+ response
3647
+ });
3648
+ /**
3649
+ * A grammar rejection as the server would rule on it. An `include` the
3650
+ * graph does not declare is the server's call (ADR-0029 §1), and so is a
3651
+ * path the graph cannot place (no parameter, no decoding issue: the URL
3652
+ * names something the graph does not); anything else is the codec's 400,
3653
+ * which the server returns identically.
3654
+ */
3655
+ const queryFailure = (error, cookie) => error.parameter === "include" || error.parameter === void 0 && !Schema.isSchemaError(error.cause) ? forward : respond(respondWith(400, queryErrorDocument(error), cookie));
3656
+ /**
3657
+ * Build the façade over an api and a replica. Route reflection and the
3658
+ * writer's declaration checks run here, so a declaration the graph cannot
3659
+ * serve is a definition-time defect, never a response.
3660
+ */
3661
+ const fetchHandler = (options) => {
3662
+ const { nodes, view, coverageOf } = options;
3663
+ const routes = routesOf(options.api, nodes);
3664
+ const mutate = options.mutate;
3665
+ const writeRoutes = mutate === void 0 ? [] : writeRoutesOf(options.api, nodes, options.writes);
3666
+ const intentOf = options.intentOf ?? graphIntentOf(nodes);
3667
+ const base = baseOf(options.baseUrl ?? "");
3668
+ const online = options.online ?? navigatorOnline;
3669
+ /** The cookie at the time of an answer; read only when a response is synthesised. */
3670
+ const cookie = options.cookie === void 0 ? Effect.succeed(void 0) : Effect.catch(options.cookie, () => Effect.succeed(void 0));
3671
+ const writers = /* @__PURE__ */ new Map();
3672
+ /** The writer for a link prefix: one per prefix, built when a route first needs it. */
3673
+ const writerFor = (prefix) => {
3674
+ const existing = writers.get(prefix);
3675
+ if (existing !== void 0) return existing;
3676
+ const made = make$1({
3677
+ nodes,
3678
+ prefix,
3679
+ ...options.resources === void 0 ? {} : { resources: options.resources }
3680
+ });
3681
+ writers.set(prefix, made);
3682
+ return made;
3683
+ };
3684
+ routes.forEach((route) => {
3685
+ writerFor(options.prefix ?? route.prefix);
3686
+ });
3687
+ const candidateOf = (request) => {
3688
+ if (request.method !== "GET" || !acceptsJsonApi(request.headers.get("accept"))) return;
3689
+ const url = new URL(request.url);
3690
+ const path = pathUnder(base, url);
3691
+ if (path === void 0) return void 0;
3692
+ const segments = segmentsOf(path);
3693
+ const route = routes.find((candidate) => matchesTemplate(candidate, segments));
3694
+ return route === void 0 ? void 0 : {
3695
+ route,
3696
+ url
3697
+ };
3698
+ };
3699
+ /** Rules 1 to 4 for every read the request needs, judged over the request's root rows: covered by the ledger, or not the façade's to answer. */
3700
+ const covered = (reads, snapshot) => Effect.forEach(reads, (read) => coverageOf(read, { snapshot })).pipe(Effect.map((verdicts) => verdicts.every((verdict) => verdict._tag === "Covered")));
3701
+ /** The replica with the request's root window answered from `rows`: the rows coverage judged, not a second read. */
3702
+ const pinned = (base, root, rows) => ({
3703
+ query: (subset) => Subset.hash(subset) === Subset.hash(root) ? Effect.succeed(rows) : base.query(subset),
3704
+ observe: base.observe
3705
+ });
3706
+ /** A rejection as an outcome, with the cookie read for the response it may carry. */
3707
+ const rejected = (error) => Effect.flatMap(cookie, (served) => Effect.fail(queryFailure(error, served)));
3708
+ const answer = ({ route, url }) => Effect.gen(function* () {
3709
+ const docs = writers.get(options.prefix ?? route.prefix);
3710
+ if (docs === void 0) return yield* Effect.fail(forward);
3711
+ const parsed = yield* parseRead(nodes, url).pipe(Effect.catch(rejected));
3712
+ const needed = yield* docs.toCover(parsed.read).pipe(Effect.mapError(() => forward));
3713
+ const root = yield* Query$1.tryPlan(nodes, parsed.read).pipe(Effect.mapError(() => forward));
3714
+ const rows = yield* view.query(root.root).pipe(Effect.orElseSucceed(() => void 0));
3715
+ if (rows === void 0 || !(yield* covered(needed, {
3716
+ subset: root.root,
3717
+ rows
3718
+ }))) {
3719
+ if (online()) return yield* Effect.fail(forward);
3720
+ return yield* Effect.flatMap(cookie, (served) => Effect.fail(respond(offlineResponse(served))));
3721
+ }
3722
+ const replica = pinned(view, root.root, rows);
3723
+ const document = yield* (parsed.id === void 0 ? docs.collection(replica, parsed) : docs.data(replica, parsed)).pipe(Effect.catch(rejected));
3724
+ if (parsed.id !== void 0 && document.data === null) return yield* Effect.fail(forward);
3725
+ return respondWith(200, document, yield* cookie);
3726
+ });
3727
+ const readHandle = (request) => {
3728
+ const candidate = candidateOf(request);
3729
+ if (candidate === void 0) return Effect.succeed(void 0);
3730
+ return answer(candidate).pipe(Effect.catchTags({
3731
+ Forward: () => Effect.succeed(void 0),
3732
+ Respond: (outcome) => Effect.succeed(outcome.response)
3733
+ }));
3734
+ };
3735
+ const writeCandidateOf = (request) => {
3736
+ const method = request.method.toUpperCase();
3737
+ if (!acceptsJsonApi(request.headers.get("accept"))) return void 0;
3738
+ if (method !== "DELETE" && !sendsJsonApi(request.headers.get("content-type"))) return;
3739
+ const path = pathUnder(base, new URL(request.url));
3740
+ if (path === void 0) return void 0;
3741
+ const segments = segmentsOf(path);
3742
+ const route = writeRoutes.find((candidate) => candidate.method === method && matchesTemplate(candidate, segments));
3743
+ if (route === void 0) return void 0;
3744
+ return {
3745
+ route,
3746
+ target: route.operation === "create" ? {
3747
+ operation: "create",
3748
+ type: route.type
3749
+ } : {
3750
+ operation: route.operation,
3751
+ type: route.type,
3752
+ id: decodeURIComponent(segments.at(-1) ?? "")
3753
+ }
3754
+ };
3755
+ };
3756
+ /** The mutation status URL of a journaled mutation: beside the api it belongs to (ADR-0030 §2). */
3757
+ const statusUrl = (route, mutationId) => `${base.origin ?? ""}${base.path}${route.prefix}/_legation/mutations/${mutationId}`;
3758
+ /**
3759
+ * The receipt's `data`: the resource read back through the one writer
3760
+ * after journaling (ADR-0030 §1), or — when the replica does not hold the
3761
+ * row (a patch of a row it never pulled) — the request's own members.
3762
+ */
3763
+ const receiptData = ({ route }, write) => {
3764
+ const prefix = options.prefix ?? route.prefix;
3765
+ const docs = writerFor(prefix);
3766
+ const sent = {
3767
+ type: write.type,
3768
+ id: write.id,
3769
+ attributes: write.attributes ?? {}
3770
+ };
3771
+ return docs.fromUrl(view, `${prefix}/${write.type}/${encodeURIComponent(write.id)}`).pipe(Effect.map((document) => "data" in document && document.data !== null && !Array.isArray(document.data) ? document.data : sent), Effect.orElseSucceed(() => sent));
3772
+ };
3773
+ /**
3774
+ * Journal a write and answer its `202` receipt, or fail with `undefined`
3775
+ * when it cannot be journaled (the caller forwards it online, or answers
3776
+ * offline). The body is read from a clone.
3777
+ */
3778
+ const journal = (request, candidate, preferences) => Effect.gen(function* () {
3779
+ const body = Effect.tryPromise(() => request.clone().text());
3780
+ const write = yield* decodeWrite(candidate.route.node, candidate.target, body).pipe(Effect.mapError((refusal) => ({
3781
+ _tag: "Refused",
3782
+ refusal
3783
+ })));
3784
+ const intent = intentOf(write);
3785
+ if (intent === void 0 || mutate === void 0) return yield* Effect.fail({ _tag: "Unmapped" });
3786
+ const handle = yield* mutate(intent.mutator, intent.args).pipe(Effect.mapError((error) => P.isTagged(error, "MutatorError") ? {
3787
+ _tag: "NotJournalable",
3788
+ detail: error.message
3789
+ } : { _tag: "Unmapped" }));
3790
+ const location = statusUrl(candidate.route, handle.mutationId);
3791
+ const meta = { legation: {
3792
+ status: "pending",
3793
+ mutationId: handle.mutationId,
3794
+ mutation: location
3795
+ } };
3796
+ const document = write.operation === "delete" ? { meta } : {
3797
+ data: yield* receiptData(candidate, write),
3798
+ meta
3799
+ };
3800
+ const headers = preferences.respondAsync ? {
3801
+ location,
3802
+ "preference-applied": "respond-async"
3803
+ } : { location };
3804
+ return respondWith(202, document, yield* cookie, headers);
3805
+ });
3806
+ const offlineNow = Effect.map(cookie, offlineResponse);
3807
+ /** The offline answer to a write it could not journal: ADR-0030 §3's error documents. */
3808
+ const unjournaled = (failure) => Effect.map(cookie, (served) => {
3809
+ const [status, document] = unjournaledDocument(failure);
3810
+ return respondWith(status, document, served);
3811
+ });
3812
+ /** ADR-0030 §3's decision table for a write candidate, offline meaning `navigator.onLine` false. */
3813
+ const writeHandle = (request, candidate) => {
3814
+ const preferences = preferencesOf(request.headers);
3815
+ const reachable = online();
3816
+ if (preferences.strict) return reachable ? Effect.succeed(void 0) : offlineNow;
3817
+ if (reachable && !preferences.respondAsync) return Effect.succeed(void 0);
3818
+ return journal(request, candidate, preferences).pipe(Effect.catch((failure) => reachable ? Effect.succeed(void 0) : unjournaled(failure)));
3819
+ };
3820
+ const mutationStates = options.mutationStates;
3821
+ const maxWait = Duration.fromInputUnsafe(options.maxWait ?? "30 seconds");
3822
+ /** Every prefix the api's reflected routes sit under: the status route sits beside each. */
3823
+ const prefixes = Arr.dedupe(Arr.map([...routes, ...writeRoutes], (route) => route.prefix));
3824
+ /** A status request: the route prefix it names and the id segment, or `undefined`. */
3825
+ const statusCandidateOf = (request) => {
3826
+ if (mutationStates === void 0 || request.method !== "GET") return void 0;
3827
+ if (!acceptsJsonApi(request.headers.get("accept"))) return void 0;
3828
+ const path = pathUnder(base, new URL(request.url));
3829
+ if (path === void 0) return void 0;
3830
+ const segments = segmentsOf(path);
3831
+ const [id = "", mutations = "", legation = ""] = segments.toReversed();
3832
+ if (legation !== "_legation" || mutations !== "mutations") return void 0;
3833
+ const prefix = segments.slice(0, -3);
3834
+ const joined = Arr.isReadonlyArrayEmpty(prefix) ? "" : `/${prefix.join("/")}`;
3835
+ return prefixes.includes(joined) ? {
3836
+ prefix: joined,
3837
+ id: decodeURIComponent(id)
3838
+ } : void 0;
3839
+ };
3840
+ const statusHandle = (request, states, candidate) => Effect.gen(function* () {
3841
+ const links = `${base.origin ?? ""}${base.path}${candidate.prefix}`;
3842
+ const mutationId = parseId(candidate.id);
3843
+ if (Option.isNone(mutationId)) return respondWith(404, unknownDocument(candidate.id), yield* cookie);
3844
+ const now = yield* current(states, mutationId.value);
3845
+ const asked = preferencesOf(request.headers).wait;
3846
+ const wait = asked === void 0 || Option.isNone(now) || now.value.phase !== "pending" ? void 0 : Duration.min(Duration.seconds(asked), maxWait);
3847
+ const state = wait === void 0 ? now : yield* settled(states, mutationId.value, wait);
3848
+ const served = yield* cookie;
3849
+ if (Option.isNone(state)) return respondWith(404, unknownDocument(candidate.id), served);
3850
+ const document$1 = document(nodes, links, state.value);
3851
+ return wait === void 0 ? respondWith(200, document$1, served) : respondWith(200, document$1, served, { "preference-applied": `wait=${Math.floor(Duration.toSeconds(wait))}` });
3852
+ });
3853
+ const handle = (request) => {
3854
+ const status = statusCandidateOf(request);
3855
+ if (status !== void 0 && mutationStates !== void 0) return statusHandle(request, mutationStates, status);
3856
+ const write = writeCandidateOf(request);
3857
+ return write === void 0 ? readHandle(request) : writeHandle(request, write);
3858
+ };
3859
+ const offline = (request) => {
3860
+ const write = writeCandidateOf(request);
3861
+ if (write === void 0) return offlineNow;
3862
+ const preferences = preferencesOf(request.headers);
3863
+ if (preferences.strict) return offlineNow;
3864
+ return journal(request, write, preferences).pipe(Effect.catch(unjournaled));
3865
+ };
3866
+ const forwarded = (request, response) => options.pull !== void 0 && response.status >= 200 && response.status < 300 && writeCandidateOf(request) !== void 0 ? Effect.ignore(options.pull) : Effect.void;
3867
+ return {
3868
+ matches: (request) => candidateOf(request) !== void 0 || writeCandidateOf(request) !== void 0 || statusCandidateOf(request) !== void 0,
3869
+ handle,
3870
+ offline,
3871
+ forwarded
3872
+ };
3873
+ };
3874
+ // src/JsonApiSync.ts
3875
+ /**
3876
+ * JsonApiSync
3877
+ *
3878
+ * The happy path: hand it an app's JSON:API `HttpApi` and get a fully wired,
3879
+ * offline-capable sync engine. It reflects the api into resources
3880
+ * (Convention.ts), derives collections + CRUD mutators (Derive.ts), and
3881
+ * composes the JsonApiBackend with an in-process sync room and the client
3882
+ * engine — so the app's existing REST API gains optimistic writes, a durable
3883
+ * journal, rebase, and live queries with no extra server to deploy
3884
+ * (client-direct deployment; "realtime" is periodic pull).
3885
+ *
3886
+ * The same JsonApiBackend satisfies the BYO `Backend` contract, so a later
3887
+ * server-side deployment for CVR fan-out reuses it unchanged.
3888
+ *
3889
+ * When the api advertises the JSON:API Atomic Operations extension (an
3890
+ * endpoint taking an `atomic:operations` document), pushes go to it as one
3891
+ * all-or-nothing request per batch; otherwise each mutation is replayed as
3892
+ * its own request (`JsonApiSyncOptions.atomic`).
3893
+ */
3894
+ var JsonApiSync_exports = /* @__PURE__ */ __exportAll({
3895
+ inspect: () => inspect,
3896
+ layer: () => layer,
3897
+ start: () => start
3898
+ });
3899
+ /**
3900
+ * Reflect + derive an api's resources, collections, mutators, operations —
3901
+ * and its atomic operations endpoint, when it declares one.
3902
+ */
3903
+ const inspect = (api) => {
3904
+ const resources = reflectResources(api);
3905
+ const atomic = reflectAtomic(api);
3906
+ return {
3907
+ resources,
3908
+ ...atomic !== void 0 ? { atomic } : {},
3909
+ ...derive(resources)
3910
+ };
3911
+ };
3912
+ /** Every resource collection is readable; the REST API enforces real auth. */
3913
+ const authPolicy = (resources) => Authorization.policy(Object.fromEntries([...resources.keys()].map((type) => [type, Authorization.allowAll])));
3914
+ /**
3915
+ * The full client-direct stack as a layer: provide an `HttpClient`, get a
3916
+ * ready `ClientEngine`. CVR store and invalidation bus are in-process; the
3917
+ * client store defaults to in-memory, or to the durable store over
3918
+ * `keyValueStore` (pass it for offline persistence and a durable ledger across
3919
+ * reloads, or pass `store` and `ledger` for another durable store).
3920
+ */
3921
+ const layer = (api, options) => {
3922
+ const { resources, registry, operations, atomic } = inspect(api);
3923
+ const atomicPath = options.atomic === false ? void 0 : options.atomic?.path ?? atomic?.path;
3924
+ const ledger = options.ledger ?? (options.keyValueStore !== void 0 ? keyValue(options.keyValueStore) : void 0);
3925
+ const clientStore = options.store ?? (options.keyValueStore !== void 0 ? ClientStore.layerKeyValue().pipe(Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore)(options.keyValueStore))) : ClientStore.layerMemory);
3926
+ const backendLayer = Layer.effect(Backend.Backend)(make$2({
3927
+ baseUrl: options.baseUrl,
3928
+ resources,
3929
+ mutators: registry.mutators,
3930
+ operations,
3931
+ ...atomicPath !== void 0 ? { atomic: { path: atomicPath } } : {},
3932
+ ...options.queryEncoder !== void 0 ? { query: options.queryEncoder } : {},
3933
+ ...options.includes !== void 0 ? { includes: options.includes } : {},
3934
+ ...ledger !== void 0 ? { ledger } : {}
3935
+ }));
3936
+ const authLayer = Layer.succeed(Authorization.AuthPolicy)(authPolicy(resources));
3937
+ const serverLayer = SyncRoom.layer.pipe(Layer.provide(backendLayer), Layer.provide(authLayer));
3938
+ const transportLayer = Transport.layerInMemory(options.clientGroupId).pipe(Layer.provide(serverLayer));
3939
+ const pullInterval = options.pullInterval === null ? void 0 : options.pullInterval ?? Duration.seconds(5);
3940
+ const syncLoop = {
3941
+ ...pullInterval !== void 0 ? { pullInterval } : {},
3942
+ ...options.pushDebounce !== void 0 ? { pushDebounce: options.pushDebounce } : {}
3943
+ };
3944
+ return ClientEngine.layer({
3945
+ clientId: options.clientId,
3946
+ clientGroupId: options.clientGroupId,
3947
+ mutators: registry,
3948
+ syncLoop
3949
+ }).pipe(Layer.provide(transportLayer), Layer.provide(clientStore));
3950
+ };
3951
+ /**
3952
+ * Start syncing: run the engine's sync loop. Interval pull, push-on-mutate,
3953
+ * pull-on-poke and push+pull-on-reconnect are all scheduled by the core loop
3954
+ * (configure the interval/debounce on `layer`'s options). Requires a `Scope` —
3955
+ * fork it into the app's runtime and the fibers live until the scope closes.
3956
+ */
3957
+ const start = Effect.gen(function* () {
3958
+ yield* (yield* ClientEngine.ClientEngine).runSync.pipe(Effect.orDie);
3959
+ });
3960
+ // src/NodeBridge.ts
3961
+ /**
3962
+ * NodeBridge
3963
+ *
3964
+ * The first-class integration ADR-0022 promised: a JSON:API resource
3965
+ * definition (`Resource.make` from `@thomasfosterau/effect-jsonapi`) **is**
3966
+ * a node definition — same type name, same attributes, and relationships
3967
+ * that map one-for-one onto edges. This module derives {@link Node} graphs
3968
+ * from resource definitions so an app declares its resources once and gets
3969
+ * the sync engine's replica, queries, joins, and fan-out topics from the
3970
+ * same source of truth as its HTTP API.
3971
+ *
3972
+ * The mapping:
3973
+ *
3974
+ * - `Relationship.one` / `Relationship.optional` → `Edge.one`, stored in
3975
+ * the foreign-key field `` `${name}Id` `` (override per relationship with
3976
+ * `via`). JSON:API keeps relationship linkage out of `attributes`, so the
3977
+ * engine-side row gains the key field the edge declares.
3978
+ * - `Relationship.many` / `Relationship.paginated` → `Edge.many` /
3979
+ * `Edge.paginated`. The **inverse** — the one-edge on the target holding
3980
+ * the key — is discovered when it is unambiguous, and named explicitly
3981
+ * when it is not: on the resource, with the `@legation/inverses`
3982
+ * annotation ({@link syncAnnotations}), or in the `inverses` option.
3983
+ * - `paginated` edges need an `order` (cursor pagination requires a stable
3984
+ * order, and it is part of the edge's identity — ADR-0022). It is read off
3985
+ * the relationship's own declaration, `Relationship.paginated(ref, { order })`
3986
+ * (#220), or given in the `order` option.
3987
+ *
3988
+ * **Sync metadata on the resource (#220).** What only the sync engine needs —
3989
+ * an inverse name, the literal kind of a field no schema can tell (an
3990
+ * `instant` stored as text) — is declared on the resource as
3991
+ * `@legation/`-namespaced annotations: `Resource.make(…, { annotations:
3992
+ * syncAnnotations({ inverses, kinds }) })`, or `Resource.annotate`, which
3993
+ * `Resource.extend` keeps. The {@link FromResourceOptions} remain, as
3994
+ * explicit overrides: an option entry wins over the resource's annotation or
3995
+ * declaration for the same relationship or field. A to-one relationship's
3996
+ * key field is still `` `${name}Id` `` or the `via` option until
3997
+ * effect-jsonapi's storage-key slot (effect-jsonapi#509) reaches this
3998
+ * package.
3999
+ *
4000
+ * **Acyclic resource graphs.** The library's resource types expand eagerly,
4001
+ * so mutually-referencing definitions (`Product ⇄ Review`) cannot be
4002
+ * authored; the idiomatic JSON:API graph points parent → child and the
4003
+ * child carries its foreign key as a plain attribute (`productId`). The
4004
+ * `edges` option closes that gap: it promotes such an attribute to a
4005
+ * declared one-edge on the bridged node, and {@link registry} discovers
4006
+ * collection-valued inverses across **bridged** one-edges — declared and
4007
+ * relationship-derived alike — so the acyclic graph bridges without ever
4008
+ * naming an inverse by hand.
4009
+ *
4010
+ * **The filterable set** (ADR-0028 rule d, #154) is read off the resource's
4011
+ * own declaration (effect-jsonapi, since 0.13: `Filter.able` / `Sort.able` piped
4012
+ * onto an attribute schema, `Resource.attribute(schema, { filter, sort })`,
4013
+ * `Relationship.one(ref, { filter })`) through `Resource.filterable` /
4014
+ * `Resource.sortable`. An attribute keeps its name and derives its literal
4015
+ * kind from its schema exactly as `Node.make` does; a to-one relationship's
4016
+ * declaration lands on the edge's **key field** (`author` → `authorId`,
4017
+ * kind `text`), which is the field the engine filters by. The
4018
+ * `filterable` / `sortable` options remain as overrides: an option entry
4019
+ * replaces the resource's declaration for that field (the way to give an
4020
+ * `instant` its kind, which no schema can tell), and adds fields the
4021
+ * resource does not declare.
4022
+ *
4023
+ * All mismatches are definition-time defects (a thrown
4024
+ * {@link ResourceBridgeError}), never runtime failures: a resource graph
4025
+ * either bridges completely or not at all.
4026
+ *
4027
+ * **Identity** is always `id` here: a JSON:API resource is identified by the
4028
+ * spec's `id` member, so a bridged node declares no identity attribute of its
4029
+ * own (`Node.make`'s `id` option, #144). The JSON:API surface — `/{type}/{id}`
4030
+ * widening to `filter[id]`, linkage and `included` reading `id` off a row
4031
+ * value — assumes that, so a hand-built node naming a natural key is not yet
4032
+ * servable through these handlers.
4033
+ */
4034
+ var NodeBridge_exports = /* @__PURE__ */ __exportAll({
4035
+ InversesAnnotation: () => InversesAnnotation,
4036
+ KindsAnnotation: () => KindsAnnotation,
4037
+ ResourceBridgeError: () => ResourceBridgeError,
4038
+ fromResource: () => fromResource,
4039
+ registry: () => registry,
4040
+ syncAnnotations: () => syncAnnotations
4041
+ });
4042
+ /** A definition-time mismatch between a resource graph and the node model. */
4043
+ var ResourceBridgeError = class extends Schema.TaggedError()("ResourceBridgeError", {
4044
+ resource: Schema.String,
4045
+ relationship: Schema.String,
4046
+ message: Schema.String
4047
+ }) {};
4048
+ /** The annotation key naming the inverse edge of collection relationships, by relationship name. */
4049
+ const InversesAnnotation = "@legation/inverses";
4050
+ /** The annotation key naming the literal kind of filterable fields, by row field. */
4051
+ const KindsAnnotation = "@legation/kinds";
4052
+ /**
4053
+ * The resource annotations that declare `sync`, to pass as (or merge into)
4054
+ * `Resource.make`'s `annotations` or `Resource.annotate`'s.
4055
+ */
4056
+ const syncAnnotations = (sync) => {
4057
+ let record = {};
4058
+ if (sync.inverses !== void 0) record = {
4059
+ ...record,
4060
+ [InversesAnnotation]: sync.inverses
4061
+ };
4062
+ if (sync.kinds !== void 0) record = {
4063
+ ...record,
4064
+ [KindsAnnotation]: sync.kinds
4065
+ };
4066
+ return record;
4067
+ };
4068
+ const Inverses = Schema.Record(Schema.String, Schema.String);
4069
+ const Kinds = Schema.Record(Schema.String, Schema.Literals([
4070
+ "text",
4071
+ "integer",
4072
+ "double",
4073
+ "boolean",
4074
+ "instant"
4075
+ ]));
4076
+ /**
4077
+ * Read one of the bridge's annotations off a resource: absent reads as empty,
4078
+ * and a value of the wrong shape is a definition-time defect.
4079
+ */
4080
+ const annotationOf = (resource, key, schema) => Option.map(Option.fromUndefinedOr(Resource.annotations(resource)[key]), (value) => Option.getOrElse(Schema.decodeUnknownOption(schema)(value), () => fail(resource.type, key, `the "${key}" annotation does not have the shape the bridge reads`)));
4081
+ const fail = (resource, relationship, message) => {
4082
+ throw new ResourceBridgeError({
4083
+ resource,
4084
+ relationship,
4085
+ message
4086
+ });
4087
+ };
4088
+ /**
4089
+ * The one-edges a resource bridges to: its `one`/`optional` relationships
4090
+ * plus the option-declared extras. This is the map inverse discovery
4091
+ * scans, so declared and relationship-derived edges resolve identically.
4092
+ */
4093
+ const oneEdgesOf = (resource, options) => {
4094
+ const related = R.filterMap(resource.relationships, (descriptor, name) => descriptor.kind === "one" || descriptor.kind === "optional" ? Result.succeed(Edge.one(descriptor.ref().type, { via: options.via?.[name] ?? `${name}Id` })) : Result.failVoid);
4095
+ const declared = R.map(options.edges ?? {}, (edge) => Edge.one(edge.target, { via: edge.via }));
4096
+ return {
4097
+ ...related,
4098
+ ...declared
4099
+ };
4100
+ };
4101
+ /**
4102
+ * Discover the inverse of a collection-valued relationship: the unique
4103
+ * one-edge on the (bridged) target that points back at `source`.
4104
+ */
4105
+ const discoverInverse = (source, name, target, targetOneEdges) => {
4106
+ const candidates = Object.entries(targetOneEdges).filter(([, edge]) => edge.target === source);
4107
+ const only = candidates[0];
4108
+ if (candidates.length === 1 && only !== void 0) return only[0];
4109
+ return fail(source, name, Arr.isReadonlyArrayEmpty(candidates) ? `no inverse found: "${target}" bridges no one-edge back to "${source}" — declare one (a relationship, or options.edges over its key attribute), or name it in options.inverses` : `ambiguous inverse: "${target}" bridges ${candidates.length} one-edges back to "${source}" (${candidates.map(([candidate]) => `"${candidate}"`).join(", ")}) — name the right one in options.inverses`);
4110
+ };
4111
+ /**
4112
+ * A paginated relationship's own canonical order (effect-jsonapi's
4113
+ * `Relationship.paginated(ref, { order })`), as the engine spells an order:
4114
+ * field names, `-` prefixed for descending.
4115
+ */
4116
+ const declaredOrder = (descriptor) => descriptor.kind === "paginated" ? Option.map(Option.fromUndefinedOr(descriptor.order), (order) => order.map((term) => term.direction === "desc" ? `-${term.field}` : term.field)) : Option.none();
4117
+ /**
4118
+ * Bridge one collection-valued relationship to its edge: the inverse is
4119
+ * named in the options or discovered on the target; a paginated one also
4120
+ * needs its declared order.
4121
+ */
4122
+ const collectionEdge = (resource, name, descriptor, options, oneEdgesOfTarget) => {
4123
+ const target = descriptor.ref();
4124
+ const inverse = options.inverses?.[name] ?? Option.getOrUndefined(Option.flatMapNullishOr(annotationOf(resource, "@legation/inverses", Inverses), (inverses) => inverses[name])) ?? discoverInverse(resource.type, name, target.type, oneEdgesOfTarget(target));
4125
+ if (descriptor.kind === "many") return Edge.many(target.type, { inverse });
4126
+ const order = options.order?.[name] ?? Option.getOrUndefined(declaredOrder(descriptor));
4127
+ if (order === void 0) return fail(resource.type, name, `a paginated relationship needs an edge order (cursor pagination requires a stable order) — declare it on the relationship, Relationship.paginated(ref, { order }), or in options.order`);
4128
+ return Edge.paginated(target.type, {
4129
+ inverse,
4130
+ order
4131
+ });
4132
+ };
4133
+ /** The resource's edges: its one-edges plus every collection-valued relationship's. */
4134
+ const edgesOf = (resource, options, oneEdgesOfTarget) => {
4135
+ const collections = R.filterMap(resource.relationships, (descriptor, name) => descriptor.kind === "many" || descriptor.kind === "paginated" ? Result.succeed(collectionEdge(resource, name, descriptor, options, oneEdgesOfTarget)) : Result.failVoid);
4136
+ return {
4137
+ ...oneEdgesOf(resource, options),
4138
+ ...collections
4139
+ };
4140
+ };
4141
+ /**
4142
+ * The resource's attributes minus every one-edge's key field: a one-edge's
4143
+ * key replaces any attribute of the same name (the promoted-attribute
4144
+ * case); the edge's branded id carries the field.
4145
+ */
4146
+ const attributesOf = (resource, edges) => {
4147
+ const keys = new Set(Object.values(edges).flatMap((edge) => edge.kind === "one" ? [edge.via] : []));
4148
+ return R.filter(resource.fields.attributes.fields, (_, field) => !keys.has(field));
4149
+ };
4150
+ /**
4151
+ * The filterable set the resource declares, keyed by **row field**: an
4152
+ * attribute under its own name, with the kind left to `Node.make` to derive
4153
+ * from its schema; a to-one relationship under its edge's key field, kind
4154
+ * `text` (an id). A key field declared twice — the relationship and the
4155
+ * attribute it is stored in both filterable — is a definition-time defect:
4156
+ * neither declaration may silently win.
4157
+ */
4158
+ const declaredFilterable = (resource, edges) => {
4159
+ const declared = {};
4160
+ const attributes = resource.fields.attributes.fields;
4161
+ Object.entries(Resource.filterable(resource)).forEach(([key, entry]) => {
4162
+ const edge = edges[key];
4163
+ const viaRelationship = attributes[key] === void 0 && edge?.kind === "one";
4164
+ const field = viaRelationship ? edge.via : key;
4165
+ if (declared[field] !== void 0) fail(resource.type, key, `key field "${field}" is declared filterable twice (by relationship "${key}" and by the attribute it is stored in) — declare it once`);
4166
+ declared[field] = viaRelationship ? {
4167
+ operators: entry.operators,
4168
+ kind: "text"
4169
+ } : entry.operators;
4170
+ });
4171
+ return declared;
4172
+ };
4173
+ /**
4174
+ * The declared filterable set with the resource's `@legation/kinds`
4175
+ * annotation applied: each named field keeps its operators and takes the
4176
+ * annotated kind. Naming a field the resource does not declare filterable is a
4177
+ * definition-time defect.
4178
+ */
4179
+ const withKinds = (resource, declared) => R.reduce(Option.getOrElse(annotationOf(resource, KindsAnnotation, Kinds), () => ({})), declared, (current, kind, field) => {
4180
+ const entry = current[field];
4181
+ if (entry === void 0) return fail(resource.type, KindsAnnotation, `"${field}" has a kind in "${KindsAnnotation}" but is not declared filterable on the resource`);
4182
+ const operators = "operators" in entry ? entry.operators : entry;
4183
+ return {
4184
+ ...current,
4185
+ [field]: {
4186
+ operators,
4187
+ kind
4188
+ }
4189
+ };
4190
+ });
4191
+ /**
4192
+ * The per-node options: hint keys from the bridge options; the filterable
4193
+ * set and sortable fields from the resource's declaration, each option
4194
+ * entry replacing the resource's for its field.
4195
+ */
4196
+ const nodeOptionsOf = (resource, edges, options) => ({
4197
+ ...options.hintKeys === void 0 ? {} : { hintKeys: options.hintKeys },
4198
+ filterable: {
4199
+ ...withKinds(resource, declaredFilterable(resource, edges)),
4200
+ ...options.filterable
4201
+ },
4202
+ sortable: [...Resource.sortable(resource), ...options.sortable ?? []]
4203
+ });
4204
+ /** Bridge one resource, resolving inverses through `oneEdgesOfTarget`. */
4205
+ const bridge = (resource, options, oneEdgesOfTarget) => {
4206
+ const edges = edgesOf(resource, options, oneEdgesOfTarget);
4207
+ return Node.make(resource.type, {
4208
+ attributes: attributesOf(resource, edges),
4209
+ edges,
4210
+ ...nodeOptionsOf(resource, edges, options)
4211
+ });
4212
+ };
4213
+ /**
4214
+ * Derive a {@link Node} definition from a single JSON:API resource
4215
+ * definition. Inverse discovery here sees only the target resource's own
4216
+ * relationships — bridge a whole graph with {@link registry} to discover
4217
+ * across option-declared edges too.
4218
+ */
4219
+ const fromResource = (resource, options = {}) => bridge(resource, options, (target) => oneEdgesOf(target, {}));
4220
+ /**
4221
+ * Bridge a whole resource graph and close it as a validated
4222
+ * {@link Node.NodeRegistry} — nodes plus the derived engine
4223
+ * `CollectionRegistry`, ready for `Client.make` / `Server.make`.
4224
+ * Options are keyed by resource type.
4225
+ */
4226
+ const registry = (resources, options = {}) => Node.registry(resources.map((resource) => bridge(resource, options[resource.type] ?? {}, (target) => oneEdgesOf(target, options[target.type] ?? {}))));
4227
+ // src/Relationships.ts
4228
+ var Relationships_exports = /* @__PURE__ */ __exportAll({
4229
+ hydrate: () => hydrate,
4230
+ observe: () => observe,
4231
+ query: () => query,
4232
+ related: () => related,
4233
+ resolve: () => resolve
4234
+ });
4235
+ const refOf = (value, name) => value.relationships?.[name] ?? null;
4236
+ const isLink = (value) => P.isObjectOrArray(value) && "type" in value && "id" in value;
4237
+ /** The `{ type, id }` links a row's named relationship points at. */
4238
+ const linksOf = (value, name) => {
4239
+ const ref = refOf(value, name);
4240
+ if (ref === null || ref === void 0) return [];
4241
+ return (Array.isArray(ref) ? ref : [ref]).filter(isLink);
4242
+ };
4243
+ /** Resolve one relationship of a row against a lookup (linkage → value[s]). */
4244
+ const resolve = (lookup, value, name) => {
4245
+ const ref = refOf(value, name);
4246
+ if (ref === null || ref === void 0) return ref;
4247
+ if (Array.isArray(ref)) return ref.flatMap((link) => {
4248
+ const row = lookup(link.type, link.id);
4249
+ return row === void 0 ? [] : [row];
4250
+ });
4251
+ return isLink(ref) ? lookup(ref.type, ref.id) : void 0;
4252
+ };
4253
+ /** Resolve several named relationships of a row. */
4254
+ const hydrate = (lookup, value, names) => {
4255
+ return R.fromIterableWith(names, (name) => [name, resolve(lookup, value, name)]);
4256
+ };
4257
+ /**
4258
+ * The linkage strategy: `targets` pins each referenced collection by `id` and
4259
+ * the linked ids; `resolveRow` reads the referenced rows out of the index by id
4260
+ * and hydrates via {@link hydrate}, so resolution is byte-for-byte the previous
4261
+ * behaviour.
4262
+ */
4263
+ const linkageStrategy = (names) => ({
4264
+ targets: (baseRows) => {
4265
+ const map = /* @__PURE__ */ new Map();
4266
+ baseRows.flatMap((row) => names.flatMap((name) => linksOf(row.value, name))).forEach((link) => {
4267
+ const entry = map.get(link.type) ?? {
4268
+ field: "id",
4269
+ ids: /* @__PURE__ */ new Set()
4270
+ };
4271
+ entry.ids.add(link.id);
4272
+ map.set(link.type, entry);
4273
+ });
4274
+ return map;
4275
+ },
4276
+ resolveRow: (index, baseRow) => {
4277
+ const lookup = (collection, id) => index.get(collection)?.get(id)?.[0]?.value;
4278
+ return hydrate(lookup, baseRow.value, names);
4279
+ }
4280
+ });
4281
+ /**
4282
+ * Resolve a subset's rows together with their named relationships, reading the
4283
+ * referenced collections from the engine's local view. Delegates to
4284
+ * {@link Related.queryWith} with the linkage strategy.
4285
+ */
4286
+ const query = (engine, subset, names) => Related.queryWith(engine, linkageStrategy(names), subset);
4287
+ /**
4288
+ * Live version of {@link query} as an incrementally-maintained join. Delegates
4289
+ * to {@link Related.observeWith}: a write to a referenced row re-hydrates the
4290
+ * join without re-querying the base; a base change that shifts referenced
4291
+ * collections re-establishes the observers.
4292
+ */
4293
+ const observe = (engine, subset, names) => Related.observeWith(engine, linkageStrategy(names), subset);
4294
+ /**
4295
+ * Auto-subscribing relational read: subscribes the base subset and the narrowed
4296
+ * `id IN (...)` subsets of each referenced collection, tracks the live linkage,
4297
+ * and tears the subscriptions down when the scope closes. Delegates to
4298
+ * {@link Related.relatedWith}. Pair with the backend's `includes` config to
4299
+ * deliver the related rows in the parent's response (no extra round trip).
4300
+ */
4301
+ const related = (engine, subset, names) => Related.relatedWith(engine, linkageStrategy(names), subset);
4302
+ export { ApiError, CollectionLoader_exports as CollectionLoader, ConflictError, Convention_exports as Convention, Derive_exports as Derive, DocumentWriter_exports as DocumentWriter, FacadeWrites_exports as FacadeWrites, JsonApiBackend_exports as JsonApiBackend, JsonApiFacade_exports as JsonApiFacade, JsonApiQuery_exports as JsonApiQuery, schema_exports as JsonApiSchema, JsonApiSync_exports as JsonApiSync, Ledger_exports as Ledger, MutationStatus_exports as MutationStatus, NetworkError, NodeBridge_exports as NodeBridge, ParseError, Relationships_exports as Relationships, ReplicaDocuments_exports as ReplicaDocuments, RestHandlers_exports as RestHandlers, SyncError };
4303
+
4304
+ //# sourceMappingURL=index.js.map