@haddowg/json-api-query 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,836 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/keys.ts
3
+ /**
4
+ * Recursively normalise a params value so semantically-equal params serialise identically:
5
+ * object keys are sorted; arrays keep order (order is meaningful in a list — `sort`, `include`);
6
+ * `undefined` members are dropped (an absent param == an unset one). Primitives pass through.
7
+ * Not JSON.stringify — the key stays a structured tuple so TanStack's structural matching and
8
+ * the devtools see real values, not an opaque string.
9
+ */
10
+ function normalizeParams(value) {
11
+ if (value === null || value === void 0) return null;
12
+ if (Array.isArray(value)) return value.map((v) => normalizeParams(v));
13
+ if (typeof value === "object") {
14
+ const out = {};
15
+ for (const key of Object.keys(value).sort()) {
16
+ const member = value[key];
17
+ if (member !== void 0) out[key] = normalizeParams(member);
18
+ }
19
+ return out;
20
+ }
21
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
22
+ return String(value);
23
+ }
24
+ /**
25
+ * Build a deterministic, hierarchical query key from `(type, operation, id?, rel?, params?)`.
26
+ * The fixed-position prefix (`type`, `operation`, then `id`, then `rel`) makes any ancestor a
27
+ * valid invalidation target; params (when present and non-empty) ride a single trailing
28
+ * normalised object so reorderings collapse to one key. An absent/empty params object adds no
29
+ * trailing segment, so a bare `list()` and `list({})` share a key.
30
+ */
31
+ function keyFor(target, params) {
32
+ const parts = [target.type, target.operation];
33
+ if (target.id !== void 0) parts.push(target.id);
34
+ if (target.rel !== void 0) parts.push(target.rel);
35
+ if (params !== void 0) {
36
+ const normalized = normalizeParams(params);
37
+ if (!isEmptyNormalized(normalized)) parts.push(normalized);
38
+ }
39
+ return parts;
40
+ }
41
+ /** True when a normalised params value carries no information (an empty object or array, or null). */
42
+ function isEmptyNormalized(value) {
43
+ if (value === null) return true;
44
+ if (Array.isArray(value)) return value.length === 0;
45
+ if (typeof value === "object") return Object.keys(value).length === 0;
46
+ return false;
47
+ }
48
+ /** The whole-type subtree prefix (`[type]`) — invalidates every operation/read of a type. */
49
+ function typeKey(type) {
50
+ return [type];
51
+ }
52
+ /** An operation subtree prefix (`[type, operation]`) — e.g. every `fetchMany` (list) of a type. */
53
+ function operationKey(type, operation) {
54
+ return [type, operation];
55
+ }
56
+ /**
57
+ * A single-resource subtree prefix (`[type, 'fetchOne', id]`) — matches that resource's reads.
58
+ * Pairs with {@link keyFor} (which places `id` at the same position), so it is a true prefix of
59
+ * every `fetchOne` key for the id regardless of its query params.
60
+ */
61
+ function resourceKey(type, id) {
62
+ return [
63
+ type,
64
+ "fetchOne",
65
+ id
66
+ ];
67
+ }
68
+ /**
69
+ * A single-relation read prefix (`[type, operation, id, rel]`) for `fetchRelated` /
70
+ * `fetchRelationship` — matches one parent's reads of one relation, regardless of query params.
71
+ * Pairs with {@link keyFor} (which places `id` then `rel` at fixed positions), so it is a true
72
+ * prefix of every related/relationship key for that `(id, rel)`. Used for TARGETED invalidation of
73
+ * a parent's relation after a relationship mutation (never the type's collection lists — ADR 0003).
74
+ */
75
+ function relationKey(type, operation, id, rel) {
76
+ return [
77
+ type,
78
+ operation,
79
+ id,
80
+ rel
81
+ ];
82
+ }
83
+ /**
84
+ * BOTH read prefixes for one parent's one relation — its related-collection reads
85
+ * (`[type, 'fetchRelated', id, rel]`) AND its relationship-linkage reads
86
+ * (`[type, 'fetchRelationship', id, rel]`). A relation is read over two surfaces, so a single
87
+ * prefix can't cover both; this returns the pair so an app targets every cached page of a
88
+ * `(parent, relation)` — for invalidation or an optimistic membership patch — WITHOUT
89
+ * reconstructing keys (or coupling to the page params a read used). PUBLIC: it is the key helper
90
+ * the relationship-mutation optimistic path and apps share (D35b).
91
+ */
92
+ function relationReadKeys(type, id, rel) {
93
+ return [relationKey(type, "fetchRelated", id, rel), relationKey(type, "fetchRelationship", id, rel)];
94
+ }
95
+ /**
96
+ * True when `key` is prefixed by `prefix` (segment-by-segment string equality over the prefix's
97
+ * length). The cache-walking optimistic/invalidation paths use it to find every page variant of a
98
+ * `(parent, relation)` read (whose params ride the key's trailing segment). Mirrors TanStack's own
99
+ * prefix-based `queryKey` matching so a targeted patch and TanStack's `invalidateQueries` agree.
100
+ */
101
+ function keyHasPrefix(key, prefix) {
102
+ if (key.length < prefix.length) return false;
103
+ for (let i = 0; i < prefix.length; i++) if (key[i] !== prefix[i]) return false;
104
+ return true;
105
+ }
106
+ //#endregion
107
+ //#region src/normalize.ts
108
+ const resourceKey$1 = (type, id) => `${type}:${id}`;
109
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
110
+ /** A materialised resource's `type`+`id`, when it is a real resource (string type + id). */
111
+ function identity(node) {
112
+ const { type, id } = node;
113
+ if (typeof type === "string" && typeof id === "string") return {
114
+ type,
115
+ id
116
+ };
117
+ }
118
+ /**
119
+ * The freshest attributes for a node, read from its `$raw.attributes` (the wire split of
120
+ * attributes from relations). Returns `undefined` when the node carries no `$raw` or no
121
+ * attributes block (a bare identifier view, or an attribute-less resource) — nothing to
122
+ * propagate. Never falls back to enumerating own props, which would conflate relation slots
123
+ * with attributes.
124
+ */
125
+ function attributesOf(node) {
126
+ const attrs = node.$raw?.attributes;
127
+ return isObject(attrs) ? attrs : void 0;
128
+ }
129
+ /**
130
+ * Walk a materialised value (a single resource, an augmented array, a to-one/to-many relation
131
+ * value, or `null`/`undefined`) and visit every reachable resource exactly once. Recurses into
132
+ * each resource's own enumerable relation slots so a deeply-included graph is fully indexed.
133
+ * `seen` guards relation cycles (a node memoised by reference) and keeps the walk linear.
134
+ */
135
+ function walkResources(value, descriptor, visit, seen = /* @__PURE__ */ new Set()) {
136
+ if (value === null || value === void 0) return;
137
+ if (Array.isArray(value)) {
138
+ for (const member of value) walkResources(member, descriptor, visit, seen);
139
+ return;
140
+ }
141
+ if (!isObject(value)) return;
142
+ if (seen.has(value)) return;
143
+ seen.add(value);
144
+ const node = value;
145
+ const ident = identity(node);
146
+ if (ident === void 0) return;
147
+ visit(node);
148
+ const relations = descriptor[ident.type]?.relations;
149
+ if (relations === void 0) return;
150
+ for (const name of Object.keys(relations)) {
151
+ const slot = value[name];
152
+ if (slot !== void 0) walkResources(slot, descriptor, visit, seen);
153
+ }
154
+ }
155
+ /**
156
+ * Patch one materialised node IN PLACE from `attributes`: overwrite each attribute key on the
157
+ * target's own enumerable props (relations and `type`/`id` are left untouched), and shallow-set
158
+ * any new keys. The non-enumerable `$`-accessors and the per-edge `$pivot`/`$edge` getters are
159
+ * defined separately (closing over the original wire member), so they survive unchanged — a
160
+ * per-edge view keeps its own pivot while its shared attributes update.
161
+ *
162
+ * `$raw` is intentionally NOT mutated: it is the immutable original wire object (the escape
163
+ * hatch). Only the flattened, own-enumerable attribute props — the values consumers read —
164
+ * are patched. Returns whether any value actually changed, so an idempotent re-application
165
+ * (e.g. installNormalization re-running after a factory's own `onSuccess`) reports no change and
166
+ * is not written back / re-notified.
167
+ */
168
+ function patchNode(target, attributes) {
169
+ let changed = false;
170
+ for (const [k, v] of Object.entries(attributes)) if (!(k in target) || target[k] !== v) {
171
+ target[k] = v;
172
+ changed = true;
173
+ }
174
+ return changed;
175
+ }
176
+ /**
177
+ * Index every resource in `result` by `type:id` → its freshest attributes, then return the
178
+ * set of `type:id`s the result carries. The caller patches every cached query holding any of
179
+ * those keys (see {@link normalize}).
180
+ */
181
+ function collectAttributes(result, descriptor) {
182
+ const fresh = /* @__PURE__ */ new Map();
183
+ walkResources(result, descriptor, (node) => {
184
+ const ident = identity(node);
185
+ if (ident === void 0) return;
186
+ const attributes = attributesOf(node);
187
+ if (attributes !== void 0) fresh.set(resourceKey$1(ident.type, ident.id), attributes);
188
+ });
189
+ return fresh;
190
+ }
191
+ /**
192
+ * Patch every materialised node in `cached` whose `type:id` appears in `fresh`, in place,
193
+ * with the freshest attributes — returning whether any VALUE actually changed. We mutate the
194
+ * cached objects directly (preserving their identity/accessors) rather than rebuilding them, so
195
+ * writing the same reference back triggers TanStack's subscribers without a structural-sharing diff
196
+ * fighting the per-edge views. A node whose attributes already equal `fresh` is a no-op (reports
197
+ * unchanged), so a redundant re-application doesn't write back or re-notify.
198
+ */
199
+ function patchCached(cached, fresh, descriptor) {
200
+ let changed = false;
201
+ walkResources(cached, descriptor, (node) => {
202
+ const ident = identity(node);
203
+ if (ident === void 0) return;
204
+ const attributes = fresh.get(resourceKey$1(ident.type, ident.id));
205
+ if (attributes !== void 0 && patchNode(node, attributes)) changed = true;
206
+ });
207
+ return changed;
208
+ }
209
+ /**
210
+ * Write-through patch (the heart of Strategy 2). Given a freshly-resolved materialised
211
+ * `result`, index its resources by `type:id` and patch every OTHER cached query that holds any
212
+ * of those `type:id`s in place, so a single response updates the same resource everywhere it
213
+ * is cached ("edit once, updates everywhere").
214
+ *
215
+ * Edge-local `$pivot`/`$edge` and a node's identity are preserved (only own-enumerable
216
+ * attribute props are overwritten). Pass `result` from an option factory's `onSuccess` (or use
217
+ * {@link installNormalization} to subscribe to every query/mutation success automatically).
218
+ *
219
+ * `result` is the materialised value the client produced for this response (a resource, a
220
+ * `Collection`, a related collection, or linkage). The same reference may also be the cached
221
+ * entry for the query that produced it — we mutate it too (idempotently), which is harmless and
222
+ * keeps every surface holding that resource consistent.
223
+ *
224
+ * CONTRACT: a single QueryClient must hold caches for ONE descriptor only. The patch matches a
225
+ * cached node purely by `type:id` and rewrites it with `descriptor`'s attribute split, so two
226
+ * descriptors that share a QueryClient and a `type:id` (e.g. a `default` + an `admin` server whose
227
+ * `users` differ) would cross-contaminate. `installNormalization` enforces this for the auto path;
228
+ * when calling `normalize` standalone, give each typed client/server its own QueryClient.
229
+ */
230
+ function normalize(queryClient, result, descriptor) {
231
+ const fresh = collectAttributes(result, descriptor);
232
+ if (fresh.size === 0) return;
233
+ patchAll(queryClient, fresh, descriptor);
234
+ }
235
+ /**
236
+ * Patch every cached query with a `type:id -> fresh attributes` map. We walk the query cache
237
+ * explicitly and only `setQueryData` back the queries that {@link patchCached} actually mutated —
238
+ * NOT a blanket `setQueriesData({}, ...)`. The blanket form writes data back to every cached
239
+ * query, and TanStack's `setQueryData` unconditionally clears `isInvalidated` on the written
240
+ * query: a normalize pass over one type would silently un-invalidate untouched queries of other
241
+ * types, undoing the create/delete membership invalidation (ADR 0003). Gating the write on the
242
+ * `changed` flag keeps touched queries' in-place identity (so their observers notify) while
243
+ * leaving untouched queries — and their `isInvalidated` flags — completely alone.
244
+ */
245
+ function patchAll(queryClient, fresh, descriptor) {
246
+ for (const query of queryClient.getQueryCache().getAll()) {
247
+ const data = query.state.data;
248
+ if (data === void 0 || data === null) continue;
249
+ if (patchCached(data, fresh, descriptor)) queryClient.setQueryData(query.queryKey, data);
250
+ }
251
+ }
252
+ /**
253
+ * Apply an OPTIMISTIC attribute patch to a single `type:id` across every cached query and return a
254
+ * {@link Snapshot} to roll it back. Mirrors the success path ({@link normalize}) — same in-place
255
+ * attribute overwrite, same edge preservation — but for one known resource whose fresh attributes
256
+ * the caller predicts (an update patch's attribute keys), and it captures the prior state first so
257
+ * an error can restore it. Used by the update / relationship-set mutation factories' `onMutate`.
258
+ */
259
+ function applyOptimisticPatch(queryClient, type, id, attributes, descriptor) {
260
+ const key = resourceKey$1(type, id);
261
+ const edits = [];
262
+ for (const query of queryClient.getQueryCache().getAll()) {
263
+ const data = query.state.data;
264
+ if (data === void 0 || data === null) continue;
265
+ let touched = false;
266
+ walkResources(data, descriptor, (node) => {
267
+ const ident = identity(node);
268
+ if (ident === void 0 || resourceKey$1(ident.type, ident.id) !== key) return;
269
+ const target = node;
270
+ const prior = /* @__PURE__ */ new Map();
271
+ const added = /* @__PURE__ */ new Set();
272
+ for (const k of Object.keys(attributes)) if (k in target) prior.set(k, target[k]);
273
+ else added.add(k);
274
+ edits.push({
275
+ target,
276
+ prior,
277
+ added,
278
+ wrote: { ...attributes }
279
+ });
280
+ patchNode(target, attributes);
281
+ touched = true;
282
+ });
283
+ if (touched) queryClient.setQueryData(query.queryKey, data);
284
+ }
285
+ return { restore() {
286
+ for (const edit of edits) {
287
+ for (const [k, v] of edit.prior) if (edit.target[k] === edit.wrote[k]) edit.target[k] = v;
288
+ for (const k of edit.added) if (edit.target[k] === edit.wrote[k]) delete edit.target[k];
289
+ }
290
+ } };
291
+ }
292
+ /**
293
+ * Apply an OPTIMISTIC membership change to EVERY cached read of one parent's one relation — both its
294
+ * related-collection (`fetchRelated`) and its relationship-linkage (`fetchRelationship`) reads,
295
+ * matched BY KEY PREFIX so every page/param variant is covered at once (no coupling to the page
296
+ * params a read used — the pain point D35b removes). The to-many read caches an array of members, so
297
+ * the transform runs over that array; the snapshot restores each touched query's PRIOR data
298
+ * wholesale on error. On settle the factory invalidates the relation subtree, so the server
299
+ * reconciles regardless (a degraded optimistic member — an identifier without attributes — is
300
+ * replaced by the real one). A to-one `set` is handled separately (it caches a single value, not an
301
+ * array).
302
+ */
303
+ function applyOptimisticMembership(queryClient, type, id, rel, transform) {
304
+ const prefixes = relationReadKeys(type, id, rel);
305
+ const edits = [];
306
+ for (const query of queryClient.getQueryCache().getAll()) {
307
+ const key = query.queryKey;
308
+ if (!prefixes.some((prefix) => keyHasPrefix(key, prefix))) continue;
309
+ const data = query.state.data;
310
+ if (!Array.isArray(data)) continue;
311
+ edits.push({
312
+ key,
313
+ previous: data
314
+ });
315
+ queryClient.setQueryData(key, transform(data));
316
+ }
317
+ return { restore() {
318
+ for (const edit of edits) queryClient.setQueryData(edit.key, edit.previous);
319
+ } };
320
+ }
321
+ /** True when two refs address the same resource (`type:id`). Ignores `$pivot`/attributes. */
322
+ function sameRef(a, b) {
323
+ return a.type === b.type && a.id === b.id;
324
+ }
325
+ /**
326
+ * Apply an OPTIMISTIC to-one `set` to every cached relationship/related read of one parent's one
327
+ * relation (by key prefix): swap each cached value to `next` (a bare identifier ref, or `null` to
328
+ * clear), snapshotting the prior value for rollback. A to-one read caches a single value (not an
329
+ * array), so a plain replacement is correct; the settle refetch reconciles a bare identifier into
330
+ * the fully-hydrated resource. `undefined` next means the ref carried no cache identity (an
331
+ * lid-only atomic handle) — the cache is left untouched (the wire write still happens).
332
+ */
333
+ function applyOptimisticToOne(queryClient, type, id, rel, next) {
334
+ const prefixes = relationReadKeys(type, id, rel);
335
+ const edits = [];
336
+ if (next === void 0) return { restore() {} };
337
+ for (const query of queryClient.getQueryCache().getAll()) {
338
+ const key = query.queryKey;
339
+ if (!prefixes.some((prefix) => keyHasPrefix(key, prefix))) continue;
340
+ const data = query.state.data;
341
+ if (Array.isArray(data)) continue;
342
+ edits.push({
343
+ key,
344
+ previous: data
345
+ });
346
+ queryClient.setQueryData(key, next);
347
+ }
348
+ return { restore() {
349
+ for (const edit of edits) queryClient.setQueryData(edit.key, edit.previous);
350
+ } };
351
+ }
352
+ //#endregion
353
+ //#region src/install.ts
354
+ /**
355
+ * Which descriptor is currently installed on a given QueryClient. The normalizer patches a cached
356
+ * query by walking it with the *patch's* descriptor and matching on `type:id`, so two descriptors
357
+ * sharing one QueryClient (e.g. a `default` + an `admin` server whose `users` carry different
358
+ * attribute sets — CONTEXT.md's multi-client case) would cross-contaminate: a read from one would
359
+ * overwrite the other's cached node with foreign attributes. The supported contract is therefore
360
+ * ONE descriptor per QueryClient; this registry enforces it for the auto-install entry point.
361
+ * A `WeakMap` so a discarded QueryClient (and its install record) is garbage-collected.
362
+ */
363
+ const installed = /* @__PURE__ */ new WeakMap();
364
+ /**
365
+ * Subscribe the normalizer to a QueryClient's query + mutation caches: each time a query or
366
+ * mutation settles successfully, the freshly-resolved data is indexed by `type:id` and patched
367
+ * across every cached query in place (preserving edge-local `$pivot`/`$edge`).
368
+ *
369
+ * A QueryClient may host only ONE descriptor (see {@link installed}): installing a *second*,
370
+ * different descriptor on the same QueryClient throws, because the normalizer cannot safely tell
371
+ * which descriptor a given `type:id` belongs to once two descriptors overlap. Use one QueryClient
372
+ * per typed client/server. Re-installing the *same* descriptor is allowed (idempotent-friendly):
373
+ * it just adds another subscription pair with its own teardown.
374
+ *
375
+ * Re-entrancy is guarded: the patch pass itself sets data on other queries (firing further
376
+ * `success` events), so while a pass runs we ignore the events it generates — otherwise a
377
+ * single response would cascade into a loop. Returns a {@link Teardown} that removes both
378
+ * subscriptions (and clears the install record when this was the active install).
379
+ */
380
+ function installNormalization(queryClient, descriptor) {
381
+ const existing = installed.get(queryClient);
382
+ if (existing !== void 0 && existing !== descriptor) throw new Error("installNormalization: this QueryClient already has a different descriptor installed. A QueryClient may host only one descriptor — use a separate QueryClient per client/server so a normalize pass cannot leak one descriptor’s attributes into another’s cached resources.");
383
+ installed.set(queryClient, descriptor);
384
+ let normalizing = false;
385
+ const run = (data) => {
386
+ if (normalizing || data === void 0 || data === null) return;
387
+ normalizing = true;
388
+ try {
389
+ normalize(queryClient, data, descriptor);
390
+ } finally {
391
+ normalizing = false;
392
+ }
393
+ };
394
+ const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
395
+ if (event.type === "updated" && event.action.type === "success") run(event.query.state.data);
396
+ });
397
+ const unsubscribeMutations = queryClient.getMutationCache().subscribe((event) => {
398
+ if (event.type === "updated" && event.action.type === "success") run(event.mutation.state.data);
399
+ });
400
+ let torn = false;
401
+ return () => {
402
+ if (torn) return;
403
+ torn = true;
404
+ unsubscribeQueries();
405
+ unsubscribeMutations();
406
+ if (installed.get(queryClient) === descriptor) installed.delete(queryClient);
407
+ };
408
+ }
409
+ //#endregion
410
+ //#region src/mutate.ts
411
+ /**
412
+ * Invalidate the list/collection subtree of a type (`[type, 'fetchMany']`) — refetched after a
413
+ * create/delete changes which resources the collection holds. A prefix match, so every cached
414
+ * list of the type (any filter/sort/page) is marked stale at once.
415
+ */
416
+ function invalidateLists(queryClient, type) {
417
+ return queryClient.invalidateQueries({ queryKey: operationKey(type, "fetchMany") });
418
+ }
419
+ /**
420
+ * Invalidate every read OF a single resource on delete: its `fetchOne` reads (so a stale `get`
421
+ * refetches and 404s/clears) AND its own `fetchRelated`/`fetchRelationship` reads (orphaned once
422
+ * the resource is gone — `[type, 'fetchOne', id]` does not prefix those). Prefix matches across
423
+ * include/fields/rel. NOTE: this covers the deleted resource's OWN reads; OTHER resources whose
424
+ * relationship/related queries listed this one as a member are not auto-invalidated here (the
425
+ * binding can't know which parents hold it without a cache scan — invalidate those explicitly, or
426
+ * model the parent's own relationship mutation which DOES target it).
427
+ */
428
+ function invalidateResource(queryClient, type, id) {
429
+ return Promise.all([
430
+ queryClient.invalidateQueries({ queryKey: resourceKey(type, id) }),
431
+ queryClient.invalidateQueries({ queryKey: [
432
+ type,
433
+ "fetchRelated",
434
+ id
435
+ ] }),
436
+ queryClient.invalidateQueries({ queryKey: [
437
+ type,
438
+ "fetchRelationship",
439
+ id
440
+ ] })
441
+ ]).then(() => void 0);
442
+ }
443
+ /**
444
+ * Invalidate one parent resource's reads of one relation after a set/replace/add/remove changes
445
+ * that relation's linkage/membership (ADR 0003's TARGETED invalidation). We invalidate exactly:
446
+ * - the relation's related-collection reads (`[type, 'fetchRelated', id, rel]`),
447
+ * - the relation's linkage reads (`[type, 'fetchRelationship', id, rel]`),
448
+ * - the parent resource's own reads (`[type, 'fetchOne', id]`) — whose `?include` of the relation
449
+ * embeds the now-stale members.
450
+ * It deliberately does NOT touch `[type, 'fetchMany']` (the type's collection lists): a relationship
451
+ * change never alters which resources a collection holds, so scoping to the type would needlessly
452
+ * invalidate every list of the type.
453
+ */
454
+ function invalidateParentRelations(queryClient, type, id, rel) {
455
+ return Promise.all([
456
+ queryClient.invalidateQueries({ queryKey: relationKey(type, "fetchRelated", id, rel) }),
457
+ queryClient.invalidateQueries({ queryKey: relationKey(type, "fetchRelationship", id, rel) }),
458
+ queryClient.invalidateQueries({ queryKey: resourceKey(type, id) })
459
+ ]).then(() => void 0);
460
+ }
461
+ /**
462
+ * The create mutation (`POST /{type}`): `mutationFn` runs `client.<type>.create(input, opts)`;
463
+ * on settle the type's list subtree is invalidated (a new member changes collection membership —
464
+ * a patch can't insert it, ADR 0003). The fresh resource is also normalized on success so any
465
+ * query already holding the (server-assigned-id) resource reflects it.
466
+ *
467
+ * No optimistic insert by default: inserting into the right cached lists requires knowing each
468
+ * list's sort/filter/page, which is list-specific — we invalidate instead (the documented,
469
+ * always-correct default). A caller wanting optimism supplies their own `onMutate`.
470
+ */
471
+ function createMutationOptions(queryClient, client, descriptor, type, opts) {
472
+ const accessor = client[type];
473
+ return {
474
+ mutationFn: (input) => accessor.create(input, opts),
475
+ onSuccess: (data) => {
476
+ normalize(queryClient, data, descriptor);
477
+ },
478
+ onSettled: () => invalidateLists(queryClient, type)
479
+ };
480
+ }
481
+ /**
482
+ * The update mutation (`PATCH /{type}/{id}`): `mutationFn` runs
483
+ * `client.<type>.id(id).update(patch, opts)`. An update only changes a node's attributes/linkage,
484
+ * so on success it PATCHES across the cache via {@link normalize} (no refetch). When optimistic,
485
+ * `onMutate` pre-applies the patch's attributes (those keys that are declared attributes of the
486
+ * type — relation slots are skipped) through the normalized patch and snapshots the touched
487
+ * queries; `onError` restores the snapshot.
488
+ *
489
+ * @param optimistic apply the patch's attributes immediately (rolled back on error). Default off.
490
+ */
491
+ function updateMutationOptions(queryClient, client, descriptor, type, id, opts) {
492
+ const accessor = client[type];
493
+ const optimistic = opts?.optimistic === true;
494
+ const base = {
495
+ mutationFn: (patch) => accessor.id(id).update(patch, opts),
496
+ onSuccess: (data) => {
497
+ normalize(queryClient, data, descriptor);
498
+ }
499
+ };
500
+ if (!optimistic) return base;
501
+ base.onMutate = (patch) => ({ snapshot: applyOptimisticPatch(queryClient, type, id, attributesFromPatch(descriptor, type, patch), descriptor) });
502
+ base.onError = (_error, _patch, context) => {
503
+ context?.snapshot.restore();
504
+ };
505
+ return base;
506
+ }
507
+ /**
508
+ * The delete mutation (`DELETE /{type}/{id}`): `mutationFn` runs `client.<type>.id(id).delete()`
509
+ * (resolves `void`). On settle the type's list subtree AND that resource's reads are invalidated
510
+ * (a delete changes collection membership and orphans the resource's `get`, ADR 0003).
511
+ */
512
+ function deleteMutationOptions(queryClient, client, type, id) {
513
+ const accessor = client[type];
514
+ return {
515
+ mutationFn: () => accessor.id(id).delete(),
516
+ onSettled: async () => {
517
+ await invalidateLists(queryClient, type);
518
+ await invalidateResource(queryClient, type, id);
519
+ }
520
+ };
521
+ }
522
+ /** True when `opts` requests the optimistic membership patch. */
523
+ const isOptimistic = (opts) => opts?.optimistic === true;
524
+ /**
525
+ * Coerce a linkage ref (`{type,id}` / `{type,lid}` / a resource view, optionally `$pivot`) to a
526
+ * membership ref for the optimistic array patch. The whole ref is preserved (so a hydrated member
527
+ * carrying attributes yields a rich optimistic row), requiring only a string `type`+`id`. A ref
528
+ * addressed only by `lid` (an atomic handle) has no cache identity, so it is dropped from the
529
+ * optimistic view (it still writes on the wire).
530
+ */
531
+ function toMembershipRef(ref) {
532
+ const r = ref;
533
+ if (typeof r.type !== "string" || typeof r.id !== "string") return;
534
+ return {
535
+ ...ref,
536
+ type: r.type,
537
+ id: r.id
538
+ };
539
+ }
540
+ /** The membership refs (cache-addressable) of a to-many mutation's variables. */
541
+ const membershipRefs = (refs) => refs.map(toMembershipRef).filter((r) => r !== void 0);
542
+ /**
543
+ * Wire the optimistic membership lifecycle onto a to-many relationship mutation: `onMutate` applies
544
+ * `transform` to the parent's cached related/relationship arrays (snapshotting), `onError` rolls
545
+ * back, `onSettled` invalidates the relation subtree (kept from the base). When not optimistic, the
546
+ * base (invalidate-on-settle only) is returned untouched.
547
+ */
548
+ function withMembershipOptimism(queryClient, type, id, rel, base, transform, optimistic) {
549
+ if (!optimistic) return base;
550
+ return {
551
+ ...base,
552
+ onMutate: (vars) => ({ snapshot: applyOptimisticMembership(queryClient, type, id, rel, (current) => transform(current, vars)) }),
553
+ onError: (_error, _vars, context) => {
554
+ context?.snapshot.restore();
555
+ }
556
+ };
557
+ }
558
+ /**
559
+ * A to-one relationship `set` (`PATCH …/relationships/{rel}` with a single ref or `null`):
560
+ * `mutationFn` runs `client.<type>.id(id).rel(rel).set(ref)`. A `set` changes which resource the
561
+ * to-one points at — linkage, not collection membership and not a node attribute — so on settle
562
+ * we invalidate the PARENT's relations subtree (its `fetchRelationship`/`fetchRelated`/included
563
+ * reads refetch). The mutation response (linkage) is also normalized on success. When optimistic,
564
+ * `onMutate` swaps the parent's cached relationship/related reads to the new single value (or `null`)
565
+ * and rolls back on error.
566
+ */
567
+ function setRelationshipMutationOptions(queryClient, client, descriptor, type, id, rel, opts) {
568
+ const accessor = client[type];
569
+ const base = {
570
+ mutationFn: (ref) => accessor.id(id).rel(rel).set(ref),
571
+ onSuccess: (data) => {
572
+ normalize(queryClient, data, descriptor);
573
+ },
574
+ onSettled: () => invalidateParentRelations(queryClient, type, id, rel)
575
+ };
576
+ if (!isOptimistic(opts)) return base;
577
+ return {
578
+ ...base,
579
+ onMutate: (ref) => {
580
+ const next = ref === null ? void 0 : toMembershipRef(ref);
581
+ return { snapshot: applyOptimisticToOne(queryClient, type, id, rel, ref === null ? null : next) };
582
+ },
583
+ onError: (_error, _ref, context) => {
584
+ context?.snapshot.restore();
585
+ }
586
+ };
587
+ }
588
+ /**
589
+ * A to-many relationship `replace` (`PATCH …/relationships/{rel}` with the full new set):
590
+ * `mutationFn` runs `client.<type>.id(id).rel(rel).replace(refs)`. A full replacement resets the
591
+ * relation's membership, so on settle the parent's relations subtree is invalidated. The
592
+ * mutation response (the new linkage) is normalized on success. When optimistic, the cached
593
+ * membership is swapped to `refs` and rolled back on error.
594
+ */
595
+ function replaceRelationshipMutationOptions(queryClient, client, descriptor, type, id, rel, opts) {
596
+ const accessor = client[type];
597
+ return withMembershipOptimism(queryClient, type, id, rel, {
598
+ mutationFn: (refs) => accessor.id(id).rel(rel).replace(refs),
599
+ onSuccess: (data) => {
600
+ normalize(queryClient, data, descriptor);
601
+ },
602
+ onSettled: () => invalidateParentRelations(queryClient, type, id, rel)
603
+ }, (_current, refs) => membershipRefs(refs), isOptimistic(opts));
604
+ }
605
+ /**
606
+ * A to-many relationship `add` (`POST …/relationships/{rel}`): `mutationFn` runs
607
+ * `client.<type>.id(id).rel(rel).add(refs)`. Adding changes the relation's membership, so on
608
+ * settle the parent's relations subtree is invalidated (a patch can't insert linkage members).
609
+ * When optimistic, the added refs are appended to the cached membership (deduplicated by `type:id`)
610
+ * and rolled back on error.
611
+ */
612
+ function addRelationshipMutationOptions(queryClient, client, type, id, rel, opts) {
613
+ const accessor = client[type];
614
+ return withMembershipOptimism(queryClient, type, id, rel, {
615
+ mutationFn: (refs) => accessor.id(id).rel(rel).add(refs),
616
+ onSettled: () => invalidateParentRelations(queryClient, type, id, rel)
617
+ }, (current, refs) => {
618
+ const added = membershipRefs(refs);
619
+ return [...current.filter((m) => !added.some((a) => sameRef(a, m))), ...added];
620
+ }, isOptimistic(opts));
621
+ }
622
+ /**
623
+ * A to-many relationship `remove` (`DELETE …/relationships/{rel}`): `mutationFn` runs
624
+ * `client.<type>.id(id).rel(rel).remove(refs)`. Removing changes the relation's membership, so on
625
+ * settle the parent's relations subtree is invalidated. When optimistic, the removed refs are
626
+ * dropped from the cached membership and rolled back on error.
627
+ */
628
+ function removeRelationshipMutationOptions(queryClient, client, type, id, rel, opts) {
629
+ const accessor = client[type];
630
+ return withMembershipOptimism(queryClient, type, id, rel, {
631
+ mutationFn: (refs) => accessor.id(id).rel(rel).remove(refs),
632
+ onSettled: () => invalidateParentRelations(queryClient, type, id, rel)
633
+ }, (current, refs) => {
634
+ const removed = membershipRefs(refs);
635
+ return current.filter((m) => !removed.some((r) => sameRef(r, m)));
636
+ }, isOptimistic(opts));
637
+ }
638
+ /**
639
+ * A thin atomic-batch mutation (`POST /operations`): `mutationFn` forwards the `build` callback to
640
+ * `client.atomic(build)`, preserving the client's per-op `const`-tuple result inference (the
641
+ * variables ARE the build callback). On success the materialised results are run through
642
+ * {@link normalize} so any updated resource patches across the cache.
643
+ *
644
+ * INVALIDATION is the caller's responsibility for atomic: a batch can mix creates/updates/deletes
645
+ * across many types, and the recorded ops aren't enumerable after the build runs, so the binding
646
+ * can't know which list subtrees changed membership. Updates self-heal via the success
647
+ * normalization; for a batch that creates/deletes, add an `onSettled` that invalidates the
648
+ * affected `operationKey(type, 'fetchMany')` subtrees (or `queryClient.invalidateQueries()` for a
649
+ * blanket refetch). Kept deliberately minimal — atomic is an escape hatch, not a normalized path.
650
+ */
651
+ function atomicMutationOptions(queryClient, client, descriptor) {
652
+ return {
653
+ mutationFn: (build) => client.atomic(build),
654
+ onSuccess: (data) => {
655
+ normalize(queryClient, data, descriptor);
656
+ }
657
+ };
658
+ }
659
+ /**
660
+ * Split an update patch into just its ATTRIBUTE keys (for an optimistic node patch): a key is an
661
+ * attribute iff the descriptor declares it under `attributes` for the type. Relation slots and
662
+ * any unknown key are dropped — an optimistic patch only touches a node's shared attributes (the
663
+ * same split {@link normalize}'s real patch honours), never linkage.
664
+ */
665
+ function attributesFromPatch(descriptor, type, patch) {
666
+ const attributes = descriptor[type]?.attributes ?? {};
667
+ const out = {};
668
+ for (const [key, value] of Object.entries(patch)) if (key in attributes) out[key] = value;
669
+ return out;
670
+ }
671
+ /**
672
+ * Bind a client + QueryClient to the per-type mutation option factories:
673
+ * `createMutationApi(queryClient, client, descriptor).albums.id('1').update({ optimistic: true })`
674
+ * returns the same `MutationOptions` as the standalone {@link updateMutationOptions}, with the
675
+ * common arguments pre-applied. A Proxy (descriptor-keyed type validity is enforced by the static
676
+ * `MutationApi` shape) — no per-type allocation until first access.
677
+ */
678
+ function createMutationApi(queryClient, client, descriptor) {
679
+ const cache = /* @__PURE__ */ new Map();
680
+ return new Proxy(Object.create(null), { get(_target, prop) {
681
+ if (typeof prop !== "string") return;
682
+ let api = cache.get(prop);
683
+ if (api === void 0) {
684
+ api = typeMutationApi(queryClient, client, descriptor, prop);
685
+ cache.set(prop, api);
686
+ }
687
+ return api;
688
+ } });
689
+ }
690
+ /** Build one type's bound mutation option factories (delegates to the standalone factories). */
691
+ function typeMutationApi(queryClient, client, descriptor, type) {
692
+ return {
693
+ create: (opts) => createMutationOptions(queryClient, client, descriptor, type, opts),
694
+ id: (id) => ({
695
+ update: (opts) => updateMutationOptions(queryClient, client, descriptor, type, id, opts),
696
+ delete: () => deleteMutationOptions(queryClient, client, type, id),
697
+ rel: (name) => ({
698
+ add: (opts) => addRelationshipMutationOptions(queryClient, client, type, id, name, opts),
699
+ remove: (opts) => removeRelationshipMutationOptions(queryClient, client, type, id, name, opts),
700
+ replace: (opts) => replaceRelationshipMutationOptions(queryClient, client, descriptor, type, id, name, opts),
701
+ set: (opts) => setRelationshipMutationOptions(queryClient, client, descriptor, type, id, name, opts)
702
+ })
703
+ })
704
+ };
705
+ }
706
+ //#endregion
707
+ //#region src/read.ts
708
+ /**
709
+ * The collection-list read (`GET /{type}`): options resolving the typed `Collection`, narrowed by
710
+ * `include`/`fields` exactly as `client.<type>.list(query)`. The key is `[type, 'fetchMany',
711
+ * <params>]` — the normalised query rides the trailing segment, so two equal queries share a key.
712
+ */
713
+ function listQueryOptions(client, type, query) {
714
+ const accessor = client[type];
715
+ return {
716
+ queryKey: keyFor({
717
+ type,
718
+ operation: "fetchMany"
719
+ }, query),
720
+ queryFn: () => accessor.list(query)
721
+ };
722
+ }
723
+ /**
724
+ * The single-resource read (`GET /{type}/{id}`): options resolving the typed resource view,
725
+ * narrowed by `include`/`fields` as `client.<type>.get(id, query)`. Key: `[type, 'fetchOne', id,
726
+ * <params>]` — the `id` sits at a fixed position so {@link keyFor}'s prefix matches a resource's
727
+ * reads.
728
+ */
729
+ function getQueryOptions(client, type, id, query) {
730
+ const accessor = client[type];
731
+ return {
732
+ queryKey: keyFor({
733
+ type,
734
+ operation: "fetchOne",
735
+ id
736
+ }, query),
737
+ queryFn: () => accessor.get(id, query)
738
+ };
739
+ }
740
+ /**
741
+ * The relationship-linkage read (`GET /{type}/{id}/relationships/{rel}`): options resolving the
742
+ * materialised linkage (identifier members), via `client.<type>.id(id).rel(rel).get(query)`. Key:
743
+ * `[type, 'fetchRelationship', id, rel, <params>]`. The result is the client's runtime linkage
744
+ * shape; the relationship endpoint's per-relation value is loose at the `.rel(name)` boundary, so
745
+ * the option resolves `unknown` unless the caller annotates it (parity with the client's runtime).
746
+ */
747
+ function relationshipQueryOptions(client, type, id, rel, query) {
748
+ const accessor = client[type];
749
+ return {
750
+ queryKey: keyFor({
751
+ type,
752
+ operation: "fetchRelationship",
753
+ id,
754
+ rel
755
+ }, query),
756
+ queryFn: () => accessor.id(id).rel(rel).get(query)
757
+ };
758
+ }
759
+ /**
760
+ * The related-collection read (`GET /{type}/{id}/{rel}`): options resolving the materialised
761
+ * related resource(s), via `client.<type>.id(id).rel(rel).related(query)`. Key: `[type,
762
+ * 'fetchRelated', id, rel, <params>]`. As with linkage, the related value is loose at the
763
+ * `.rel(name)` boundary, so this resolves `unknown` (parity with the client runtime).
764
+ */
765
+ function relatedQueryOptions(client, type, id, rel, query) {
766
+ const accessor = client[type];
767
+ return {
768
+ queryKey: keyFor({
769
+ type,
770
+ operation: "fetchRelated",
771
+ id,
772
+ rel
773
+ }, query),
774
+ queryFn: () => accessor.id(id).rel(rel).related(query)
775
+ };
776
+ }
777
+ /**
778
+ * Bind a client to its per-type read-option factories: `createQueryApi(client).albums.list(query)`
779
+ * returns the same `{ queryKey, queryFn }` as `listQueryOptions(client, 'albums', query)`, with
780
+ * `client`/`type` pre-applied. A Proxy (descriptor-free — type validity is enforced by the static
781
+ * `QueryApi` shape) so no per-type allocation until first access.
782
+ */
783
+ function createQueryApi(client) {
784
+ const cache = /* @__PURE__ */ new Map();
785
+ return new Proxy(Object.create(null), { get(_target, prop) {
786
+ if (typeof prop !== "string") return;
787
+ let api = cache.get(prop);
788
+ if (api === void 0) {
789
+ api = typeQueryApi(client, prop);
790
+ cache.set(prop, api);
791
+ }
792
+ return api;
793
+ } });
794
+ }
795
+ /** Build one type's bound read-option factories (delegates to the standalone factories). */
796
+ function typeQueryApi(client, type) {
797
+ return {
798
+ list: (query) => listQueryOptions(client, type, query),
799
+ get: (id, query) => getQueryOptions(client, type, id, query),
800
+ relationship(id, rel, query) {
801
+ return relationshipQueryOptions(client, type, id, rel, query);
802
+ },
803
+ related(id, rel, query) {
804
+ return relatedQueryOptions(client, type, id, rel, query);
805
+ }
806
+ };
807
+ }
808
+ //#endregion
809
+ exports.addRelationshipMutationOptions = addRelationshipMutationOptions;
810
+ exports.applyOptimisticMembership = applyOptimisticMembership;
811
+ exports.applyOptimisticPatch = applyOptimisticPatch;
812
+ exports.applyOptimisticToOne = applyOptimisticToOne;
813
+ exports.atomicMutationOptions = atomicMutationOptions;
814
+ exports.createMutationApi = createMutationApi;
815
+ exports.createMutationOptions = createMutationOptions;
816
+ exports.createQueryApi = createQueryApi;
817
+ exports.deleteMutationOptions = deleteMutationOptions;
818
+ exports.getQueryOptions = getQueryOptions;
819
+ exports.installNormalization = installNormalization;
820
+ exports.keyFor = keyFor;
821
+ exports.keyHasPrefix = keyHasPrefix;
822
+ exports.listQueryOptions = listQueryOptions;
823
+ exports.normalize = normalize;
824
+ exports.normalizeParams = normalizeParams;
825
+ exports.operationKey = operationKey;
826
+ exports.relatedQueryOptions = relatedQueryOptions;
827
+ exports.relationKey = relationKey;
828
+ exports.relationReadKeys = relationReadKeys;
829
+ exports.relationshipQueryOptions = relationshipQueryOptions;
830
+ exports.removeRelationshipMutationOptions = removeRelationshipMutationOptions;
831
+ exports.replaceRelationshipMutationOptions = replaceRelationshipMutationOptions;
832
+ exports.resourceKey = resourceKey;
833
+ exports.sameRef = sameRef;
834
+ exports.setRelationshipMutationOptions = setRelationshipMutationOptions;
835
+ exports.typeKey = typeKey;
836
+ exports.updateMutationOptions = updateMutationOptions;