@haddowg/json-api-client 1.0.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.mjs ADDED
@@ -0,0 +1,1232 @@
1
+ //#region src/errors.ts
2
+ /**
3
+ * Thrown by the always-on light structural guards (ADR 0004) when a parsed response is not a
4
+ * JSON:API document, or a `data`/`included` member is not a resource object carrying `type`+`id`.
5
+ * Distinct from {@link JsonApiError} (a non-2xx server response): this signals the wire shape the
6
+ * runtime relies on was violated, not an application error the server reported. The opt-in
7
+ * per-field validator throws its engine's own error type, never this one.
8
+ */
9
+ var StructuralGuardError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "StructuralGuardError";
13
+ }
14
+ };
15
+ /**
16
+ * Thrown for any non-2xx JSON:API response. Carries the parsed error document plus
17
+ * expressive status matchers and pointer-grouping helpers.
18
+ */
19
+ var JsonApiError = class extends Error {
20
+ status;
21
+ errors;
22
+ constructor(status, errors, message) {
23
+ super(message ?? `JSON:API request failed with status ${status}`);
24
+ this.name = "JsonApiError";
25
+ this.status = status;
26
+ this.errors = errors;
27
+ }
28
+ hasStatus(status) {
29
+ return this.status === status;
30
+ }
31
+ is4xx() {
32
+ return this.status >= 400 && this.status < 500;
33
+ }
34
+ is5xx() {
35
+ return this.status >= 500 && this.status < 600;
36
+ }
37
+ isBadRequest() {
38
+ return this.status === 400;
39
+ }
40
+ isUnauthorized() {
41
+ return this.status === 401;
42
+ }
43
+ isForbidden() {
44
+ return this.status === 403;
45
+ }
46
+ isNotFound() {
47
+ return this.status === 404;
48
+ }
49
+ isNotAcceptable() {
50
+ return this.status === 406;
51
+ }
52
+ isConflict() {
53
+ return this.status === 409;
54
+ }
55
+ isUnsupportedMediaType() {
56
+ return this.status === 415;
57
+ }
58
+ isUnprocessable() {
59
+ return this.status === 422;
60
+ }
61
+ /** Alias of {@link isUnprocessable} — the JSON:API validation status. */
62
+ isValidationError() {
63
+ return this.status === 422;
64
+ }
65
+ isRateLimited() {
66
+ return this.status === 429;
67
+ }
68
+ /**
69
+ * Group errors by user-facing input path (falls back to the raw pointer, then the
70
+ * query parameter). The payoff for form/validation UX — 422 `source.pointer`s are
71
+ * remapped to the flat shape the caller actually supplied.
72
+ */
73
+ byPath() {
74
+ const out = {};
75
+ for (const e of this.errors) {
76
+ const key = e.path ?? e.source?.pointer ?? e.source?.parameter ?? "_";
77
+ (out[key] ??= []).push(e);
78
+ }
79
+ return out;
80
+ }
81
+ };
82
+ //#endregion
83
+ //#region src/validate.ts
84
+ /** Whether a `validate` option is the schema-driven config (vs a bare validator function). */
85
+ function isConfig(option) {
86
+ return typeof option === "object" && option !== null && !Array.isArray(option);
87
+ }
88
+ /**
89
+ * Normalise the `validate?` option to a single per-resource validator. The config form looks the
90
+ * schema up by `resource.type` and skips a type with no schema (the seam stays graceful for a
91
+ * partially-covered bundle); the bare-function form calls the validator with `schema = undefined`,
92
+ * delegating the whole decision to the caller. Returns `undefined` when validation is off.
93
+ */
94
+ function resolveValidator(option) {
95
+ if (option === void 0) return;
96
+ if (isConfig(option)) {
97
+ const { schemas, validator } = option;
98
+ return { validate(resource) {
99
+ const schema = schemas[resource.type];
100
+ if (schema !== void 0) validator(resource, schema);
101
+ } };
102
+ }
103
+ return { validate(resource) {
104
+ option(resource, void 0);
105
+ } };
106
+ }
107
+ const isObject$3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
108
+ /**
109
+ * The light structural guards every parsed response passes through (ADR 0004): assert the body
110
+ * is a JSON:API document and that each `data`/`included` member carries a string `type` (and,
111
+ * for a full resource, an `id`). This is the always-on, engine-free floor — it never duplicates
112
+ * the opt-in per-field validation; it only proves the envelope invariant the runtime relies on.
113
+ *
114
+ * A primary-`data` resource and every `included` resource must carry `type`+`id`. A relationship
115
+ * endpoint's primary data is pure resource-identifier linkage, which also carries `type`+`id`, so
116
+ * the same guard holds; `linkage` is accepted for symmetry with the materialiser but the assertion
117
+ * is identical (an identifier is `type`+`id`). `data: null` (an empty to-one) and a missing
118
+ * `data` (a meta-only document) are valid and pass.
119
+ */
120
+ function assertJsonApiDocument(body) {
121
+ if (!isObject$3(body)) throw new StructuralGuardError("Response body is not a JSON:API document (expected an object)");
122
+ if (!("data" in body) && !("meta" in body)) throw new StructuralGuardError("Response body is not a JSON:API document (missing top-level \"data\" or \"meta\")");
123
+ const data = body["data"];
124
+ if (Array.isArray(data)) data.forEach((member, i) => assertResource(member, `data[${i}]`));
125
+ else if (data !== null && data !== void 0) assertResource(data, "data");
126
+ const included = body["included"];
127
+ if (included !== void 0) {
128
+ if (!Array.isArray(included)) throw new StructuralGuardError("Response \"included\" is not an array");
129
+ included.forEach((member, i) => assertResource(member, `included[${i}]`));
130
+ }
131
+ }
132
+ /** Assert a single member is a resource object/identifier carrying string `type` + `id`. */
133
+ function assertResource(member, at) {
134
+ if (!isObject$3(member)) throw new StructuralGuardError(`${at} is not a JSON:API resource object`);
135
+ if (typeof member["type"] !== "string") throw new StructuralGuardError(`${at} is missing a string "type"`);
136
+ if (typeof member["id"] !== "string") throw new StructuralGuardError(`${at} (type "${member["type"]}") is missing a string "id"`);
137
+ }
138
+ /**
139
+ * Run the opt-in validator over every wire resource in a document's `data` + `included`, each
140
+ * against its per-type schema. Called only when a validator is configured (off by default). The
141
+ * structural guards have already proven each member carries `type`+`id`, so the cast is sound.
142
+ */
143
+ function validateDocument(body, validator) {
144
+ const data = body.data;
145
+ if (Array.isArray(data)) for (const member of data) validateResourceMember(member, validator);
146
+ else if (data !== null && data !== void 0) validateResourceMember(data, validator);
147
+ if (body.included !== void 0) for (const member of body.included) validator.validate(member);
148
+ }
149
+ /**
150
+ * Validate a `data` member, but only when it is a full RESOURCE OBJECT. A relationship-linkage
151
+ * endpoint's `data` is bare resource IDENTIFIERS (`type`+`id`[+`meta`], no `attributes`/
152
+ * `relationships`); the per-type schema describes the resource object, so running it over an
153
+ * identifier would spuriously fail. The structural guard already covered the identifier shape.
154
+ */
155
+ function validateResourceMember(member, validator) {
156
+ if (isObject$3(member) && ("attributes" in member || "relationships" in member)) validator.validate(member);
157
+ }
158
+ //#endregion
159
+ //#region src/materialise.ts
160
+ const isObject$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
161
+ const define = (target, key, value) => {
162
+ Object.defineProperty(target, key, {
163
+ value,
164
+ enumerable: false,
165
+ configurable: true
166
+ });
167
+ };
168
+ const defineGetter = (target, key, get) => {
169
+ Object.defineProperty(target, key, {
170
+ get,
171
+ enumerable: false,
172
+ configurable: true
173
+ });
174
+ };
175
+ /**
176
+ * Materialise a JSON:API document into a hydrated graph (ADR 0002 / CONTEXT.md):
177
+ *
178
+ * - single resource -> a flattened resource object (own enumerable type/id/attributes/
179
+ * relations + non-enumerable `$`-accessors);
180
+ * - collection / related collection -> an augmented array of resource objects;
181
+ * - relationship linkage -> an augmented array of identifier views (to-many) or a single
182
+ * identifier / null (to-one).
183
+ *
184
+ * Included resources hydrate into nested per-edge views (reads through to the shared
185
+ * node for attributes, owns its own `$edge`/`$pivot`); linked-but-not-included relations
186
+ * stay as identifiers; links-only relations are `undefined`. The envelope rides
187
+ * non-enumerable `$`-accessors so `{...res}` / `JSON.stringify(res)` are clean.
188
+ *
189
+ * `linkage` discriminates a relationship-endpoint response (primary `data` is pure
190
+ * resource-identifier linkage) from a resource/collection response (primary `data` is a
191
+ * full resource) — a structural guess can't tell them apart for an attribute-less,
192
+ * relation-less resource, so the caller passes the surface it issued.
193
+ *
194
+ * `paginatorOverride` sets the top-level array's `$page.kind` for a related/relationship read,
195
+ * where the paginator is the RELATION's (which may diverge from the related type's collection —
196
+ * D6a) and an empty page carries no member to sniff a type from. `primaryType` is preferred for a
197
+ * top-level collection read (statically-known); both override the sniff-from-`data[0]` fallback.
198
+ */
199
+ function materialise(doc, ctx, linkage = false, primaryType, paginatorOverride) {
200
+ assertJsonApiDocument(doc);
201
+ if (ctx.validate !== void 0) validateDocument(doc, ctx.validate);
202
+ const env = {};
203
+ if (doc.jsonapi !== void 0) env.jsonapi = doc.jsonapi;
204
+ if (doc.meta !== void 0) env.meta = doc.meta;
205
+ if (doc.links !== void 0) env.links = doc.links;
206
+ const index = buildIndex(doc.included);
207
+ const nodes = /* @__PURE__ */ new Map();
208
+ const build = (raw) => buildNode(raw, env, index, nodes, ctx);
209
+ const data = doc.data;
210
+ if (data === null) return null;
211
+ if (Array.isArray(data)) {
212
+ const raws = data;
213
+ return augmentArray(linkage ? raws.map((raw) => buildIdentifierView(raw)) : raws.map((raw) => buildEdgeView(build(raw), raw)), doc, ctx, linkage, primaryType, paginatorOverride);
214
+ }
215
+ if (isObject$2(data)) {
216
+ const raw = data;
217
+ if (linkage) return buildIdentifierView(raw);
218
+ return build(raw);
219
+ }
220
+ }
221
+ /** Index `included` by `type:id` so relationship linkage can be hydrated. */
222
+ function buildIndex(included) {
223
+ const index = /* @__PURE__ */ new Map();
224
+ if (!included) return index;
225
+ for (const raw of included) if (isObject$2(raw) && typeof raw["type"] === "string" && typeof raw["id"] === "string") index.set(key(raw["type"], raw["id"]), raw);
226
+ return index;
227
+ }
228
+ const key = (type, id) => `${type}:${id}`;
229
+ /**
230
+ * Build (or reuse) the shared NODE for a resource: flattened type/id/attributes + hydrated
231
+ * relations as own enumerable props, plus the resource-level `$`-accessors. The node is
232
+ * memoised by `type:id` so attribute reads share one object; per-edge data never lands here.
233
+ */
234
+ function buildNode(raw, env, index, nodes, ctx) {
235
+ const id = key(raw.type, raw.id);
236
+ const existing = nodes.get(id);
237
+ if (existing) return existing;
238
+ const node = {
239
+ type: raw.type,
240
+ id: raw.id
241
+ };
242
+ nodes.set(id, node);
243
+ if (raw.attributes) for (const [k, v] of Object.entries(raw.attributes)) node[k] = v;
244
+ const rels = raw.relationships ?? {};
245
+ const resType = ctx.descriptor[raw.type];
246
+ for (const [name, rel] of Object.entries(rels)) {
247
+ if (!("data" in rel)) continue;
248
+ const relDesc = resType?.relations[name];
249
+ node[name] = hydrateRelation(rel, relDesc?.cardinality, relatedPaginatorKind(relDesc, ctx), env, index, nodes, ctx);
250
+ }
251
+ attachResourceEnvelope(node, raw, env, rels);
252
+ return node;
253
+ }
254
+ /**
255
+ * Resolve one relationship's value: an included member hydrates to a per-edge view, a
256
+ * linked-but-not-included member stays an identifier, an empty to-one is `null`, and a
257
+ * to-many is always an augmented array (of views and/or identifiers).
258
+ *
259
+ * `relatedKind` is the related type's paginator kind (from the descriptor), threaded so a
260
+ * to-many array reports the same `$page.kind` it would on its dedicated endpoint.
261
+ */
262
+ function hydrateRelation(rel, cardinality, relatedKind, env, index, nodes, ctx) {
263
+ const data = rel.data;
264
+ if (Array.isArray(data)) return augmentRelationArray(data.map((member) => resolveMember(member, env, index, nodes, ctx)), rel, relatedKind, ctx);
265
+ if (data === void 0 && cardinality === "many") return augmentRelationArray([], rel, relatedKind, ctx);
266
+ if (data === null || data === void 0) return data === null ? null : void 0;
267
+ if (isObject$2(data)) return resolveMember(data, env, index, nodes, ctx, {
268
+ links: rel.links,
269
+ meta: rel.meta
270
+ });
271
+ }
272
+ /** A single linkage member -> a hydrated per-edge view if included, else an identifier view. */
273
+ function resolveMember(member, env, index, nodes, ctx, relationEdge) {
274
+ const included = index.get(key(member.type, member.id));
275
+ if (included) return buildEdgeView(buildNode(included, env, index, nodes, ctx), member, relationEdge);
276
+ return buildIdentifierView(member, relationEdge);
277
+ }
278
+ /** Attach the resource-level `$`-accessors ($meta/$links/$self/$document/$raw/$rel). */
279
+ function attachResourceEnvelope(node, raw, env, rels) {
280
+ defineGetter(node, "$meta", () => raw.meta);
281
+ defineGetter(node, "$links", () => raw.links);
282
+ defineGetter(node, "$self", () => raw.links?.self);
283
+ defineGetter(node, "$document", () => env);
284
+ defineGetter(node, "$raw", () => raw);
285
+ define(node, "$rel", (name) => {
286
+ const rel = rels[name];
287
+ if (!rel) return void 0;
288
+ const view = {};
289
+ if ("data" in rel) view.data = rel.data;
290
+ if (rel.links !== void 0) view.links = rel.links;
291
+ if (rel.meta !== void 0) view.meta = rel.meta;
292
+ return view;
293
+ });
294
+ }
295
+ /**
296
+ * A per-edge VIEW over a node: a distinct wrapper per membership that reads through to the
297
+ * shared node for attributes (and the resource-level `$`-accessors) but owns its own
298
+ * edge-local `$edge`/`$pivot`. Identity is by `type:id`, never reference.
299
+ *
300
+ * `relationEdge` (set for a to-one) carries the relationship object's self/related links and
301
+ * meta, which ride the value's `$edge` per CONTEXT.md.
302
+ */
303
+ function buildEdgeView(node, member, relationEdge) {
304
+ const view = {};
305
+ Object.assign(view, node);
306
+ for (const k of [
307
+ "$meta",
308
+ "$links",
309
+ "$self",
310
+ "$document",
311
+ "$raw",
312
+ "$rel"
313
+ ]) delegate(view, node, k);
314
+ attachEdgeEnvelope(view, member, false, relationEdge);
315
+ return view;
316
+ }
317
+ /** An identifier VIEW for a linked-but-not-included member: a clean `{type,id,meta?}` + edge. */
318
+ function buildIdentifierView(member, relationEdge) {
319
+ const view = {
320
+ type: member.type,
321
+ id: member.id
322
+ };
323
+ if (member.meta !== void 0) view.meta = member.meta;
324
+ attachEdgeEnvelope(view, member, true, relationEdge);
325
+ return view;
326
+ }
327
+ /**
328
+ * Attach `$edge` (the member's identifier-level edge meta/links) and `$pivot` sugar over it.
329
+ * For a to-one, the relationship object's self/related links + meta (`relationEdge`) merge in,
330
+ * with member-level meta winning on key collision.
331
+ */
332
+ function attachEdgeEnvelope(view, member, includeLinks, relationEdge) {
333
+ defineGetter(view, "$edge", () => {
334
+ const links = (includeLinks ? member.links : void 0) ?? relationEdge?.links;
335
+ const meta = mergeMeta(relationEdge?.meta, member.meta);
336
+ if (links === void 0 && meta === void 0) return void 0;
337
+ const edge = {};
338
+ if (links !== void 0) edge.links = links;
339
+ if (meta !== void 0) edge.meta = meta;
340
+ return edge;
341
+ });
342
+ defineGetter(view, "$pivot", () => {
343
+ const pivot = member.meta?.["pivot"];
344
+ return isObject$2(pivot) ? pivot : void 0;
345
+ });
346
+ }
347
+ /** Merge a relationship-object meta with member-level meta (member wins on key collision). */
348
+ function mergeMeta(base, override) {
349
+ if (base === void 0) return override;
350
+ if (override === void 0) return base;
351
+ return {
352
+ ...base,
353
+ ...override
354
+ };
355
+ }
356
+ /** Mirror a non-enumerable accessor from `source` onto `target`, reading through live. */
357
+ function delegate(target, source, k) {
358
+ const value = source[k];
359
+ if (typeof value === "function") define(target, k, value.bind(source));
360
+ else defineGetter(target, k, () => source[k]);
361
+ }
362
+ /**
363
+ * A top-level / related collection array: `$page` from `doc`, links/meta from the document.
364
+ * `linkage` propagates to `$next()`/`$prev()` so a relationship-endpoint page re-materialises
365
+ * as identifiers, not resources.
366
+ */
367
+ function augmentArray(members, doc, ctx, linkage, primaryType, paginatorOverride) {
368
+ const arr = [...members];
369
+ const links = doc.links;
370
+ defineGetter(arr, "$links", () => links);
371
+ defineGetter(arr, "$meta", () => doc.meta);
372
+ attachPage(arr, paginatorOverride ?? paginatorKind(doc, ctx, primaryType), pageMeta(doc.meta), links, ctx, linkage);
373
+ return arr;
374
+ }
375
+ /**
376
+ * A to-many relationship array: `$page`/links/meta come from the relationship object. `kind`
377
+ * is the related type's paginator (so an in-document compound-include to-many reports the same
378
+ * discriminant it would when fetched at its own relationship/related endpoint — "one model,
379
+ * three surfaces").
380
+ */
381
+ function augmentRelationArray(members, rel, kind, ctx) {
382
+ const arr = [...members];
383
+ const links = rel.links;
384
+ defineGetter(arr, "$links", () => links);
385
+ defineGetter(arr, "$meta", () => rel.meta);
386
+ attachPage(arr, kind, pageMeta(rel.meta), links, ctx, false);
387
+ return arr;
388
+ }
389
+ /** Attach `$page` + `$next()`/`$prev()` driven by link presence (count-free-safe). */
390
+ function attachPage(arr, kind, meta, links, ctx, linkage) {
391
+ const page = {
392
+ kind,
393
+ links: pageLinks(links)
394
+ };
395
+ if (meta !== void 0) page.meta = meta;
396
+ defineGetter(arr, "$page", () => page);
397
+ define(arr, "$next", () => navigateLink(page.links.next, ctx, linkage, kind));
398
+ define(arr, "$prev", () => navigateLink(page.links.prev, ctx, linkage, kind));
399
+ }
400
+ const navigateLink = (link, ctx, linkage, paginator) => link === void 0 ? Promise.resolve(void 0) : ctx.navigate(link, linkage, paginator);
401
+ /** Extract the navigation links (a link may be a string or a `{href}` object). */
402
+ function pageLinks(links) {
403
+ const out = {};
404
+ if (!links) return out;
405
+ for (const k of [
406
+ "first",
407
+ "prev",
408
+ "next",
409
+ "last"
410
+ ]) {
411
+ const href = linkHref(links[k]);
412
+ if (href !== void 0) out[k] = href;
413
+ }
414
+ return out;
415
+ }
416
+ const linkHref = (link) => {
417
+ if (typeof link === "string") return link;
418
+ if (isObject$2(link) && typeof link["href"] === "string") return link["href"];
419
+ };
420
+ const pageMeta = (meta) => isObject$2(meta?.["page"]) ? meta["page"] : void 0;
421
+ /**
422
+ * The document's paginator kind (from the primary type's descriptor; default `none`). Prefers the
423
+ * statically-known `primaryType` (passed by the descriptor binding at the call site) so an EMPTY
424
+ * page still reports the collection's real kind; falls back to sniffing the first member's type
425
+ * (for a re-materialised `$next()` page or a related read, where the primary type isn't threaded).
426
+ */
427
+ function paginatorKind(doc, ctx, primaryType) {
428
+ const first = Array.isArray(doc.data) ? doc.data[0] : doc.data;
429
+ const type = primaryType ?? (isObject$2(first) && typeof first["type"] === "string" ? first["type"] : void 0);
430
+ return type !== void 0 ? ctx.descriptor[type]?.paginator ?? "none" : "none";
431
+ }
432
+ /**
433
+ * A to-many relation's paginator kind. The relation's OWN related/relationship endpoint may
434
+ * paginate differently than the related type's top-level collection (D6a), so prefer the
435
+ * per-relation `paginator` (carried in the descriptor only when it diverges); otherwise fall back
436
+ * to the related type's own paginator (the relationship and related endpoints both paginate that
437
+ * type), so the discriminant is identical across the three surfaces. A polymorphic relation
438
+ * resolves off its first declared related type (its members span types, matching the relationship
439
+ * endpoint's first-member resolution).
440
+ */
441
+ function relatedPaginatorKind(relation, ctx) {
442
+ if (relation?.paginator !== void 0) return relation.paginator;
443
+ const relatedType = relation?.types[0];
444
+ if (relatedType === void 0) return "none";
445
+ return ctx.descriptor[relatedType]?.paginator ?? "none";
446
+ }
447
+ //#endregion
448
+ //#region src/types.ts
449
+ /** The JSON:API media type. */
450
+ const JSON_API_MEDIA_TYPE = "application/vnd.api+json";
451
+ /** The Atomic Operations extension URI. */
452
+ const ATOMIC_EXT = "https://jsonapi.org/ext/atomic";
453
+ /**
454
+ * Compose the JSON:API media type with any opted-in `ext`/`profile` parameters. Each parameter
455
+ * is a space-joined, double-quoted URI list, in the canonical order `ext` then `profile` so the
456
+ * output is deterministic (stable for caching/assertions). Empty/absent lists are omitted; with
457
+ * neither, the bare {@link JSON_API_MEDIA_TYPE} is returned (no behaviour change for a plain
458
+ * request). Used for both `Accept` and (when a body is present) `Content-Type`.
459
+ */
460
+ function mediaType(params = {}) {
461
+ let out = JSON_API_MEDIA_TYPE;
462
+ if (params.ext !== void 0 && params.ext.length > 0) out += `; ext="${params.ext.join(" ")}"`;
463
+ if (params.profiles !== void 0 && params.profiles.length > 0) out += `; profile="${params.profiles.join(" ")}"`;
464
+ return out;
465
+ }
466
+ /**
467
+ * The media type for an Atomic Operations request/response: the JSON:API media type carrying
468
+ * the atomic `ext` parameter. Sent as both `Content-Type` and `Accept` on a `client.atomic`
469
+ * batch (per the JSON:API atomic extension).
470
+ */
471
+ const ATOMIC_MEDIA_TYPE = mediaType({ ext: [ATOMIC_EXT] });
472
+ //#endregion
473
+ //#region src/request.ts
474
+ const isAbsolute = (path) => /^https?:\/\//i.test(path);
475
+ const append = (parts, key, value) => {
476
+ if (value === void 0 || value === null || value === "") return;
477
+ parts.push(`${key}=${encodeURIComponent(String(value))}`);
478
+ };
479
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
480
+ /**
481
+ * Append a filter value under its (already-bracketed) key. A plain object is a STRUCTURED filter
482
+ * value — a `Range`/`DateRange` deepObject (`{ min, max }`) — and recurses into nested bracketed
483
+ * keys (`filter[k][min]=`), matching the server's deepObject wire shape; an array joins with `,`;
484
+ * a scalar is appended directly. Without the object branch a `{ min, max }` value would reach the
485
+ * wire as `filter[k]=[object Object]` and be silently ignored by the server (D23).
486
+ */
487
+ const appendFilterValue = (parts, key, value) => {
488
+ if (isPlainObject(value)) {
489
+ for (const [subKey, subValue] of Object.entries(value)) appendFilterValue(parts, `${key}[${subKey}]`, subValue);
490
+ return;
491
+ }
492
+ append(parts, key, Array.isArray(value) ? value.join(",") : value);
493
+ };
494
+ /**
495
+ * Serialise a read query to a query string (no leading `?`). Bracketed keys
496
+ * (`filter[title]`, `page[number]`, `fields[albums]`) are kept literal; only values
497
+ * are percent-encoded. Empty/undefined entries are skipped; key order is deterministic
498
+ * (family order, then insertion order within a family) so URLs are stable for caching.
499
+ */
500
+ function serializeQuery(query) {
501
+ const parts = [];
502
+ if (query.filter) for (const [key, value] of Object.entries(query.filter)) appendFilterValue(parts, `filter[${key}]`, value);
503
+ if (query.sort !== void 0) append(parts, "sort", Array.isArray(query.sort) ? query.sort.join(",") : query.sort);
504
+ if (query.include) append(parts, "include", query.include.join(","));
505
+ if (query.fields) for (const [type, names] of Object.entries(query.fields)) parts.push(`fields[${type}]=${encodeURIComponent(names.join(","))}`);
506
+ if (query.withCount && query.withCount.length > 0) append(parts, "withCount", query.withCount.join(","));
507
+ if (query.page) for (const [key, value] of Object.entries(query.page)) append(parts, `page[${key}]`, Array.isArray(value) ? value.join(",") : value);
508
+ return parts.join("&");
509
+ }
510
+ /**
511
+ * Execute a request: build the URL (verbatim when already absolute — for page links —
512
+ * else `baseUrl + path`), apply content negotiation and any async per-request headers,
513
+ * then drive the transport. A 2xx with a body parses to a {@link Document}; an empty /
514
+ * 204 body resolves to `undefined`. A non-2xx throws a {@link JsonApiError} carrying the
515
+ * parsed error objects (tolerating a non-JSON error body with an empty list).
516
+ */
517
+ async function execute(ctx, req) {
518
+ let url = isAbsolute(req.path) ? req.path : ctx.baseUrl + req.path;
519
+ if (req.query) {
520
+ const qs = serializeQuery(req.query);
521
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
522
+ }
523
+ const negotiated = mediaType({
524
+ ext: req.ext,
525
+ profiles: req.profiles
526
+ });
527
+ const headers = { Accept: req.accept ?? negotiated };
528
+ const transportReq = {
529
+ method: req.method,
530
+ url,
531
+ headers
532
+ };
533
+ if (req.body !== void 0) {
534
+ headers["Content-Type"] = req.contentType ?? negotiated;
535
+ transportReq.body = req.raw && typeof req.body === "string" ? req.body : JSON.stringify(req.body);
536
+ }
537
+ if (ctx.headers) mergeHeaders(headers, await ctx.headers());
538
+ const res = await ctx.transport(transportReq);
539
+ if (res.status >= 200 && res.status < 300) return parseDocument(res.body);
540
+ throw new JsonApiError(res.status, parseErrors(res.body));
541
+ }
542
+ /**
543
+ * Merge a `HeadersInit` (object / tuple array / `Headers`) into the outgoing map,
544
+ * preserving the caller's casing (unlike the WHATWG `Headers`, which lowercases). The
545
+ * caller's headers override the runtime defaults, so per-request `Accept` etc. can win.
546
+ */
547
+ function mergeHeaders(into, extra) {
548
+ if (extra instanceof Headers) extra.forEach((value, key) => {
549
+ into[key] = value;
550
+ });
551
+ else if (Array.isArray(extra)) {
552
+ for (const [key, value] of extra) if (key !== void 0 && value !== void 0) into[key] = String(value);
553
+ } else for (const [key, value] of Object.entries(extra)) into[key] = String(value);
554
+ }
555
+ function parseDocument(body) {
556
+ if (body === "") return;
557
+ return JSON.parse(body);
558
+ }
559
+ function parseErrors(body) {
560
+ if (body === "") return [];
561
+ try {
562
+ const doc = JSON.parse(body);
563
+ return Array.isArray(doc.errors) ? doc.errors : [];
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
568
+ //#endregion
569
+ //#region src/serialize-write.ts
570
+ /** Per-edge writable pivot data, supplied on a to-many member as the `$pivot` key. */
571
+ const PIVOT_KEY = "$pivot";
572
+ /** The reserved input keys never routed to attributes/relationships. */
573
+ const RESERVED_INPUT = /* @__PURE__ */ new Set(["type", "id"]);
574
+ const isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
575
+ /**
576
+ * Build a JSON:API write document from FLAT input. Each input key is routed via the
577
+ * descriptor: a declared relation -> `data.relationships[name] = { data: <linkage> }`;
578
+ * anything else -> `data.attributes[name]`. Relationship linkage is coerced by cardinality:
579
+ *
580
+ * - to-one accepts an identifier `{type,id}`, a materialised resource object (its `{type,id}`
581
+ * are extracted), or `null` (-> `data: null`, clearing the relationship);
582
+ * - to-many accepts an array of those; a member may carry a `$pivot` object, rendered as the
583
+ * member identifier's `meta.pivot` (writable pivot fields).
584
+ *
585
+ * `data.type` is always the accessor `type`. `data.id` is written from `opts.id` (an update),
586
+ * else from `input.id` only when the type's `clientId` policy allows it (omitted when
587
+ * `forbidden`, passed through when `optional`/`required`).
588
+ */
589
+ function toDocument(descriptor, type, input, opts) {
590
+ const resource = descriptor[type];
591
+ const relations = resource?.relations ?? {};
592
+ const data = { type };
593
+ const id = resolveId(input, opts, resource?.clientId);
594
+ if (id !== void 0) data.id = id;
595
+ const attributes = {};
596
+ const relationships = {};
597
+ for (const [keyName, value] of Object.entries(input)) {
598
+ if (RESERVED_INPUT.has(keyName)) continue;
599
+ const relation = relations[keyName];
600
+ if (relation !== void 0) relationships[keyName] = { data: coerceLinkage(value, relation.cardinality) };
601
+ else attributes[keyName] = value;
602
+ }
603
+ if (Object.keys(attributes).length > 0) data.attributes = attributes;
604
+ if (Object.keys(relationships).length > 0) data.relationships = relationships;
605
+ return { data };
606
+ }
607
+ /**
608
+ * Build a relationship-mutation document `{ data: <linkage> }` for the
609
+ * `/{type}/{id}/relationships/{rel}` endpoints, coercing the caller's refs by cardinality:
610
+ *
611
+ * - to-one accepts an identifier `{type,id}`, a materialised resource object, or `null`
612
+ * (-> `data: null`, clearing the relationship);
613
+ * - to-many accepts an array of those; a member may carry `$pivot`, rendered as the member
614
+ * identifier's `meta.pivot`.
615
+ *
616
+ * The same {@link coerceLinkage} the whole-resource writer uses, so a relationship written
617
+ * standalone and one embedded in a resource serialise identically.
618
+ */
619
+ function toRelationshipDocument(refs, cardinality) {
620
+ return { data: coerceLinkage(refs, cardinality) };
621
+ }
622
+ /** Resolve the document id: an update's handle id always wins; a create honours the policy. */
623
+ function resolveId(input, opts, clientId) {
624
+ if (opts?.id !== void 0) return opts.id;
625
+ if (clientId === "forbidden") return;
626
+ const candidate = input["id"];
627
+ return typeof candidate === "string" ? candidate : void 0;
628
+ }
629
+ /** Coerce one relationship value to linkage by cardinality (array for to-many, identifier/null for to-one). */
630
+ function coerceLinkage(value, cardinality) {
631
+ if (cardinality === "many") return (Array.isArray(value) ? value : value == null ? [] : [value]).map((member) => toIdentifier(member));
632
+ if (value === null || value === void 0) return null;
633
+ return toIdentifier(value);
634
+ }
635
+ /**
636
+ * Extract a resource identifier from a linkage input value: an identifier `{type,id}`, a
637
+ * materialised resource object (the same `{type,id}` enumerable props), or — inside an atomic
638
+ * batch — a local identifier `{type,lid}` referencing a just-created resource (the handle a
639
+ * `tx.create` returns). A `$pivot` key is lifted onto `meta.pivot` (writable pivot fields).
640
+ * Throws on a value carrying no `type`, or neither an `id` nor a `lid`.
641
+ */
642
+ function toIdentifier(value) {
643
+ if (!isObject$1(value)) throw new TypeError(`Relationship linkage must be an identifier or resource object; received ${describe(value)}`);
644
+ const { type, id, lid } = value;
645
+ if (typeof type !== "string") throw new TypeError("Relationship linkage requires a `type`");
646
+ const identifier = typeof id === "string" ? {
647
+ type,
648
+ id
649
+ } : typeof lid === "string" ? {
650
+ type,
651
+ lid
652
+ } : (() => {
653
+ throw new TypeError("Relationship linkage requires an `id` or a `lid`");
654
+ })();
655
+ const pivot = value[PIVOT_KEY];
656
+ if (isObject$1(pivot)) identifier.meta = { pivot };
657
+ return identifier;
658
+ }
659
+ const describe = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value;
660
+ /**
661
+ * Invert a write error's `source.pointer` to the caller's FLAT input path, descriptor-aware:
662
+ *
663
+ * - `/data/attributes/title` -> `title`
664
+ * - `/data/relationships/artist/data` -> `artist`
665
+ * - `/data/id` -> `id` (client-id create conflict)
666
+ * - `/data/attributes/releaseInfo/label` -> `releaseInfo.label` (nested map)
667
+ * - `/data/relationships/orderedTracks/data/0/meta/pivot/position` -> `orderedTracks[0].$pivot.position`
668
+ *
669
+ * Pivot values nest under a member's `meta.pivot` (symmetric with reads — see
670
+ * `JsonPointerBuilder::forLinkageMeta` / bundle ADR 0103), so a `meta/pivot/<field>` tail on
671
+ * a member of a pivot-bearing to-many is surfaced as `.$pivot.<field>`.
672
+ *
673
+ * A query-side pointer is left untouched (those are already user-facing — see
674
+ * {@link withRemappedPaths}). An unrecognised / non-write pointer is returned verbatim.
675
+ * `/data/type` and a bare `/data` are passed through (the client owns those; the caller
676
+ * never supplies them).
677
+ */
678
+ function remapPointer(descriptor, type, pointer) {
679
+ const segments = pointer.split("/").filter((s) => s !== "");
680
+ if (segments[0] !== "data") return pointer;
681
+ if (segments[1] === "attributes") return remapAttribute(segments.slice(2));
682
+ if (segments[1] === "id") return "id";
683
+ if (segments[1] === "relationships") return remapRelationship(descriptor[type]?.relations ?? {}, segments.slice(2));
684
+ return pointer;
685
+ }
686
+ /** `[releaseInfo, label]` -> `releaseInfo.label`; a bare `[title]` -> `title`. */
687
+ function remapAttribute(rest) {
688
+ return rest.join(".");
689
+ }
690
+ /**
691
+ * Remap a relationship pointer's tail (everything after `relationships/`, or — for a
692
+ * relationship-endpoint document — the relation name spliced ahead of the `data` tail). The
693
+ * leading segment is the relation name; a `data` segment is dropped; a numeric index becomes
694
+ * `[n]`; a `meta/pivot/<field>` tail on a pivot-bearing relation becomes `.$pivot.<field>`. The
695
+ * `relations` map tells us whether the relation carries pivot (so a member's `meta.pivot`
696
+ * fields are the pivot data the bundle validates).
697
+ */
698
+ function remapRelationship(relations, rest) {
699
+ const name = rest[0];
700
+ if (name === void 0) return "data/relationships";
701
+ const hasPivot = relations[name]?.pivot === true;
702
+ let path = name;
703
+ for (let i = 1; i < rest.length; i++) {
704
+ const seg = rest[i];
705
+ if (seg === "data") continue;
706
+ if (/^\d+$/.test(seg)) path += `[${seg}]`;
707
+ else if (seg === "meta" && hasPivot && rest[i + 1] === "pivot") {
708
+ path += ".$pivot";
709
+ i++;
710
+ } else path += `.${seg}`;
711
+ }
712
+ return path;
713
+ }
714
+ /**
715
+ * Invert a relationship-MUTATION error's `source.pointer` to the caller's flat path, for the
716
+ * `/{type}/{id}/relationships/{rel}` endpoints. Two pointer shapes reach here:
717
+ *
718
+ * - the LINKAGE document shape (validation on the linkage members), rooted at `data` — the
719
+ * relation name is supplied by the route, not the wire:
720
+ * - `/data` -> `<rel>` (to-one clear/set)
721
+ * - `/data/meta/pivot/position` -> `<rel>.$pivot.position` (to-one pivot, rare)
722
+ * - `/data/0/meta/pivot/position` -> `<rel>[0].$pivot.position`
723
+ * - the RESOURCE-document shape that core's relationship prohibitions emit unchanged
724
+ * (`FullReplacementProhibited`/`RemovalProhibited`/`AdditionProhibited`/
725
+ * `RelationshipTypeInappropriate` all point at `/data/relationships/<name>`) — short-circuited
726
+ * to the relation name (drop the `relationships/<name>` prefix) rather than treating
727
+ * `relationships` as a linkage sub-segment.
728
+ *
729
+ * A non-`data` pointer is returned verbatim.
730
+ */
731
+ function remapRelationshipPointer(rel, hasPivot, pointer) {
732
+ const segments = pointer.split("/").filter((s) => s !== "");
733
+ if (segments[0] !== "data") return pointer;
734
+ const tail = segments[1] === "relationships" ? segments.slice(3) : segments.slice(1);
735
+ return remapRelationship({ [rel]: { pivot: hasPivot } }, [rel, ...tail]);
736
+ }
737
+ /**
738
+ * Return a {@link JsonApiError} with each error object's `path` populated by remapping its
739
+ * `source.pointer` (descriptor-aware) — so {@link JsonApiError.byPath} groups by the flat input
740
+ * shape. Errors with no write pointer (e.g. a `source.parameter` query-side error, or no
741
+ * source) are passed through unchanged. The original error is not mutated.
742
+ */
743
+ function withRemappedPaths(error, descriptor, type) {
744
+ return mapErrors(error, (pointer) => ({ path: remapPointer(descriptor, type, pointer) }));
745
+ }
746
+ /**
747
+ * The relationship-mutation twin of {@link withRemappedPaths}: remap each error's pointer
748
+ * keyed by the relation name from the route. Handles both the linkage-document shape (rooted
749
+ * at `data`) and the resource-document shape core's prohibition exceptions emit
750
+ * (`/data/relationships/<rel>`). `hasPivot` lets a member-level `meta` field surface as
751
+ * `$pivot`. The original error is not mutated.
752
+ */
753
+ function withRemappedRelationshipPaths(error, rel, hasPivot) {
754
+ return mapErrors(error, (pointer) => ({ path: remapRelationshipPointer(rel, hasPivot, pointer) }));
755
+ }
756
+ /**
757
+ * Invert an atomic-batch error's `source.pointer` to `(opIndex, flatPath)`: an atomic 422
758
+ * points at `/atomic:operations/{n}/data/...`, so strip the `atomic:operations/{n}` prefix to
759
+ * the op index, resolve that op's wire type (`typeForOp`), and delegate the remaining `/data`
760
+ * tail to the same {@link remapPointer} a standalone write uses (so the flat path is identical
761
+ * — `title`, `releaseInfo.label`, `artist`, …). A pointer without the atomic prefix is remapped
762
+ * verbatim under the (whole-batch) document at op index `undefined`. The original error is not
763
+ * mutated.
764
+ */
765
+ function withRemappedAtomicPaths(error, descriptor, typeForOp) {
766
+ return mapErrors(error, (pointer) => {
767
+ const segments = pointer.split("/").filter((s) => s !== "");
768
+ if (segments[0] !== "atomic:operations" || segments[1] === void 0) return { path: pointer };
769
+ const opIndex = Number(segments[1]);
770
+ if (!Number.isInteger(opIndex)) return { path: pointer };
771
+ const type = typeForOp(opIndex);
772
+ const tail = `/${segments.slice(2).join("/")}`;
773
+ return {
774
+ opIndex,
775
+ path: type === void 0 ? tail : remapPointer(descriptor, type, tail)
776
+ };
777
+ });
778
+ }
779
+ /** Shared error-mapper: clone each error with `path`/`opIndex` from its `source.pointer`, else pass through. */
780
+ function mapErrors(error, remap) {
781
+ const remapped = error.errors.map((e) => {
782
+ const pointer = e.source?.pointer;
783
+ if (pointer === void 0) return e;
784
+ const { path, opIndex } = remap(pointer);
785
+ return opIndex === void 0 ? {
786
+ ...e,
787
+ path
788
+ } : {
789
+ ...e,
790
+ path,
791
+ opIndex
792
+ };
793
+ });
794
+ return new JsonApiError(error.status, remapped, error.message);
795
+ }
796
+ //#endregion
797
+ //#region src/atomic.ts
798
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
799
+ /** The deterministic local id for the create at `opIndex` (stable + human-readable). */
800
+ const lidFor = (opIndex) => `atomic-${opIndex}`;
801
+ async function runAtomic(request, materialiseCtx, descriptor, path, build) {
802
+ const operations = [];
803
+ /** The wire type of each op, by index — drives the error-pointer remap. */
804
+ const typeByOp = [];
805
+ const returned = build({
806
+ create(input) {
807
+ const opIndex = operations.length;
808
+ const lid = lidFor(opIndex);
809
+ const data = atomicResourceData(descriptor, input, { lid });
810
+ operations.push({
811
+ op: "add",
812
+ data
813
+ });
814
+ typeByOp.push(input.type);
815
+ return {
816
+ type: input.type,
817
+ lid,
818
+ kind: "create",
819
+ opIndex
820
+ };
821
+ },
822
+ update(input) {
823
+ const opIndex = operations.length;
824
+ const identity = input.id !== void 0 ? { id: input.id } : { lid: input.lid };
825
+ const data = atomicResourceData(descriptor, input, identity);
826
+ operations.push({
827
+ op: "update",
828
+ ref: {
829
+ type: input.type,
830
+ ...identity
831
+ },
832
+ data
833
+ });
834
+ typeByOp.push(input.type);
835
+ return {
836
+ type: input.type,
837
+ kind: "update",
838
+ opIndex
839
+ };
840
+ },
841
+ delete(ref) {
842
+ const opIndex = operations.length;
843
+ const { type, identity } = toRef(ref);
844
+ operations.push({
845
+ op: "remove",
846
+ ref: {
847
+ type,
848
+ ...identity
849
+ }
850
+ });
851
+ typeByOp.push(type);
852
+ return {
853
+ kind: "delete",
854
+ opIndex
855
+ };
856
+ }
857
+ });
858
+ const req = {
859
+ method: "POST",
860
+ path,
861
+ body: { "atomic:operations": operations },
862
+ ext: [ATOMIC_EXT]
863
+ };
864
+ let doc;
865
+ try {
866
+ doc = await execute(request, req);
867
+ } catch (error) {
868
+ if (error instanceof JsonApiError) throw withRemappedAtomicPaths(error, descriptor, (opIndex) => typeByOp[opIndex]);
869
+ throw error;
870
+ }
871
+ const results = parseResults(doc, materialiseCtx);
872
+ if (!(Array.isArray(returned) && returned.every((h) => h !== null && typeof h === "object" && "opIndex" in h))) return results;
873
+ return returned.map((handle) => handle.kind === "delete" ? void 0 : results[handle.opIndex]);
874
+ }
875
+ /**
876
+ * Build the resource `data` for an `add`/`update` op from flat input via {@link toDocument}
877
+ * (so attributes/relationships route identically to a standalone write), then carry the op's
878
+ * identity onto the resource: a server `id` (a create's server-assigned id is omitted; an
879
+ * update by id keeps it) or a same-batch `lid` (a create's generated lid, or an update/remove
880
+ * targeting an earlier op's resource).
881
+ */
882
+ function atomicResourceData(descriptor, input, identity) {
883
+ const { id: _id, lid: _lid, ...fields } = input;
884
+ const { data } = toDocument(descriptor, input.type, fields, "id" in identity ? { id: identity.id } : void 0);
885
+ const out = { ...data };
886
+ if ("lid" in identity) {
887
+ delete out["id"];
888
+ out["lid"] = identity.lid;
889
+ }
890
+ return out;
891
+ }
892
+ /**
893
+ * Coerce a remove ref (an identifier, a materialised resource, or a same-batch `{type,lid}`
894
+ * handle) to `{ type, identity }` — `identity` an `id` or a `lid`; throws if neither is present.
895
+ */
896
+ function toRef(ref) {
897
+ if (!isObject(ref)) throw new TypeError("atomic delete requires an identifier `{ type, id }` or `{ type, lid }`");
898
+ const { type, id, lid } = ref;
899
+ if (typeof type !== "string") throw new TypeError("atomic delete requires a `type`");
900
+ if (typeof id === "string") return {
901
+ type,
902
+ identity: { id }
903
+ };
904
+ if (typeof lid === "string") return {
905
+ type,
906
+ identity: { lid }
907
+ };
908
+ throw new TypeError("atomic delete requires an `id` or a `lid`");
909
+ }
910
+ /** Parse `{ "atomic:results": [...] }` positionally, materialising each result's `data`. An absent results array (an all-`204` batch) yields `[]`. */
911
+ function parseResults(doc, ctx) {
912
+ const results = doc?.["atomic:results"];
913
+ if (!Array.isArray(results)) return [];
914
+ return results.map((result) => {
915
+ const entry = isObject(result) ? result : {};
916
+ const out = { data: entry["data"] === void 0 ? void 0 : materialise({ data: entry["data"] }, ctx) };
917
+ if (isObject(entry["meta"])) out.meta = entry["meta"];
918
+ return out;
919
+ });
920
+ }
921
+ //#endregion
922
+ //#region src/transport.ts
923
+ /** The default transport, backed by the global `fetch`. */
924
+ const fetchTransport = async (req) => {
925
+ const init = {
926
+ method: req.method,
927
+ headers: req.headers
928
+ };
929
+ if (req.body !== void 0) init.body = req.body;
930
+ const res = await fetch(req.url, init);
931
+ const headers = {};
932
+ res.headers.forEach((value, key) => {
933
+ headers[key] = value;
934
+ });
935
+ return {
936
+ status: res.status,
937
+ headers,
938
+ body: await res.text()
939
+ };
940
+ };
941
+ //#endregion
942
+ //#region src/client.ts
943
+ /** Substitute `{key}` placeholders in a path template (`/albums/{id}/{rel}` -> `/albums/1/tracks`). */
944
+ const fill = (template, vars) => template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
945
+ /**
946
+ * Run an operation: resolve the path template from the type's descriptor, fill its
947
+ * placeholders, execute, and materialise the resulting document. A no-body / 204 response
948
+ * materialises to `undefined`; an operation the type does not declare (no path template) throws
949
+ * a local `Error` before any request. `linkage` marks a relationship-endpoint read (primary
950
+ * `data` is pure resource-identifier linkage) so an attribute-less, relation-less member is
951
+ * still materialised as an identifier.
952
+ */
953
+ async function run(ctx, type, operation, vars, query, linkage = false, relationForCount) {
954
+ const template = ctx.descriptor[type]?.paths[operation];
955
+ if (template === void 0) throw new Error(`No "${operation}" path declared for type "${type}"`);
956
+ const req = {
957
+ method: "GET",
958
+ path: fill(template, vars)
959
+ };
960
+ if (query !== void 0) {
961
+ req.query = query;
962
+ negotiateProfiles(ctx, type, query, req, relationForCount);
963
+ }
964
+ const doc = await execute(ctx.request, req);
965
+ const primaryType = operation === "fetchMany" ? type : void 0;
966
+ const paginatorOverride = (operation === "fetchRelated" || operation === "fetchRelationship") && vars["rel"] !== void 0 ? relatedPaginatorKind(ctx.descriptor[type]?.relations[vars["rel"]], ctx.materialise) : void 0;
967
+ return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, linkage, primaryType, paginatorOverride);
968
+ }
969
+ /**
970
+ * Add any profiles a query's parameters require to the request's negotiated `profiles`. A
971
+ * `withCount` read needs the type's Countable profile in `Accept` — else the server rejects it
972
+ * (400) under strict query-param validation. The profile URI is read from the descriptor's
973
+ * `countable` block (never hardcoded); absent when the type advertises no Countable profile.
974
+ */
975
+ function negotiateProfiles(ctx, type, query, req, relationForCount) {
976
+ if (query.withCount === void 0 || query.withCount.length === 0) return;
977
+ const profile = relationForCount !== void 0 ? ctx.descriptor[type]?.relations[relationForCount]?.countable?.profile : ctx.descriptor[type]?.countable?.profile;
978
+ if (profile !== void 0) req.profiles = [...req.profiles ?? [], profile];
979
+ }
980
+ /** Project a write's opts to the read-query families the response honours (`include`/`fields`). */
981
+ function writeQuery(opts) {
982
+ if (opts === void 0) return;
983
+ const query = {};
984
+ if (opts.include !== void 0) query.include = [...opts.include];
985
+ if (opts.fields !== void 0) {
986
+ const fields = {};
987
+ for (const [k, v] of Object.entries(opts.fields)) fields[k] = [...v];
988
+ query.fields = fields;
989
+ }
990
+ return Object.keys(query).length > 0 ? query : void 0;
991
+ }
992
+ /**
993
+ * Drive a write: fill the path template, attach the body (and any `include`/`fields` query),
994
+ * execute, and materialise the response (`undefined` for a `204`/no body — so create/update
995
+ * return the resource and delete/`204` relationship mutations resolve `undefined`/`void`).
996
+ * On a thrown {@link JsonApiError}, each error's flat `path` is populated (via `spec.remap`)
997
+ * before it propagates, so a caller's `byPath()` keys are the flat input shape.
998
+ */
999
+ async function runWrite(ctx, spec) {
1000
+ const template = ctx.descriptor[spec.type]?.paths[spec.operation];
1001
+ if (template === void 0) throw new Error(`No "${spec.operation}" path declared for type "${spec.type}"`);
1002
+ const req = {
1003
+ method: spec.method,
1004
+ path: fill(template, spec.vars),
1005
+ body: spec.body
1006
+ };
1007
+ if (spec.query !== void 0) req.query = spec.query;
1008
+ let doc;
1009
+ try {
1010
+ doc = await execute(ctx.request, req);
1011
+ } catch (error) {
1012
+ if (error instanceof JsonApiError) throw spec.remap(error);
1013
+ throw error;
1014
+ }
1015
+ return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, spec.linkage ?? false);
1016
+ }
1017
+ /**
1018
+ * Invoke a custom action over its declared HTTP method (`action.method`, default `POST`) at its
1019
+ * (filled) path, with a body shaped by the action's `input` mode:
1020
+ *
1021
+ * - `none` — no body;
1022
+ * - `document` — when the action names an `inputType`, FLAT input (the resource's attributes /
1023
+ * relationships) is built into the JSON:API envelope like `create`, and a `422`'s
1024
+ * `source.pointer`s are remapped to the flat paths; a bespoke command document (no `inputType`)
1025
+ * is sent verbatim;
1026
+ * - `raw` — the caller's payload, sent with the action's declared media type (e.g.
1027
+ * `application/octet-stream`), falling back to a wildcard when the spec declared none.
1028
+ *
1029
+ * The response is shaped by the action's `output`: `document` materialises into the resource /
1030
+ * collection view, `meta` returns the document's top-level `meta`, `none` resolves `undefined` (a
1031
+ * `204` / no body).
1032
+ */
1033
+ async function runAction(ctx, action, vars, input) {
1034
+ const req = {
1035
+ method: action.method ?? "POST",
1036
+ path: fill(action.path, vars)
1037
+ };
1038
+ let remap;
1039
+ if (action.input === "document") if (action.inputType !== void 0) {
1040
+ const inputType = action.inputType;
1041
+ req.body = toDocument(ctx.descriptor, inputType, input ?? {});
1042
+ remap = (error) => withRemappedPaths(error, ctx.descriptor, inputType);
1043
+ } else req.body = input;
1044
+ else if (action.input === "raw") {
1045
+ req.body = input;
1046
+ req.contentType = action.contentType ?? "*/*";
1047
+ req.raw = true;
1048
+ }
1049
+ let doc;
1050
+ try {
1051
+ doc = await execute(ctx.request, req);
1052
+ } catch (error) {
1053
+ if (remap !== void 0 && error instanceof JsonApiError) throw remap(error);
1054
+ throw error;
1055
+ }
1056
+ if (action.output === "none" || doc === void 0) return;
1057
+ if (action.output === "meta") return doc.meta;
1058
+ return materialise(doc, ctx.materialise);
1059
+ }
1060
+ /**
1061
+ * Build the typed `.actions` accessor at one scope: a plain object exposing exactly the
1062
+ * type's actions declared at `scope` (collection on the type accessor, resource on a handle),
1063
+ * each a function dispatching to {@link runAction} with the action descriptor + the scope's
1064
+ * path vars (resource scope supplies `{id}`). Names not declared at this scope are absent —
1065
+ * `client.albums.actions.reissue` (resource-scoped) is undefined; reach it via `.id(id)`.
1066
+ */
1067
+ function actionsAccessor(ctx, type, scope, vars) {
1068
+ const actions = ctx.descriptor[type]?.actions ?? {};
1069
+ const out = {};
1070
+ for (const [name, action] of Object.entries(actions)) if (action.scope === scope) out[name] = (input) => runAction(ctx, action, vars, input);
1071
+ return out;
1072
+ }
1073
+ /**
1074
+ * Build the relationship accessor for `client.<type>.id(id).<rel>`: reads
1075
+ * (`.get()` linkage / `.related()` collection) plus mutations — to-many `.add` (POST) /
1076
+ * `.remove` (DELETE) / `.replace` (PATCH) and to-one `.set` (PATCH). Every mutation posts
1077
+ * a `{ data: <linkage> }` body to `fetchRelationship` and materialises the response as
1078
+ * linkage (or `undefined`/`void` for a `204`). The cardinality is read off the descriptor
1079
+ * so the linkage is coerced correctly; the static types gate which verb is callable.
1080
+ */
1081
+ function relationshipAccessor(ctx, type, id, rel) {
1082
+ const vars = {
1083
+ id,
1084
+ rel
1085
+ };
1086
+ const relation = ctx.descriptor[type]?.relations[rel];
1087
+ const cardinality = relation?.cardinality ?? "many";
1088
+ const hasPivot = relation?.pivot === true;
1089
+ const mutate = (method, refs) => runWrite(ctx, {
1090
+ type,
1091
+ operation: "fetchRelationship",
1092
+ vars,
1093
+ method,
1094
+ body: toRelationshipDocument(refs, cardinality),
1095
+ linkage: true,
1096
+ remap: (error) => withRemappedRelationshipPaths(error, rel, hasPivot)
1097
+ });
1098
+ const advertises = (verb) => {
1099
+ const mutations = relation?.mutations;
1100
+ return mutations === void 0 ? true : mutations[verb] === true;
1101
+ };
1102
+ const accessor = {};
1103
+ if (relation?.relationship !== false) accessor["get"] = (query) => run(ctx, type, "fetchRelationship", vars, query, true, rel);
1104
+ if (relation?.related !== false) accessor["related"] = (query) => run(ctx, type, "fetchRelated", vars, query, false, rel);
1105
+ if (cardinality === "many") {
1106
+ if (advertises("add")) accessor["add"] = (refs) => mutate("POST", refs);
1107
+ if (advertises("remove")) accessor["remove"] = (refs) => mutate("DELETE", refs);
1108
+ if (advertises("replace")) accessor["replace"] = (refs) => mutate("PATCH", refs);
1109
+ } else if (advertises("set")) accessor["set"] = (ref) => mutate("PATCH", ref);
1110
+ return accessor;
1111
+ }
1112
+ /**
1113
+ * The reserved members on a {@link ResourceHandle} — names the Proxy resolves itself, so a
1114
+ * relation of the same name is shadowed and must route through `.rel(name)`. The codegen
1115
+ * imports this to warn at build time (keeping the runtime + the collision detector in sync).
1116
+ * `then` is reserved so a handle is never mistaken for a thenable.
1117
+ */
1118
+ const HANDLE_RESERVED = /* @__PURE__ */ new Set([
1119
+ "type",
1120
+ "id",
1121
+ "get",
1122
+ "update",
1123
+ "delete",
1124
+ "rel",
1125
+ "actions",
1126
+ "then"
1127
+ ]);
1128
+ /** Build the id-scoped resource handle (a Proxy resolving relation accessors by name). */
1129
+ function resourceHandle(ctx, type, id) {
1130
+ const relations = ctx.descriptor[type]?.relations ?? {};
1131
+ const base = {
1132
+ type,
1133
+ id,
1134
+ get: (query) => run(ctx, type, "fetchOne", { id }, query),
1135
+ update: (patch, opts) => runWrite(ctx, {
1136
+ type,
1137
+ operation: "update",
1138
+ vars: { id },
1139
+ method: "PATCH",
1140
+ body: toDocument(ctx.descriptor, type, patch, { id }),
1141
+ query: writeQuery(opts),
1142
+ remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
1143
+ }),
1144
+ delete: () => runWrite(ctx, {
1145
+ type,
1146
+ operation: "delete",
1147
+ vars: { id },
1148
+ method: "DELETE",
1149
+ body: void 0,
1150
+ remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
1151
+ }),
1152
+ rel: (name) => relationshipAccessor(ctx, type, id, name),
1153
+ actions: actionsAccessor(ctx, type, "resource", { id })
1154
+ };
1155
+ return new Proxy(base, { get(target, prop, receiver) {
1156
+ if (typeof prop === "string" && !HANDLE_RESERVED.has(prop) && prop in relations) return relationshipAccessor(ctx, type, id, prop);
1157
+ return Reflect.get(target, prop, receiver);
1158
+ } });
1159
+ }
1160
+ /** Build the collection-scoped accessor for one wire type (`client.albums`). */
1161
+ function typeAccessor(ctx, type) {
1162
+ return {
1163
+ list: (query) => run(ctx, type, "fetchMany", {}, query),
1164
+ get: (id, query) => run(ctx, type, "fetchOne", { id }, query),
1165
+ create: (input, opts) => runWrite(ctx, {
1166
+ type,
1167
+ operation: "create",
1168
+ vars: {},
1169
+ method: "POST",
1170
+ body: toDocument(ctx.descriptor, type, input),
1171
+ query: writeQuery(opts),
1172
+ remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
1173
+ }),
1174
+ id: (id) => resourceHandle(ctx, type, id),
1175
+ actions: actionsAccessor(ctx, type, "collection", {})
1176
+ };
1177
+ }
1178
+ /**
1179
+ * Build a descriptor-driven read client. Generic at runtime (a Proxy over the descriptor),
1180
+ * not per-type codegen: `client.<type>` yields a {@link TypeAccessor}, `.id(id)` a handle,
1181
+ * and a handle's relation accessors drive the relationship/related endpoints. Navigation
1182
+ * (`$next()`/`$prev()`, related links) is wired through `materialise`'s `navigate` seam,
1183
+ * which executes the (absolute) link and re-materialises.
1184
+ */
1185
+ function createClient(descriptor, options) {
1186
+ const request = {
1187
+ baseUrl: options.baseUrl,
1188
+ transport: options.transport ?? fetchTransport,
1189
+ ...options.headers !== void 0 ? { headers: options.headers } : {}
1190
+ };
1191
+ const validator = resolveValidator(options.validate);
1192
+ const ctx = {
1193
+ request,
1194
+ descriptor,
1195
+ materialise: {
1196
+ descriptor,
1197
+ navigate: async (url, linkage = false, paginator) => {
1198
+ const doc = await execute(request, {
1199
+ method: "GET",
1200
+ path: url
1201
+ });
1202
+ return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, linkage, void 0, paginator);
1203
+ },
1204
+ ...validator !== void 0 ? { validate: validator } : {}
1205
+ }
1206
+ };
1207
+ const atomicDescriptor = options.atomic ?? null;
1208
+ const atomic = (build) => {
1209
+ if (atomicDescriptor === null) throw new Error("This API does not expose an Atomic Operations endpoint");
1210
+ return runAtomic(request, ctx.materialise, descriptor, atomicDescriptor.path, build);
1211
+ };
1212
+ const accessors = /* @__PURE__ */ new Map();
1213
+ return new Proxy(Object.create(null), {
1214
+ get(_target, prop) {
1215
+ if (prop === "atomic") return atomic;
1216
+ if (typeof prop !== "string" || !(prop in descriptor)) return;
1217
+ let accessor = accessors.get(prop);
1218
+ if (accessor === void 0) {
1219
+ accessor = typeAccessor(ctx, prop);
1220
+ accessors.set(prop, accessor);
1221
+ }
1222
+ return accessor;
1223
+ },
1224
+ has(_target, prop) {
1225
+ return prop === "atomic" || typeof prop === "string" && prop in descriptor;
1226
+ }
1227
+ });
1228
+ }
1229
+ //#endregion
1230
+ export { ATOMIC_EXT, ATOMIC_MEDIA_TYPE, HANDLE_RESERVED, JSON_API_MEDIA_TYPE, JsonApiError, StructuralGuardError, assertJsonApiDocument, createClient, execute, fetchTransport, materialise, mediaType, relatedPaginatorKind, remapPointer, remapRelationshipPointer, resolveValidator, runAtomic, serializeQuery, toDocument, toRelationshipDocument, validateDocument, withRemappedAtomicPaths, withRemappedPaths, withRemappedRelationshipPaths };
1231
+
1232
+ //# sourceMappingURL=index.mjs.map