@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/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/index.cjs +836 -0
- package/dist/index.d.cts +438 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +438 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +810 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
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
|