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