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