@gleanql/client 0.1.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 +39 -0
- package/dist/index.d.mts +954 -0
- package/dist/index.mjs +1412 -0
- package/package.json +57 -0
- package/src/adapter-shared.ts +76 -0
- package/src/adapter-ws.ts +134 -0
- package/src/adapter.ts +152 -0
- package/src/cache-resolve.ts +98 -0
- package/src/cache.ts +341 -0
- package/src/context.ts +67 -0
- package/src/glue-client.ts +1000 -0
- package/src/glue-server.ts +46 -0
- package/src/index.ts +30 -0
- package/src/integration.ts +201 -0
- package/src/mutation.ts +171 -0
- package/src/mutator.ts +58 -0
- package/src/normalize.ts +101 -0
- package/src/paginate.ts +361 -0
- package/src/persisted.ts +73 -0
- package/src/proxy.ts +288 -0
- package/src/reactivity.ts +149 -0
- package/src/route.ts +84 -0
- package/src/runtime.ts +212 -0
- package/src/scope.ts +97 -0
- package/src/serialize.ts +175 -0
- package/src/testing.ts +220 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
import { argAliasSuffix, canonicalArgs, responseKey } from "@gleanql/core";
|
|
2
|
+
//#region src/adapter-shared.ts
|
|
3
|
+
/** Build a `GraphResult`, omitting empty `data`/`errors` keys. */
|
|
4
|
+
function result(data, errors) {
|
|
5
|
+
return {
|
|
6
|
+
...data != null ? { data } : {},
|
|
7
|
+
...errors && errors.length > 0 ? { errors } : {}
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Bridge a push-based transport (SSE `onmessage`, a graphql-ws sink) into the
|
|
12
|
+
* pull-based `AsyncIterator` the runtime consumes. The transport wires its callbacks
|
|
13
|
+
* to `push`/`finish`; the consumer's `return()` (cleanup) calls `onReturn` to tear the
|
|
14
|
+
* transport down. Values pushed with no pending pull queue up and drain in order.
|
|
15
|
+
*/
|
|
16
|
+
function pushPullIterator(onReturn) {
|
|
17
|
+
const queue = [];
|
|
18
|
+
let waiting = null;
|
|
19
|
+
let done = false;
|
|
20
|
+
const ended = () => ({
|
|
21
|
+
value: void 0,
|
|
22
|
+
done: true
|
|
23
|
+
});
|
|
24
|
+
const finish = () => {
|
|
25
|
+
if (done) return;
|
|
26
|
+
done = true;
|
|
27
|
+
if (waiting) {
|
|
28
|
+
waiting(ended());
|
|
29
|
+
waiting = null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
push(value) {
|
|
34
|
+
if (done) return;
|
|
35
|
+
if (waiting) {
|
|
36
|
+
waiting({
|
|
37
|
+
value,
|
|
38
|
+
done: false
|
|
39
|
+
});
|
|
40
|
+
waiting = null;
|
|
41
|
+
} else queue.push(value);
|
|
42
|
+
},
|
|
43
|
+
finish,
|
|
44
|
+
next() {
|
|
45
|
+
if (queue.length > 0) return Promise.resolve({
|
|
46
|
+
value: queue.shift(),
|
|
47
|
+
done: false
|
|
48
|
+
});
|
|
49
|
+
if (done) return Promise.resolve(ended());
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
waiting = resolve;
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
return() {
|
|
55
|
+
onReturn?.();
|
|
56
|
+
finish();
|
|
57
|
+
return Promise.resolve(ended());
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/adapter.ts
|
|
63
|
+
/**
|
|
64
|
+
* Client adapter interface. The graph runtime owns cache identity, Suspense,
|
|
65
|
+
* batching and normalization; an adapter owns transport (HTTP, auth, retries).
|
|
66
|
+
* Ships a plain fetch adapter; any transport (graphql-ws for subscriptions, or a
|
|
67
|
+
* urql/Apollo client if an app already runs one) slots in behind this interface.
|
|
68
|
+
*/
|
|
69
|
+
/** Minimal fetch transport. Context is used only to build headers; it is never serialized into the body. */
|
|
70
|
+
function createFetchAdapter(options) {
|
|
71
|
+
const doFetch = options.fetch ?? fetch;
|
|
72
|
+
return {
|
|
73
|
+
async execute(operation, variables, context) {
|
|
74
|
+
const post = async (body) => {
|
|
75
|
+
const res = await doFetch(options.endpoint, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {
|
|
78
|
+
"content-type": "application/json",
|
|
79
|
+
...options.headers ? options.headers(context) : {}
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify(body)
|
|
82
|
+
});
|
|
83
|
+
const parsed = await res.json().catch(() => void 0);
|
|
84
|
+
if (parsed === void 0) throw new Error(`graph fetch: non-JSON response (${res.status} ${res.statusText}) from ${options.endpoint}`);
|
|
85
|
+
return parsed;
|
|
86
|
+
};
|
|
87
|
+
if (options.persisted && operation.hash) {
|
|
88
|
+
const extensions = { persistedQuery: {
|
|
89
|
+
version: 1,
|
|
90
|
+
sha256Hash: operation.hash
|
|
91
|
+
} };
|
|
92
|
+
const first = await post({
|
|
93
|
+
operationName: operation.name,
|
|
94
|
+
variables,
|
|
95
|
+
extensions
|
|
96
|
+
});
|
|
97
|
+
if (first.errors?.some((e) => e.message === "PersistedQueryNotFound")) {
|
|
98
|
+
options.onPersistedRetry?.(operation.name);
|
|
99
|
+
return post({
|
|
100
|
+
query: operation.document,
|
|
101
|
+
operationName: operation.name,
|
|
102
|
+
variables,
|
|
103
|
+
extensions
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return first;
|
|
107
|
+
}
|
|
108
|
+
return post({
|
|
109
|
+
query: operation.document,
|
|
110
|
+
variables,
|
|
111
|
+
operationName: operation.name
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
subscribe(operation, variables) {
|
|
115
|
+
return sseIterable(options.subscriptionEndpoint ?? `${options.endpoint}/stream`, operation.document, variables, operation.name);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Bridge a Server-Sent Events stream into an `AsyncIterable<GraphResult>`. */
|
|
120
|
+
function sseIterable(url, document, variables, operationName) {
|
|
121
|
+
return { [Symbol.asyncIterator]() {
|
|
122
|
+
if (typeof EventSource === "undefined") return { next: () => Promise.resolve({
|
|
123
|
+
value: void 0,
|
|
124
|
+
done: true
|
|
125
|
+
}) };
|
|
126
|
+
const qs = `query=${encodeURIComponent(document)}&operationName=${encodeURIComponent(operationName)}&variables=${encodeURIComponent(JSON.stringify(variables ?? {}))}`;
|
|
127
|
+
const es = new EventSource(`${url}?${qs}`);
|
|
128
|
+
const it = pushPullIterator(() => es.close());
|
|
129
|
+
es.onmessage = (e) => {
|
|
130
|
+
try {
|
|
131
|
+
it.push(JSON.parse(e.data));
|
|
132
|
+
} catch {}
|
|
133
|
+
};
|
|
134
|
+
es.onerror = () => it.push({ errors: [{ message: "subscription stream error" }] });
|
|
135
|
+
return it;
|
|
136
|
+
} };
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/cache.ts
|
|
140
|
+
/** Separator joining a record key + field key into one field-tracking key (NUL — never in a key). */
|
|
141
|
+
const FIELD_SEP = "\0";
|
|
142
|
+
function fieldTrackingKey(recordKey, fieldKey) {
|
|
143
|
+
return recordKey + FIELD_SEP + fieldKey;
|
|
144
|
+
}
|
|
145
|
+
var GraphCache = class GraphCache {
|
|
146
|
+
maxRecords;
|
|
147
|
+
records = /* @__PURE__ */ new Map();
|
|
148
|
+
/**
|
|
149
|
+
* Optional LRU cap. The client cache accumulates entities across navigations; a
|
|
150
|
+
* long session would otherwise grow without bound. When set, the least-recently
|
|
151
|
+
* used records are evicted past the cap. Unset (default) = unbounded, so the
|
|
152
|
+
* server's per-request cache and existing callers are unchanged.
|
|
153
|
+
*/
|
|
154
|
+
constructor(maxRecords) {
|
|
155
|
+
this.maxRecords = maxRecords;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Reactivity substrate. Every write bumps `version` and notifies listeners, so
|
|
159
|
+
* UI can re-render after a mutation, refetch, or peer-tab/subscription update.
|
|
160
|
+
* `version` + `subscribe` are exactly the `useSyncExternalStore` contract.
|
|
161
|
+
*/
|
|
162
|
+
_version = 0;
|
|
163
|
+
listeners = /* @__PURE__ */ new Set();
|
|
164
|
+
/**
|
|
165
|
+
* Version counters for fine-grained reactivity, at two granularities. A component
|
|
166
|
+
* tracks the keys it read during render; its `useSyncExternalStore` snapshot is a
|
|
167
|
+
* digest of those keys' versions, so a global notify only re-renders the components
|
|
168
|
+
* whose keys actually changed (valtio's approach — no per-key subscription fan-out).
|
|
169
|
+
*
|
|
170
|
+
* - `recordVersions` bumps on ANY write to a record (record-level trackers, e.g.
|
|
171
|
+
* `usePaginated` watching a connection).
|
|
172
|
+
* - `fieldVersions` bumps only the written field (field-level trackers, e.g.
|
|
173
|
+
* `useGlean`, so reading `product.title` ignores a write to `product.views`).
|
|
174
|
+
*
|
|
175
|
+
* The global `version`/`subscribe` stay the notify channel; both granularities are
|
|
176
|
+
* resolved through {@link trackedVersion}.
|
|
177
|
+
*/
|
|
178
|
+
recordVersions = /* @__PURE__ */ new Map();
|
|
179
|
+
fieldVersions = /* @__PURE__ */ new Map();
|
|
180
|
+
/** Current version of a record (0 if never written). */
|
|
181
|
+
recordVersion(key) {
|
|
182
|
+
return this.recordVersions.get(key) ?? 0;
|
|
183
|
+
}
|
|
184
|
+
/** Current version of a single field on a record (0 if never written). */
|
|
185
|
+
fieldVersion(recordKey, fieldKey) {
|
|
186
|
+
return this.fieldVersions.get(fieldTrackingKey(recordKey, fieldKey)) ?? 0;
|
|
187
|
+
}
|
|
188
|
+
/** The opaque tracking key a field read records; resolve it with {@link trackedVersion}. */
|
|
189
|
+
fieldTrackingKey(recordKey, fieldKey) {
|
|
190
|
+
return fieldTrackingKey(recordKey, fieldKey);
|
|
191
|
+
}
|
|
192
|
+
/** Version of a tracked key: a bare record key, or `record\0field` for a single field. */
|
|
193
|
+
trackedVersion(trackingKey) {
|
|
194
|
+
return trackingKey.includes(FIELD_SEP) ? this.fieldVersions.get(trackingKey) ?? 0 : this.recordVersion(trackingKey);
|
|
195
|
+
}
|
|
196
|
+
/** Bump a record's version + (optionally) one of its fields' versions. */
|
|
197
|
+
bumpRecord(key, fieldKey) {
|
|
198
|
+
this.recordVersions.set(key, (this.recordVersions.get(key) ?? 0) + 1);
|
|
199
|
+
if (fieldKey !== void 0) this.bumpField(key, fieldKey);
|
|
200
|
+
}
|
|
201
|
+
bumpField(recordKey, fieldKey) {
|
|
202
|
+
const k = fieldTrackingKey(recordKey, fieldKey);
|
|
203
|
+
this.fieldVersions.set(k, (this.fieldVersions.get(k) ?? 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
get version() {
|
|
206
|
+
return this._version;
|
|
207
|
+
}
|
|
208
|
+
subscribe(listener) {
|
|
209
|
+
this.listeners.add(listener);
|
|
210
|
+
return () => this.listeners.delete(listener);
|
|
211
|
+
}
|
|
212
|
+
bump() {
|
|
213
|
+
this._version++;
|
|
214
|
+
for (const listener of this.listeners) listener();
|
|
215
|
+
}
|
|
216
|
+
/** Public notify: bump the version + run listeners (e.g. after `absorbRecords`). */
|
|
217
|
+
notify() {
|
|
218
|
+
this.bump();
|
|
219
|
+
}
|
|
220
|
+
/** Stable storage key for a ref: entity identity wins over path identity. */
|
|
221
|
+
recordKey(ref) {
|
|
222
|
+
if (ref.__typename != null && ref.id != null) return `${ref.__typename}:${ref.id}`;
|
|
223
|
+
if (ref.path != null) return `path:${ref.path}`;
|
|
224
|
+
throw new Error("GraphRef requires either (__typename + id) or path");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Reference-counted retention (Relay-style). A mounted reader retains the
|
|
228
|
+
* records it displays; retained records are never LRU-evicted and survive
|
|
229
|
+
* {@link gc}. The tracking hooks do this automatically — each component
|
|
230
|
+
* retains what it read while mounted — so `gc()` is safe to call any time
|
|
231
|
+
* (e.g. on navigation): it can only drop records nothing on screen reads.
|
|
232
|
+
*/
|
|
233
|
+
retainCounts = /* @__PURE__ */ new Map();
|
|
234
|
+
/** Pin a record. Returns the matching release; calling it twice is a no-op. */
|
|
235
|
+
retain(key) {
|
|
236
|
+
this.stamp(key);
|
|
237
|
+
this.retainCounts.set(key, (this.retainCounts.get(key) ?? 0) + 1);
|
|
238
|
+
let released = false;
|
|
239
|
+
return () => {
|
|
240
|
+
if (released) return;
|
|
241
|
+
released = true;
|
|
242
|
+
const n = this.retainCounts.get(key) ?? 0;
|
|
243
|
+
if (n <= 1) this.retainCounts.delete(key);
|
|
244
|
+
else this.retainCounts.set(key, n - 1);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
isRetained(key) {
|
|
248
|
+
return this.retainCounts.has(key);
|
|
249
|
+
}
|
|
250
|
+
/** The record key a tracked key belongs to (strips the `\0field` part, if any). */
|
|
251
|
+
trackedRecordKey(trackingKey) {
|
|
252
|
+
const i = trackingKey.indexOf(FIELD_SEP);
|
|
253
|
+
return i === -1 ? trackingKey : trackingKey.slice(0, i);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Generation clock for staleness-aware GC. The glue advances it on each page
|
|
257
|
+
* navigation; every read/write/retain stamps the record with the current
|
|
258
|
+
* epoch. "Unretained" alone is NOT a reason to drop data (a back-navigation
|
|
259
|
+
* should hit a warm cache) — `gc({ keepEpochs })` drops only records that are
|
|
260
|
+
* unretained AND haven't been touched for that many generations.
|
|
261
|
+
*/
|
|
262
|
+
epoch = 0;
|
|
263
|
+
lastActive = /* @__PURE__ */ new Map();
|
|
264
|
+
/** Advance the generation clock (call on navigation). Returns the new epoch. */
|
|
265
|
+
advanceEpoch() {
|
|
266
|
+
return ++this.epoch;
|
|
267
|
+
}
|
|
268
|
+
stamp(key) {
|
|
269
|
+
this.lastActive.set(key, this.epoch);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Drop unretained records; returns how many were dropped. Version counters
|
|
273
|
+
* survive, so if a dropped record is refetched its trackers still see
|
|
274
|
+
* monotonic versions.
|
|
275
|
+
*
|
|
276
|
+
* - `gc()` — drop EVERY unretained record (a full reset, e.g. logout).
|
|
277
|
+
* - `gc({ keepEpochs: N })` — drop only records also untouched for ≥ N
|
|
278
|
+
* generations (see {@link advanceEpoch}); recently-used data stays warm
|
|
279
|
+
* for back-navigation even though nothing on screen retains it.
|
|
280
|
+
*/
|
|
281
|
+
gc(options = {}) {
|
|
282
|
+
const { keepEpochs } = options;
|
|
283
|
+
let dropped = 0;
|
|
284
|
+
for (const key of [...this.records.keys()]) {
|
|
285
|
+
if (this.retainCounts.has(key)) continue;
|
|
286
|
+
if (keepEpochs != null && this.epoch - (this.lastActive.get(key) ?? 0) < keepEpochs) continue;
|
|
287
|
+
this.records.delete(key);
|
|
288
|
+
this.lastActive.delete(key);
|
|
289
|
+
dropped++;
|
|
290
|
+
}
|
|
291
|
+
if (dropped > 0) this.bump();
|
|
292
|
+
return dropped;
|
|
293
|
+
}
|
|
294
|
+
hasRecord(ref) {
|
|
295
|
+
return this.records.has(this.recordKey(ref));
|
|
296
|
+
}
|
|
297
|
+
/** Mark a key most-recently-used (Map keeps insertion order; re-insert to bump). No-op when unbounded. */
|
|
298
|
+
touch(key) {
|
|
299
|
+
if (!this.maxRecords) return;
|
|
300
|
+
const rec = this.records.get(key);
|
|
301
|
+
if (rec) {
|
|
302
|
+
this.records.delete(key);
|
|
303
|
+
this.records.set(key, rec);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Evict least-recently-used records past the cap (Map's first key is the
|
|
308
|
+
* oldest), skipping retained records — a record someone on screen reads is
|
|
309
|
+
* never the eviction victim, even if it's the coldest. If retained records
|
|
310
|
+
* alone exceed the cap, the cache temporarily runs over it.
|
|
311
|
+
*/
|
|
312
|
+
evict() {
|
|
313
|
+
if (!this.maxRecords) return;
|
|
314
|
+
let over = this.records.size - this.maxRecords;
|
|
315
|
+
if (over <= 0) return;
|
|
316
|
+
for (const key of [...this.records.keys()]) {
|
|
317
|
+
if (over <= 0) break;
|
|
318
|
+
if (this.retainCounts.has(key)) continue;
|
|
319
|
+
this.records.delete(key);
|
|
320
|
+
over--;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Get-or-create the record map for a storage key. */
|
|
324
|
+
ensureRecord(key) {
|
|
325
|
+
this.stamp(key);
|
|
326
|
+
let rec = this.records.get(key);
|
|
327
|
+
if (!rec) {
|
|
328
|
+
rec = /* @__PURE__ */ new Map();
|
|
329
|
+
this.records.set(key, rec);
|
|
330
|
+
} else this.touch(key);
|
|
331
|
+
return rec;
|
|
332
|
+
}
|
|
333
|
+
getField(ref, fieldKey) {
|
|
334
|
+
const key = this.recordKey(ref);
|
|
335
|
+
const rec = this.records.get(key);
|
|
336
|
+
if (rec && rec.has(fieldKey)) {
|
|
337
|
+
this.touch(key);
|
|
338
|
+
this.stamp(key);
|
|
339
|
+
return {
|
|
340
|
+
status: "ready",
|
|
341
|
+
value: rec.get(fieldKey)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return { status: "missing" };
|
|
345
|
+
}
|
|
346
|
+
setField(ref, fieldKey, value) {
|
|
347
|
+
const key = this.recordKey(ref);
|
|
348
|
+
this.ensureRecord(key).set(fieldKey, value);
|
|
349
|
+
this.bumpRecord(key, fieldKey);
|
|
350
|
+
this.evict();
|
|
351
|
+
this.bump();
|
|
352
|
+
}
|
|
353
|
+
/** Merge a flat record of fields into the entity/path record. */
|
|
354
|
+
merge(ref, fields) {
|
|
355
|
+
const key = this.recordKey(ref);
|
|
356
|
+
const rec = this.ensureRecord(key);
|
|
357
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
358
|
+
rec.set(k, v);
|
|
359
|
+
this.bumpField(key, k);
|
|
360
|
+
}
|
|
361
|
+
this.bumpRecord(key);
|
|
362
|
+
this.evict();
|
|
363
|
+
this.bump();
|
|
364
|
+
}
|
|
365
|
+
/** Drop a whole record (mutation invalidation). */
|
|
366
|
+
invalidate(ref) {
|
|
367
|
+
const key = this.recordKey(ref);
|
|
368
|
+
for (const fieldKey of this.records.get(key)?.keys() ?? []) this.bumpField(key, fieldKey);
|
|
369
|
+
this.records.delete(key);
|
|
370
|
+
this.bumpRecord(key);
|
|
371
|
+
this.bump();
|
|
372
|
+
}
|
|
373
|
+
/** Drop a single field so the next read re-fetches it. */
|
|
374
|
+
invalidateField(ref, fieldKey) {
|
|
375
|
+
const key = this.recordKey(ref);
|
|
376
|
+
this.records.get(key)?.delete(fieldKey);
|
|
377
|
+
this.bumpRecord(key, fieldKey);
|
|
378
|
+
this.bump();
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Fold a serialized snapshot into THIS cache, field-by-field, WITHOUT replacing
|
|
382
|
+
* existing records and WITHOUT notifying. Returns whether anything was
|
|
383
|
+
* added/changed. The caller decides when to `notify()` — so a render-phase merge
|
|
384
|
+
* can write records (visible to synchronous reads) yet defer the subscriber bump
|
|
385
|
+
* to a commit-phase effect. Idempotent: re-absorbing the same snapshot is a no-op.
|
|
386
|
+
*/
|
|
387
|
+
absorbRecords(snapshot) {
|
|
388
|
+
let changed = false;
|
|
389
|
+
for (const [key, fields] of Object.entries(snapshot)) {
|
|
390
|
+
const rec = this.ensureRecord(key);
|
|
391
|
+
let recordChanged = false;
|
|
392
|
+
for (const [k, v] of Object.entries(fields)) if (!rec.has(k) || rec.get(k) !== v) {
|
|
393
|
+
rec.set(k, v);
|
|
394
|
+
this.bumpField(key, k);
|
|
395
|
+
recordChanged = true;
|
|
396
|
+
}
|
|
397
|
+
if (recordChanged) {
|
|
398
|
+
this.bumpRecord(key);
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
this.evict();
|
|
403
|
+
return changed;
|
|
404
|
+
}
|
|
405
|
+
/** Serialize the whole cache (for hydration). */
|
|
406
|
+
snapshot() {
|
|
407
|
+
const out = {};
|
|
408
|
+
for (const [key, rec] of this.records) out[key] = Object.fromEntries(rec);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
static fromSnapshot(snapshot, maxRecords) {
|
|
412
|
+
const cache = new GraphCache(maxRecords);
|
|
413
|
+
for (const [key, rec] of Object.entries(snapshot)) cache.records.set(key, new Map(Object.entries(rec)));
|
|
414
|
+
cache.evict();
|
|
415
|
+
return cache;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/normalize.ts
|
|
420
|
+
const defaultKeyOf = (_t, obj) => obj.id != null ? String(obj.id) : void 0;
|
|
421
|
+
function normalizeValue(cache, value, anchor, field, keyOf = defaultKeyOf, seen = /* @__PURE__ */ new WeakSet()) {
|
|
422
|
+
if (value === null || typeof value !== "object") return value;
|
|
423
|
+
if (seen.has(value)) throw new Error(`normalizeValue: circular reference at ${anchor}.${field} — cannot normalize cyclic data`);
|
|
424
|
+
seen.add(value);
|
|
425
|
+
try {
|
|
426
|
+
return normalizeNonCyclic(cache, value, anchor, field, keyOf, seen);
|
|
427
|
+
} finally {
|
|
428
|
+
seen.delete(value);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function normalizeNonCyclic(cache, value, anchor, field, keyOf, seen) {
|
|
432
|
+
if (Array.isArray(value)) return value.map((item, i) => normalizeValue(cache, item, anchor, `${field}.${i}`, keyOf, seen));
|
|
433
|
+
const obj = value;
|
|
434
|
+
const typename = typeof obj.__typename === "string" ? obj.__typename : void 0;
|
|
435
|
+
const identity = typename != null ? keyOf(typename, obj) : void 0;
|
|
436
|
+
if (typename != null && identity != null) {
|
|
437
|
+
const ref = {
|
|
438
|
+
__typename: typename,
|
|
439
|
+
id: identity
|
|
440
|
+
};
|
|
441
|
+
const entityAnchor = cache.recordKey(ref);
|
|
442
|
+
for (const [key, v] of Object.entries(obj)) cache.setField(ref, key, normalizeValue(cache, v, entityAnchor, key, keyOf, seen));
|
|
443
|
+
return ref;
|
|
444
|
+
}
|
|
445
|
+
const ref = { path: `${anchor}.${field}` };
|
|
446
|
+
for (const [key, v] of Object.entries(obj)) cache.setField(ref, key, normalizeValue(cache, v, anchor, `${field}.${key}`, keyOf, seen));
|
|
447
|
+
return ref;
|
|
448
|
+
}
|
|
449
|
+
/** Seed an operation result; returns each root field's ref for reading. */
|
|
450
|
+
function seedResult(cache, data, options = {}) {
|
|
451
|
+
const rootPath = options.rootPath ?? "Query";
|
|
452
|
+
const roots = {};
|
|
453
|
+
for (const [field, value] of Object.entries(data)) roots[field] = normalizeValue(cache, value, rootPath, field, options.keyOf);
|
|
454
|
+
return roots;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/runtime.ts
|
|
458
|
+
var GraphRuntime = class GraphRuntime {
|
|
459
|
+
options;
|
|
460
|
+
cache;
|
|
461
|
+
pending = /* @__PURE__ */ new Map();
|
|
462
|
+
queue = [];
|
|
463
|
+
flushScheduled = false;
|
|
464
|
+
constructor(options) {
|
|
465
|
+
this.options = options;
|
|
466
|
+
this.cache = options.cache ?? new GraphCache(options.maxCacheRecords);
|
|
467
|
+
}
|
|
468
|
+
/** Synchronous on hit; throws a (cached) promise on miss. */
|
|
469
|
+
readField(ref, fieldKey, debug) {
|
|
470
|
+
const got = this.cache.getField(ref, fieldKey);
|
|
471
|
+
if (got.status === "ready") return got.value;
|
|
472
|
+
this.reportMiss(ref, fieldKey, debug);
|
|
473
|
+
const pkey = this.pendingKey(ref, fieldKey);
|
|
474
|
+
const existing = this.pending.get(pkey);
|
|
475
|
+
if (existing) throw existing.promise;
|
|
476
|
+
const entry = this.makeDeferred();
|
|
477
|
+
this.pending.set(pkey, entry);
|
|
478
|
+
this.queue.push({
|
|
479
|
+
ref,
|
|
480
|
+
fieldKey
|
|
481
|
+
});
|
|
482
|
+
this.scheduleFlush();
|
|
483
|
+
throw entry.promise;
|
|
484
|
+
}
|
|
485
|
+
/** Seed a record's fields (e.g. from the compiled operation result). */
|
|
486
|
+
seed(ref, fields) {
|
|
487
|
+
this.cache.merge(ref, fields);
|
|
488
|
+
}
|
|
489
|
+
/** Normalize a full operation result into the cache; returns root refs. */
|
|
490
|
+
seedResult(data, options) {
|
|
491
|
+
return seedResult(this.cache, data, {
|
|
492
|
+
keyOf: this.options.keyOf,
|
|
493
|
+
...options
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Low-level pagination primitive: append a freshly-fetched page onto a cached
|
|
498
|
+
* connection. Normalizes the page's `nodes` and concats them after the existing
|
|
499
|
+
* ones, and (if present) replaces `pageInfo`. Every reader of the connection
|
|
500
|
+
* re-renders with the longer `nodes` array. This makes no assumptions about HOW
|
|
501
|
+
* the page was fetched or which cursor convention the schema uses — the app fetches
|
|
502
|
+
* the next page however it likes (the connection's ref is available via
|
|
503
|
+
* `selectionOf(value)`), then hands the page object here to merge it in.
|
|
504
|
+
*/
|
|
505
|
+
appendConnection(connectionRef, page, mergeRefs) {
|
|
506
|
+
const keyOf = this.options.keyOf;
|
|
507
|
+
const anchor = this.cache.recordKey(connectionRef);
|
|
508
|
+
const existing = this.cache.getField(connectionRef, "nodes");
|
|
509
|
+
const prior = existing.status === "ready" && Array.isArray(existing.value) ? existing.value : [];
|
|
510
|
+
if (Array.isArray(page.nodes)) {
|
|
511
|
+
const fresh = page.nodes.map((n, i) => normalizeValue(this.cache, n, anchor, `nodes.${prior.length + i}`, keyOf));
|
|
512
|
+
const merged = mergeRefs ? mergeRefs(prior, fresh) : [...prior, ...fresh];
|
|
513
|
+
this.cache.setField(connectionRef, "nodes", [...merged]);
|
|
514
|
+
}
|
|
515
|
+
if (page.pageInfo != null) this.cache.setField(connectionRef, "pageInfo", normalizeValue(this.cache, page.pageInfo, anchor, "pageInfo", keyOf));
|
|
516
|
+
}
|
|
517
|
+
/** Invalidate a record (e.g. after a mutation) and clear its pending reads. */
|
|
518
|
+
invalidate(ref) {
|
|
519
|
+
const prefix = `${this.cache.recordKey(ref)}.`;
|
|
520
|
+
this.cache.invalidate(ref);
|
|
521
|
+
for (const key of [...this.pending.keys()]) if (key.startsWith(prefix)) this.pending.delete(key);
|
|
522
|
+
}
|
|
523
|
+
/** Serialize the cache for hydration across the server/client boundary. */
|
|
524
|
+
snapshot() {
|
|
525
|
+
return this.cache.snapshot();
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Fold a snapshot into the live cache (write only, no notify); returns whether
|
|
529
|
+
* anything changed. Use for a per-navigation merge where the notify is deferred
|
|
530
|
+
* to a commit-phase effect (see `serialize.ts#absorbHydrationPayload`).
|
|
531
|
+
*/
|
|
532
|
+
absorbRecords(snapshot) {
|
|
533
|
+
return this.cache.absorbRecords(snapshot);
|
|
534
|
+
}
|
|
535
|
+
/** Notify subscribers after one or more `absorbRecords` calls. */
|
|
536
|
+
notify() {
|
|
537
|
+
this.cache.notify();
|
|
538
|
+
}
|
|
539
|
+
/** Convenience: absorb a snapshot and notify if it changed (non-React callers). */
|
|
540
|
+
absorb(snapshot) {
|
|
541
|
+
const changed = this.cache.absorbRecords(snapshot);
|
|
542
|
+
if (changed) this.cache.notify();
|
|
543
|
+
return changed;
|
|
544
|
+
}
|
|
545
|
+
static hydrate(snapshot, options) {
|
|
546
|
+
return new GraphRuntime({
|
|
547
|
+
...options,
|
|
548
|
+
cache: GraphCache.fromSnapshot(snapshot, options.maxCacheRecords)
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
reportMiss(ref, fieldKey, debug) {
|
|
552
|
+
const mode = this.options.unexpectedMissingField ?? "allow";
|
|
553
|
+
if (mode === "allow") return;
|
|
554
|
+
const where = debug?.component ? ` (read by ${debug.component})` : "";
|
|
555
|
+
const message = `Runtime graph field miss: ${this.cache.recordKey(ref)}.${fieldKey}${where} was not in the compiled operation.`;
|
|
556
|
+
if (mode === "error") throw new Error(message);
|
|
557
|
+
(this.options.onWarn ?? ((m) => console.warn(m)))(message);
|
|
558
|
+
}
|
|
559
|
+
scheduleFlush() {
|
|
560
|
+
if (this.flushScheduled) return;
|
|
561
|
+
this.flushScheduled = true;
|
|
562
|
+
(this.options.schedule ?? queueMicrotask)(() => void this.flush());
|
|
563
|
+
}
|
|
564
|
+
async flush() {
|
|
565
|
+
this.flushScheduled = false;
|
|
566
|
+
const misses = this.queue;
|
|
567
|
+
this.queue = [];
|
|
568
|
+
if (misses.length === 0) return;
|
|
569
|
+
try {
|
|
570
|
+
const results = await this.options.fetchMissing(misses);
|
|
571
|
+
for (const r of results) this.cache.setField(r.ref, r.fieldKey, r.value);
|
|
572
|
+
for (const miss of misses) {
|
|
573
|
+
const pkey = this.pendingKey(miss.ref, miss.fieldKey);
|
|
574
|
+
this.pending.get(pkey)?.resolve();
|
|
575
|
+
this.pending.delete(pkey);
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
for (const miss of misses) {
|
|
579
|
+
const pkey = this.pendingKey(miss.ref, miss.fieldKey);
|
|
580
|
+
this.pending.get(pkey)?.reject(error);
|
|
581
|
+
this.pending.delete(pkey);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Stable key for a pending (ref, field) read — also the `invalidate` prefix base. */
|
|
586
|
+
pendingKey(ref, fieldKey) {
|
|
587
|
+
return `${this.cache.recordKey(ref)}.${fieldKey}`;
|
|
588
|
+
}
|
|
589
|
+
makeDeferred() {
|
|
590
|
+
let resolve;
|
|
591
|
+
let reject;
|
|
592
|
+
return {
|
|
593
|
+
promise: new Promise((res, rej) => {
|
|
594
|
+
resolve = res;
|
|
595
|
+
reject = rej;
|
|
596
|
+
}),
|
|
597
|
+
resolve,
|
|
598
|
+
reject
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/proxy.ts
|
|
604
|
+
/**
|
|
605
|
+
* Runtime graph proxies.
|
|
606
|
+
*
|
|
607
|
+
* The compiler statically infers what fields a route needs; this layer is what
|
|
608
|
+
* makes ordinary reads (`product.title`, `product.featuredImage?.url`,
|
|
609
|
+
* `collection.products({ first: 12 }).nodes`) actually *execute* at runtime.
|
|
610
|
+
*
|
|
611
|
+
* A graph value is a Proxy over a cache `GraphRef`. Property access routes
|
|
612
|
+
* through the Suspense-aware runtime:
|
|
613
|
+
* - scalar field -> `runtime.readField(ref, key)` (sync hit, or throws a promise)
|
|
614
|
+
* - object field -> the stored `GraphRef`, re-wrapped as a child proxy
|
|
615
|
+
* - list field -> an array of child proxies/scalars
|
|
616
|
+
* - callable field -> a function `(args) => value` (field arguments)
|
|
617
|
+
*
|
|
618
|
+
* The proxy is intentionally transparent: parent components pass it as a normal
|
|
619
|
+
* prop, child components read fields off it, and nothing in userland sees a ref,
|
|
620
|
+
* a selection object, or a promise (the promise is thrown to Suspense).
|
|
621
|
+
*/
|
|
622
|
+
/** Escape-hatch / brand keys exposed on every graph proxy. */
|
|
623
|
+
const GRAPH_REF = Symbol.for("graph.ref");
|
|
624
|
+
const GRAPH_TYPE = Symbol.for("graph.type");
|
|
625
|
+
const GRAPH_TRAIL = Symbol.for("graph.trail");
|
|
626
|
+
/**
|
|
627
|
+
* Read tracking for fine-grained reactivity. Every field read records the record
|
|
628
|
+
* key it touched into the active tracker; the tracking hook's `useSyncExternalStore`
|
|
629
|
+
* snapshot is then a digest of just those records' versions.
|
|
630
|
+
*
|
|
631
|
+
* Attribution is primarily PER BINDING: `useGlean` binds the graph with its render's
|
|
632
|
+
* own `affected` set (see `GraphBinding.tracker`), so reads through that render's
|
|
633
|
+
* proxies record into that set directly — fiber-local, safe under concurrent/
|
|
634
|
+
* interleaved rendering. This ambient global is only a fallback for proxies created
|
|
635
|
+
* without a binding tracker (the server / isomorphic accessor), where no re-render
|
|
636
|
+
* depends on attribution.
|
|
637
|
+
*/
|
|
638
|
+
let currentTracker = null;
|
|
639
|
+
/** Install (or clear) the active read tracker. Returns the previous one. */
|
|
640
|
+
function setReadTracker(tracker) {
|
|
641
|
+
const prev = currentTracker;
|
|
642
|
+
currentTracker = tracker;
|
|
643
|
+
return prev;
|
|
644
|
+
}
|
|
645
|
+
/** A value is a `GraphRef` if it carries entity identity or a path. */
|
|
646
|
+
function isGraphRef(value) {
|
|
647
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
648
|
+
const v = value;
|
|
649
|
+
return v.__typename != null && v.id != null || typeof v.path === "string";
|
|
650
|
+
}
|
|
651
|
+
function toArgValue(value) {
|
|
652
|
+
if (value === null) return {
|
|
653
|
+
kind: "literal",
|
|
654
|
+
value: null
|
|
655
|
+
};
|
|
656
|
+
if (Array.isArray(value)) return {
|
|
657
|
+
kind: "list",
|
|
658
|
+
items: value.map(toArgValue)
|
|
659
|
+
};
|
|
660
|
+
if (typeof value === "object") return {
|
|
661
|
+
kind: "object",
|
|
662
|
+
fields: Object.entries(value).map(([k, v]) => [k, toArgValue(v)])
|
|
663
|
+
};
|
|
664
|
+
return {
|
|
665
|
+
kind: "literal",
|
|
666
|
+
value
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/** Plain runtime args (`{ first: 12 }`) -> IR `ArgMap`, reusing core's canonicalization. */
|
|
670
|
+
function toArgMap(args) {
|
|
671
|
+
if (!args) return [];
|
|
672
|
+
return Object.entries(args).map(([k, v]) => [k, toArgValue(v)]);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Candidate response keys for a (possibly callable) field, most-specific first.
|
|
676
|
+
* A callable field that coexisted with a differently-argued sibling was aliased
|
|
677
|
+
* by the merger (`url_transformMaxWidth300`); a lone callable field keeps its
|
|
678
|
+
* plain name. The proxy tries the alias key first, then the plain name, which
|
|
679
|
+
* is correct for both shapes without the proxy having to know about conflicts.
|
|
680
|
+
*/
|
|
681
|
+
function responseKeyCandidates(name, argMap) {
|
|
682
|
+
if (argMap.length === 0) return [name];
|
|
683
|
+
const suffix = argAliasSuffix(argMap);
|
|
684
|
+
return suffix ? [`${name}_${suffix}`, name] : [name];
|
|
685
|
+
}
|
|
686
|
+
/** Read a field on a ref, resolving the right response key, and wrap object/list results. */
|
|
687
|
+
function readField(state, fieldName, args) {
|
|
688
|
+
const { binding, ref, type } = state;
|
|
689
|
+
const runtime = binding.getRuntime();
|
|
690
|
+
const fieldDef = binding.schema.getField(type, fieldName);
|
|
691
|
+
const candidates = responseKeyCandidates(fieldName, toArgMap(args));
|
|
692
|
+
let key = candidates[0];
|
|
693
|
+
for (const candidate of candidates) if (runtime.cache.getField(ref, candidate).status === "ready") {
|
|
694
|
+
key = candidate;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
const tracker = binding.tracker ?? currentTracker;
|
|
698
|
+
if (tracker) try {
|
|
699
|
+
tracker.add(runtime.cache.fieldTrackingKey(runtime.cache.recordKey(ref), key));
|
|
700
|
+
} catch {}
|
|
701
|
+
const raw = runtime.readField(ref, key);
|
|
702
|
+
const childTrail = [...state.trail, {
|
|
703
|
+
name: fieldName,
|
|
704
|
+
...args ? { args } : {}
|
|
705
|
+
}];
|
|
706
|
+
return wrap(binding, raw, fieldDef?.type, childTrail);
|
|
707
|
+
}
|
|
708
|
+
/** Wrap a raw cache value as a child proxy (object), array of proxies (list), or scalar. */
|
|
709
|
+
function wrap(binding, value, declaredType, trail) {
|
|
710
|
+
if (Array.isArray(value)) return value.map((item) => wrap(binding, item, declaredType, trail));
|
|
711
|
+
if (isGraphRef(value)) return createGraphProxy(binding, value, value.__typename ?? declaredType ?? "Unknown", trail);
|
|
712
|
+
return value;
|
|
713
|
+
}
|
|
714
|
+
const handler = {
|
|
715
|
+
get(target, prop) {
|
|
716
|
+
const { state } = target;
|
|
717
|
+
if (prop === GRAPH_REF) return state.ref;
|
|
718
|
+
if (prop === GRAPH_TYPE) return state.type;
|
|
719
|
+
if (prop === GRAPH_TRAIL) return state.trail;
|
|
720
|
+
if (prop === "selection") return {
|
|
721
|
+
ref: state.ref,
|
|
722
|
+
type: state.type
|
|
723
|
+
};
|
|
724
|
+
if (typeof prop === "symbol") return void 0;
|
|
725
|
+
if (prop === "__typename") return state.ref.__typename ?? readField(state, "__typename");
|
|
726
|
+
const fieldName = prop;
|
|
727
|
+
const fieldDef = state.binding.schema.getField(state.type, fieldName);
|
|
728
|
+
if (fieldDef?.args && fieldDef.args.length > 0) return (args) => readField(state, fieldName, args);
|
|
729
|
+
return readField(state, fieldName);
|
|
730
|
+
},
|
|
731
|
+
has(target, prop) {
|
|
732
|
+
if (prop === GRAPH_REF || prop === GRAPH_TYPE || prop === GRAPH_TRAIL || prop === "selection") return true;
|
|
733
|
+
return typeof prop === "string" && !!target.state.binding.schema.getField(target.state.type, prop);
|
|
734
|
+
},
|
|
735
|
+
set() {
|
|
736
|
+
throw new Error("graph values are read-only");
|
|
737
|
+
},
|
|
738
|
+
ownKeys() {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
function createGraphProxy(binding, ref, type, trail = []) {
|
|
743
|
+
return new Proxy({ state: {
|
|
744
|
+
binding,
|
|
745
|
+
ref,
|
|
746
|
+
type,
|
|
747
|
+
trail
|
|
748
|
+
} }, handler);
|
|
749
|
+
}
|
|
750
|
+
/** Read the hidden selection token off any graph proxy. */
|
|
751
|
+
function selectionOf(value) {
|
|
752
|
+
if (value && typeof value === "object" && GRAPH_REF in value) {
|
|
753
|
+
const v = value;
|
|
754
|
+
return {
|
|
755
|
+
ref: v[GRAPH_REF],
|
|
756
|
+
type: v[GRAPH_TYPE]
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** Read the root→value path off any graph proxy (for `usePaginated`/`fetchMore`). */
|
|
761
|
+
function trailOf(value) {
|
|
762
|
+
if (value && typeof value === "object" && GRAPH_TRAIL in value) return value[GRAPH_TRAIL];
|
|
763
|
+
}
|
|
764
|
+
function bindGraph(options) {
|
|
765
|
+
const binding = {
|
|
766
|
+
schema: options.schema,
|
|
767
|
+
getRuntime: options.getRuntime,
|
|
768
|
+
...options.tracker ? { tracker: options.tracker } : {}
|
|
769
|
+
};
|
|
770
|
+
const queryType = options.schema.queryType;
|
|
771
|
+
const rootFields = options.schema.getType(queryType)?.fields ?? {};
|
|
772
|
+
const graph = {};
|
|
773
|
+
for (const [fieldName, fieldDef] of Object.entries(rootFields)) graph[fieldName] = (args) => {
|
|
774
|
+
const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
|
|
775
|
+
const trail = [{
|
|
776
|
+
name: fieldName,
|
|
777
|
+
...args ? { args } : {}
|
|
778
|
+
}];
|
|
779
|
+
if (fieldDef.list) return (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail));
|
|
780
|
+
return createGraphProxy(binding, isGraphRef(seeded) ? seeded : { path: `${queryType}.${fieldName}(${canonicalArgs(toArgMap(args))})` }, fieldDef.type, trail);
|
|
781
|
+
};
|
|
782
|
+
return graph;
|
|
783
|
+
}
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/cache-resolve.ts
|
|
786
|
+
/**
|
|
787
|
+
* Cache-first resolution.
|
|
788
|
+
*
|
|
789
|
+
* Before executing an operation over the network, check whether the normalized
|
|
790
|
+
* cache already satisfies its entire selection. The link from a root call to its
|
|
791
|
+
* entity (`product(handle:"x") -> Product:123`) is persisted under the root
|
|
792
|
+
* record, so a re-run, a back-navigation, or data another writer already filled
|
|
793
|
+
* (mutation, subscription, sibling query) resolves with zero requests. Coverage
|
|
794
|
+
* is all-or-nothing here; partial gaps fall back to a full fetch (a future
|
|
795
|
+
* `node(id:)` patch could fetch only the missing fields).
|
|
796
|
+
*/
|
|
797
|
+
const ROOT = "Query";
|
|
798
|
+
function resolveArg(v, vars) {
|
|
799
|
+
if (v.kind === "var") return {
|
|
800
|
+
kind: "literal",
|
|
801
|
+
value: vars[v.name] ?? null
|
|
802
|
+
};
|
|
803
|
+
if (v.kind === "list") return {
|
|
804
|
+
kind: "list",
|
|
805
|
+
items: v.items.map((i) => resolveArg(i, vars))
|
|
806
|
+
};
|
|
807
|
+
if (v.kind === "object") return {
|
|
808
|
+
kind: "object",
|
|
809
|
+
fields: v.fields.map(([k, fv]) => [k, resolveArg(fv, vars)])
|
|
810
|
+
};
|
|
811
|
+
return v;
|
|
812
|
+
}
|
|
813
|
+
/** Stable per-root-call key (`product(handle:"x")`), with operation variables substituted. */
|
|
814
|
+
function rootLinkKey(field, vars) {
|
|
815
|
+
const resolved = (field.args ?? []).map(([k, v]) => [k, resolveArg(v, vars)]);
|
|
816
|
+
return `${field.name}(${canonicalArgs(resolved)})`;
|
|
817
|
+
}
|
|
818
|
+
/** Record each root field's resolved ref so a later run finds the entity without a fetch. */
|
|
819
|
+
function persistRootLinks(cache, selection, vars, roots, rootPath = ROOT) {
|
|
820
|
+
const rec = { path: rootPath };
|
|
821
|
+
for (const field of selection.fields) {
|
|
822
|
+
const key = responseKey(field);
|
|
823
|
+
if (key in roots) cache.setField(rec, rootLinkKey(field, vars), roots[key]);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/** Does `ref`'s record cover every field in `selection` (recursively)? */
|
|
827
|
+
function covers(cache, ref, selection) {
|
|
828
|
+
for (const field of selection.fields) {
|
|
829
|
+
const got = cache.getField(ref, responseKey(field));
|
|
830
|
+
if (got.status !== "ready") return false;
|
|
831
|
+
if (field.selection && !coversValue(cache, got.value, field.selection)) return false;
|
|
832
|
+
}
|
|
833
|
+
for (const frag of selection.inlineFragments ?? []) if (ref.__typename === frag.onType && !covers(cache, ref, frag.selection)) return false;
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
function coversValue(cache, value, selection) {
|
|
837
|
+
if (value == null) return true;
|
|
838
|
+
if (Array.isArray(value)) return value.every((v) => coversValue(cache, v, selection));
|
|
839
|
+
if (isGraphRef(value)) return covers(cache, value, selection);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Try to satisfy an operation entirely from cache. `covered` is true only when
|
|
844
|
+
* every root link exists and every selected field beneath it is present.
|
|
845
|
+
*/
|
|
846
|
+
function resolveFromCache(cache, selection, vars, rootPath = ROOT) {
|
|
847
|
+
const rec = { path: rootPath };
|
|
848
|
+
const roots = {};
|
|
849
|
+
for (const field of selection.fields) {
|
|
850
|
+
const link = cache.getField(rec, rootLinkKey(field, vars));
|
|
851
|
+
if (link.status !== "ready") return {
|
|
852
|
+
covered: false,
|
|
853
|
+
roots: {}
|
|
854
|
+
};
|
|
855
|
+
const ref = link.value;
|
|
856
|
+
if (ref == null) {
|
|
857
|
+
roots[responseKey(field)] = null;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (field.selection && (!isGraphRef(ref) || !covers(cache, ref, field.selection))) return {
|
|
861
|
+
covered: false,
|
|
862
|
+
roots: {}
|
|
863
|
+
};
|
|
864
|
+
roots[responseKey(field)] = ref;
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
covered: true,
|
|
868
|
+
roots
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/route.ts
|
|
873
|
+
/** Execute a compiled operation and seed the runtime cache (steps 2–5 of the route flow). */
|
|
874
|
+
async function runRoute(args) {
|
|
875
|
+
const variables = args.operation.variables(args.routeContext);
|
|
876
|
+
const selection = args.operation.selection;
|
|
877
|
+
if ((args.options?.cacheFirst ?? true) && selection) {
|
|
878
|
+
const hit = resolveFromCache(args.runtime.cache, selection, variables);
|
|
879
|
+
if (hit.covered) return {
|
|
880
|
+
variables,
|
|
881
|
+
roots: hit.roots
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const result = await args.adapter.execute({
|
|
885
|
+
name: args.operation.name,
|
|
886
|
+
kind: args.operation.kind,
|
|
887
|
+
document: args.operation.document
|
|
888
|
+
}, variables, args.context);
|
|
889
|
+
const roots = result.data ? args.runtime.seedResult(result.data) : {};
|
|
890
|
+
if (selection && result.data) persistRootLinks(args.runtime.cache, selection, variables, roots);
|
|
891
|
+
return {
|
|
892
|
+
variables,
|
|
893
|
+
roots,
|
|
894
|
+
errors: result.errors
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Re-run an operation against the network, bypassing cache-first, and re-seed.
|
|
899
|
+
* The re-seed writes through the cache, bumping its version and notifying
|
|
900
|
+
* subscribers — so a `useSyncExternalStore` (`cache.subscribe`) re-renders the
|
|
901
|
+
* UI with the fresh data. Use for an explicit "Refresh" / post-mutation refetch.
|
|
902
|
+
*/
|
|
903
|
+
function refetch(args) {
|
|
904
|
+
return runRoute({
|
|
905
|
+
...args,
|
|
906
|
+
options: { cacheFirst: false }
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/scope.ts
|
|
911
|
+
var GraphScope = class {
|
|
912
|
+
singleton;
|
|
913
|
+
als;
|
|
914
|
+
constructor(als) {
|
|
915
|
+
this.als = als;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Attach an AsyncLocalStorage after construction — for isomorphic frameworks
|
|
919
|
+
* (e.g. React Router) where the *same* `graph` accessor module loads in both
|
|
920
|
+
* bundles: construct `new GraphScope()` in a universal, client-safe module (no
|
|
921
|
+
* `node:async_hooks`), then a server-only module calls `attachAls(...)` to
|
|
922
|
+
* upgrade it to per-request ALS isolation. `run`/`current` read `als`
|
|
923
|
+
* dynamically, so the upgrade takes effect immediately; the client keeps using
|
|
924
|
+
* the singleton set by `set()`.
|
|
925
|
+
*/
|
|
926
|
+
attachAls(als) {
|
|
927
|
+
this.als = als;
|
|
928
|
+
}
|
|
929
|
+
/** The active graph, or throw a clear error if read outside any scope. */
|
|
930
|
+
current() {
|
|
931
|
+
const active = this.als?.getStore() ?? this.singleton;
|
|
932
|
+
if (!active) throw new Error("No active graph runtime. On the server wrap rendering in scope.run(active, fn); on the client call scope.set(active) after hydration.");
|
|
933
|
+
return active;
|
|
934
|
+
}
|
|
935
|
+
/** Run `fn` with `active` as the request-scoped runtime (server). */
|
|
936
|
+
run(active, fn) {
|
|
937
|
+
if (this.als) return this.als.run(active, fn);
|
|
938
|
+
const prev = this.singleton;
|
|
939
|
+
this.singleton = active;
|
|
940
|
+
try {
|
|
941
|
+
return fn();
|
|
942
|
+
} finally {
|
|
943
|
+
this.singleton = prev;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/** Install the active graph as a singleton (client, post-hydration). */
|
|
947
|
+
set(active) {
|
|
948
|
+
this.singleton = active;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* Pair a {@link GraphScope} with a zero-arg resolver — the framework-agnostic
|
|
953
|
+
* binding for the generated accessor. An app exports `activeGraph` and points
|
|
954
|
+
* `@gleanql/vite`'s `requestScope: { import: "activeGraph", from: "..." }` at it,
|
|
955
|
+
* then wraps server rendering in `scope.run(active, fn)` (or
|
|
956
|
+
* `integration.runInScope`). Pass an `AsyncLocalStorage` to isolate concurrent
|
|
957
|
+
* server requests; omit it for the client singleton.
|
|
958
|
+
*
|
|
959
|
+
* ```ts
|
|
960
|
+
* import { AsyncLocalStorage } from "node:async_hooks";
|
|
961
|
+
* export const { scope, activeGraph } = bindScope(new AsyncLocalStorage());
|
|
962
|
+
* ```
|
|
963
|
+
*/
|
|
964
|
+
function bindScope(als) {
|
|
965
|
+
const scope = new GraphScope(als);
|
|
966
|
+
return {
|
|
967
|
+
scope,
|
|
968
|
+
activeGraph: () => scope.current()
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/mutation.ts
|
|
973
|
+
/**
|
|
974
|
+
* A reversible batch of cache writes. Optimistic updates record the prior value
|
|
975
|
+
* of every field they touch so the whole batch can be rolled back if the
|
|
976
|
+
* mutation fails (transport error or `userErrors`).
|
|
977
|
+
*/
|
|
978
|
+
var MutationTransaction = class {
|
|
979
|
+
cache;
|
|
980
|
+
undo = [];
|
|
981
|
+
constructor(cache) {
|
|
982
|
+
this.cache = cache;
|
|
983
|
+
}
|
|
984
|
+
/** Optimistically write a field, remembering how to undo it. */
|
|
985
|
+
set(ref, fieldKey, value) {
|
|
986
|
+
const before = this.cache.getField(ref, fieldKey);
|
|
987
|
+
if (before.status === "ready") {
|
|
988
|
+
const prev = before.value;
|
|
989
|
+
this.undo.push(() => this.cache.setField(ref, fieldKey, prev));
|
|
990
|
+
} else this.undo.push(() => this.cache.invalidateField(ref, fieldKey));
|
|
991
|
+
this.cache.setField(ref, fieldKey, value);
|
|
992
|
+
}
|
|
993
|
+
/** Roll back every write in reverse order. */
|
|
994
|
+
rollback() {
|
|
995
|
+
for (let i = this.undo.length - 1; i >= 0; i--) this.undo[i]();
|
|
996
|
+
this.undo.length = 0;
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
/**
|
|
1000
|
+
* Execute a mutation, normalize its result into the cache, surface userErrors,
|
|
1001
|
+
* and apply optimistic/invalidation policy. The returned promise never rejects
|
|
1002
|
+
* for logical failures — inspect `ok`/`userErrors`/`errors`.
|
|
1003
|
+
*/
|
|
1004
|
+
async function runMutation(options) {
|
|
1005
|
+
const { runtime, adapter, context, variables } = options;
|
|
1006
|
+
const tx = new MutationTransaction(runtime.cache);
|
|
1007
|
+
if (options.optimistic) options.optimistic(tx);
|
|
1008
|
+
let result;
|
|
1009
|
+
try {
|
|
1010
|
+
result = await adapter.execute({
|
|
1011
|
+
name: options.operation.name,
|
|
1012
|
+
kind: "mutation",
|
|
1013
|
+
document: options.operation.document
|
|
1014
|
+
}, variables, context);
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
tx.rollback();
|
|
1017
|
+
return {
|
|
1018
|
+
userErrors: [],
|
|
1019
|
+
errors: [{ message: errorMessage(error) }],
|
|
1020
|
+
ok: false
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
if (result.errors && result.errors.length > 0) {
|
|
1024
|
+
tx.rollback();
|
|
1025
|
+
return {
|
|
1026
|
+
userErrors: [],
|
|
1027
|
+
errors: result.errors,
|
|
1028
|
+
ok: false
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const data = result.data;
|
|
1032
|
+
const userErrors = data ? extractUserErrors(data) : [];
|
|
1033
|
+
if (userErrors.length > 0) {
|
|
1034
|
+
tx.rollback();
|
|
1035
|
+
return {
|
|
1036
|
+
data,
|
|
1037
|
+
userErrors,
|
|
1038
|
+
ok: false
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
if (data) runtime.seedResult(data);
|
|
1042
|
+
if (options.update && data) options.update(data, tx);
|
|
1043
|
+
if (options.invalidate && data) for (const target of options.invalidate(data)) {
|
|
1044
|
+
const ref = toRef(target);
|
|
1045
|
+
if (ref) runtime.invalidate(ref);
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
data,
|
|
1049
|
+
userErrors: [],
|
|
1050
|
+
ok: true
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/** Invalidate a record by graph value (proxy) or raw ref — next read re-fetches. */
|
|
1054
|
+
function invalidateValue(runtime, value) {
|
|
1055
|
+
const ref = toRef(value);
|
|
1056
|
+
if (ref) runtime.invalidate(ref);
|
|
1057
|
+
}
|
|
1058
|
+
/** Collect `userErrors` from each top-level mutation payload in the result. */
|
|
1059
|
+
function extractUserErrors(data) {
|
|
1060
|
+
const out = [];
|
|
1061
|
+
for (const value of Object.values(data)) if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1062
|
+
const ue = value.userErrors;
|
|
1063
|
+
if (Array.isArray(ue)) {
|
|
1064
|
+
for (const e of ue) if (e && typeof e === "object") out.push(e);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return out;
|
|
1068
|
+
}
|
|
1069
|
+
function toRef(value) {
|
|
1070
|
+
const selection = selectionOf(value);
|
|
1071
|
+
if (selection) return selection.ref;
|
|
1072
|
+
if (value && typeof value === "object") {
|
|
1073
|
+
const v = value;
|
|
1074
|
+
if (v.__typename != null && v.id != null || typeof v.path === "string") return v;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/** One rule for stringifying unknown errors, shared across hooks and transports. */
|
|
1078
|
+
function errorMessage(error) {
|
|
1079
|
+
return error instanceof Error ? error.message : String(error);
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/mutator.ts
|
|
1083
|
+
function createMutator(options) {
|
|
1084
|
+
const mutate = {};
|
|
1085
|
+
for (const [name, operation] of Object.entries(options.operations)) {
|
|
1086
|
+
if (operation.kind !== "mutation") continue;
|
|
1087
|
+
const mutationOp = {
|
|
1088
|
+
name: operation.name,
|
|
1089
|
+
kind: "mutation",
|
|
1090
|
+
document: operation.document
|
|
1091
|
+
};
|
|
1092
|
+
mutate[name] = (variables, opts) => runMutation({
|
|
1093
|
+
operation: mutationOp,
|
|
1094
|
+
variables,
|
|
1095
|
+
adapter: options.adapter,
|
|
1096
|
+
context: options.context,
|
|
1097
|
+
runtime: options.runtime,
|
|
1098
|
+
...opts
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
return mutate;
|
|
1102
|
+
}
|
|
1103
|
+
//#endregion
|
|
1104
|
+
//#region src/context.ts
|
|
1105
|
+
/**
|
|
1106
|
+
* Build the route/request context from a RequestInfo. `params` and `search` come
|
|
1107
|
+
* from the URL; everything else is contributed by `options.context`. The raw
|
|
1108
|
+
* `request` is included for header derivation but is *not* serialized to the
|
|
1109
|
+
* client (see `serializeGraph`).
|
|
1110
|
+
*/
|
|
1111
|
+
function buildRouteContext(requestInfo, options = {}) {
|
|
1112
|
+
const url = new URL(requestInfo.request.url);
|
|
1113
|
+
return {
|
|
1114
|
+
...options.context?.(requestInfo) ?? {},
|
|
1115
|
+
params: requestInfo.params,
|
|
1116
|
+
search: url.searchParams,
|
|
1117
|
+
request: requestInfo.request
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
//#endregion
|
|
1121
|
+
//#region src/integration.ts
|
|
1122
|
+
/**
|
|
1123
|
+
* RedwoodSDK integration.
|
|
1124
|
+
*
|
|
1125
|
+
* Answers the four questions the brief asks of a framework adapter:
|
|
1126
|
+
* - Which operation drives this entrypoint? -> `resolveOperationName` / explicit name
|
|
1127
|
+
* - How do we read params/search/request/env? -> `buildRouteContext`
|
|
1128
|
+
* - How do we preload + seed? -> `runRoute` into a fresh per-request cache
|
|
1129
|
+
* - How do we expose the graph to components? -> attach a bound graph to `ctx`
|
|
1130
|
+
*
|
|
1131
|
+
* Per request: pick the operation, compute variables, execute via the client
|
|
1132
|
+
* adapter, seed a fresh cache, and attach `{ runtime, graph, ... }` to
|
|
1133
|
+
* `requestInfo.ctx` so Pages/components read graph fields with cache hits. Unseeded
|
|
1134
|
+
* (lazy) fields fall through to the Suspense runtime.
|
|
1135
|
+
*/
|
|
1136
|
+
const CTX_KEY = "__graph";
|
|
1137
|
+
function createGraphIntegration(options) {
|
|
1138
|
+
const keyOf = (typename, obj) => options.schema.identityOf(typename, obj);
|
|
1139
|
+
function makeRuntime(requestContext) {
|
|
1140
|
+
return new GraphRuntime({
|
|
1141
|
+
keyOf,
|
|
1142
|
+
unexpectedMissingField: options.unexpectedMissingField,
|
|
1143
|
+
onWarn: options.onWarn,
|
|
1144
|
+
fetchMissing: async (misses) => options.fetchMissing ? options.fetchMissing(misses, requestContext) : missesUnresolved(misses)
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
async function preload(requestInfo, operationName) {
|
|
1148
|
+
const name = operationName ?? options.resolveOperationName?.(requestInfo);
|
|
1149
|
+
const operation = name ? options.operations[name] : void 0;
|
|
1150
|
+
if (!operation) return void 0;
|
|
1151
|
+
const requestContext = buildRouteContext(requestInfo, options);
|
|
1152
|
+
const runtime = makeRuntime(requestContext);
|
|
1153
|
+
const { variables, roots, errors } = await runRoute({
|
|
1154
|
+
operation,
|
|
1155
|
+
routeContext: requestContext,
|
|
1156
|
+
adapter: options.adapter,
|
|
1157
|
+
context: requestContext,
|
|
1158
|
+
runtime
|
|
1159
|
+
});
|
|
1160
|
+
const active = {
|
|
1161
|
+
runtime,
|
|
1162
|
+
graph: bindGraph({
|
|
1163
|
+
schema: options.schema,
|
|
1164
|
+
getRuntime: () => runtime,
|
|
1165
|
+
roots
|
|
1166
|
+
}),
|
|
1167
|
+
mutate: createMutator({
|
|
1168
|
+
operations: options.operations,
|
|
1169
|
+
adapter: options.adapter,
|
|
1170
|
+
runtime,
|
|
1171
|
+
context: requestContext
|
|
1172
|
+
}),
|
|
1173
|
+
roots,
|
|
1174
|
+
operation,
|
|
1175
|
+
variables,
|
|
1176
|
+
requestContext,
|
|
1177
|
+
...errors ? { errors } : {}
|
|
1178
|
+
};
|
|
1179
|
+
requestInfo.ctx[CTX_KEY] = active;
|
|
1180
|
+
return active;
|
|
1181
|
+
}
|
|
1182
|
+
function getActive(requestInfo) {
|
|
1183
|
+
return requestInfo.ctx[CTX_KEY];
|
|
1184
|
+
}
|
|
1185
|
+
async function refetch(requestInfo, operationName) {
|
|
1186
|
+
const active = getActive(requestInfo);
|
|
1187
|
+
if (!active) return;
|
|
1188
|
+
const operation = options.operations[operationName ?? active.operation.name];
|
|
1189
|
+
if (!operation) return;
|
|
1190
|
+
await runRoute({
|
|
1191
|
+
operation,
|
|
1192
|
+
routeContext: active.requestContext,
|
|
1193
|
+
adapter: options.adapter,
|
|
1194
|
+
context: active.requestContext,
|
|
1195
|
+
runtime: active.runtime,
|
|
1196
|
+
options: { cacheFirst: false }
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
function getGraph(requestInfo) {
|
|
1200
|
+
const active = getActive(requestInfo);
|
|
1201
|
+
if (!active) throw new Error("No graph attached to this request. Call integration.preload(requestInfo) before rendering (e.g. in a middleware or at the top of the Page).");
|
|
1202
|
+
return active.graph;
|
|
1203
|
+
}
|
|
1204
|
+
function getMutator(requestInfo) {
|
|
1205
|
+
const active = getActive(requestInfo);
|
|
1206
|
+
if (!active) throw new Error("No graph attached to this request. Call integration.preload(requestInfo) first.");
|
|
1207
|
+
return active.mutate;
|
|
1208
|
+
}
|
|
1209
|
+
function invalidate(requestInfo, value) {
|
|
1210
|
+
const active = getActive(requestInfo);
|
|
1211
|
+
if (active) invalidateValue(active.runtime, value);
|
|
1212
|
+
}
|
|
1213
|
+
function runInScope(requestInfo, fn) {
|
|
1214
|
+
const active = getActive(requestInfo);
|
|
1215
|
+
if (!active || !options.scope) return fn();
|
|
1216
|
+
return options.scope.run({
|
|
1217
|
+
runtime: active.runtime,
|
|
1218
|
+
graph: active.graph
|
|
1219
|
+
}, fn);
|
|
1220
|
+
}
|
|
1221
|
+
return {
|
|
1222
|
+
preload,
|
|
1223
|
+
getGraph,
|
|
1224
|
+
getMutator,
|
|
1225
|
+
invalidate,
|
|
1226
|
+
getActive,
|
|
1227
|
+
refetch,
|
|
1228
|
+
runInScope
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
/** Default missing-field resolution: leave each miss undefined (hybrid allow/warn). */
|
|
1232
|
+
function missesUnresolved(misses) {
|
|
1233
|
+
return misses.map((m) => ({
|
|
1234
|
+
ref: m.ref,
|
|
1235
|
+
fieldKey: m.fieldKey,
|
|
1236
|
+
value: void 0
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/serialize.ts
|
|
1241
|
+
/** Build the JSON-safe hydration payload from an active request. */
|
|
1242
|
+
function serializeGraph(active, options = {}) {
|
|
1243
|
+
const allow = new Set(options.clientSafeContext ?? []);
|
|
1244
|
+
const context = {};
|
|
1245
|
+
for (const key of allow) if (key in active.requestContext) context[key] = active.requestContext[key];
|
|
1246
|
+
return {
|
|
1247
|
+
operationName: active.operation.name,
|
|
1248
|
+
variables: active.variables,
|
|
1249
|
+
snapshot: active.runtime.snapshot(),
|
|
1250
|
+
roots: active.roots,
|
|
1251
|
+
context
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const DEFAULT_GLOBAL = "__GRAPH_STATE__";
|
|
1255
|
+
/**
|
|
1256
|
+
* Render a `<script>` that publishes the payload on `window[globalKey]` for
|
|
1257
|
+
* client hydration. JSON is escaped so it cannot break out of the script element
|
|
1258
|
+
* or be interpreted as HTML (`<`, `>`, `&`, U+2028/U+2029).
|
|
1259
|
+
*/
|
|
1260
|
+
function renderGraphHydrationScript(payload, options = {}) {
|
|
1261
|
+
return `<script${options.nonce ? ` nonce="${options.nonce}"` : ""}>${graphHydrationScriptContent(payload, options.globalKey)}<\/script>`;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Just the inner JS of the hydration script (no `<script>` wrapper), for JSX
|
|
1265
|
+
* hosts that inject via `dangerouslySetInnerHTML` and set the nonce themselves.
|
|
1266
|
+
* JSON is escaped so it can't break out of the script element or be parsed as HTML.
|
|
1267
|
+
*/
|
|
1268
|
+
function graphHydrationScriptContent(payload, globalKey = DEFAULT_GLOBAL) {
|
|
1269
|
+
const json = JSON.stringify(payload).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
1270
|
+
return `window[${JSON.stringify(globalKey)}]=${json}`;
|
|
1271
|
+
}
|
|
1272
|
+
/** Read the hydration payload published by `renderGraphHydrationScript` (client). */
|
|
1273
|
+
function readGraphHydrationPayload(globalKey = DEFAULT_GLOBAL) {
|
|
1274
|
+
return globalThis[globalKey];
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Rebuild the runtime + bound graph on the client from a hydration payload.
|
|
1278
|
+
* Reads present in the snapshot resolve synchronously; missing fields suspend and
|
|
1279
|
+
* fetch through the client adapter.
|
|
1280
|
+
*/
|
|
1281
|
+
function hydrateGraph(payload, options) {
|
|
1282
|
+
const runtime = GraphRuntime.hydrate(payload.snapshot, {
|
|
1283
|
+
keyOf: (typename, obj) => options.schema.identityOf(typename, obj),
|
|
1284
|
+
unexpectedMissingField: options.unexpectedMissingField,
|
|
1285
|
+
onWarn: options.onWarn,
|
|
1286
|
+
fetchMissing: async (misses) => options.fetchMissing ? options.fetchMissing(misses, payload.context) : misses.map((m) => ({
|
|
1287
|
+
ref: m.ref,
|
|
1288
|
+
fieldKey: m.fieldKey,
|
|
1289
|
+
value: void 0
|
|
1290
|
+
}))
|
|
1291
|
+
});
|
|
1292
|
+
const graph = bindGraph({
|
|
1293
|
+
schema: options.schema,
|
|
1294
|
+
getRuntime: () => runtime,
|
|
1295
|
+
roots: payload.roots
|
|
1296
|
+
});
|
|
1297
|
+
const active = {
|
|
1298
|
+
runtime,
|
|
1299
|
+
graph,
|
|
1300
|
+
roots: payload.roots,
|
|
1301
|
+
context: payload.context
|
|
1302
|
+
};
|
|
1303
|
+
options.scope?.set({
|
|
1304
|
+
runtime,
|
|
1305
|
+
graph
|
|
1306
|
+
});
|
|
1307
|
+
return active;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Render-phase merge: fold a payload's snapshot into a live runtime, write-only
|
|
1311
|
+
* (no subscriber notify — the caller bumps in a commit-phase effect). Idempotent.
|
|
1312
|
+
* Returns whether anything changed.
|
|
1313
|
+
*/
|
|
1314
|
+
function absorbHydrationPayload(runtime, payload) {
|
|
1315
|
+
return runtime.absorbRecords(payload.snapshot);
|
|
1316
|
+
}
|
|
1317
|
+
/** Derive the current-page pointer from a payload (drives `refresh()`). */
|
|
1318
|
+
function pagePointer(payload) {
|
|
1319
|
+
return {
|
|
1320
|
+
operationName: payload.operationName,
|
|
1321
|
+
variables: payload.variables,
|
|
1322
|
+
context: payload.context,
|
|
1323
|
+
roots: payload.roots
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/adapter-ws.ts
|
|
1328
|
+
function toErrors(error) {
|
|
1329
|
+
if (Array.isArray(error)) return error.map((e) => ({ message: String(e?.message ?? e) }));
|
|
1330
|
+
if (error instanceof Error) return [{ message: error.message }];
|
|
1331
|
+
if (error && typeof error === "object" && "reason" in error) return [{ message: String(error.reason) || "subscription socket closed" }];
|
|
1332
|
+
return [{ message: String(error) }];
|
|
1333
|
+
}
|
|
1334
|
+
function createGraphWsAdapter(options) {
|
|
1335
|
+
const { client } = options;
|
|
1336
|
+
const payloadFor = (operation, variables, context) => {
|
|
1337
|
+
const extensions = options.extensions?.(context);
|
|
1338
|
+
return {
|
|
1339
|
+
query: operation.document,
|
|
1340
|
+
variables: variables ?? {},
|
|
1341
|
+
operationName: operation.name,
|
|
1342
|
+
...extensions && Object.keys(extensions).length > 0 ? { extensions } : {}
|
|
1343
|
+
};
|
|
1344
|
+
};
|
|
1345
|
+
return {
|
|
1346
|
+
execute(operation, variables, context) {
|
|
1347
|
+
return new Promise((resolve) => {
|
|
1348
|
+
let settled = false;
|
|
1349
|
+
let dispose;
|
|
1350
|
+
const settle = (r) => {
|
|
1351
|
+
if (settled) return;
|
|
1352
|
+
settled = true;
|
|
1353
|
+
resolve(r);
|
|
1354
|
+
dispose?.();
|
|
1355
|
+
};
|
|
1356
|
+
dispose = client.subscribe(payloadFor(operation, variables, context), {
|
|
1357
|
+
next: (value) => settle(value),
|
|
1358
|
+
error: (error) => settle({ errors: toErrors(error) }),
|
|
1359
|
+
complete: () => settle({})
|
|
1360
|
+
});
|
|
1361
|
+
if (settled) dispose?.();
|
|
1362
|
+
});
|
|
1363
|
+
},
|
|
1364
|
+
subscribe(operation, variables, context) {
|
|
1365
|
+
return { [Symbol.asyncIterator]() {
|
|
1366
|
+
let dispose;
|
|
1367
|
+
const it = pushPullIterator(() => dispose?.());
|
|
1368
|
+
dispose = client.subscribe(payloadFor(operation, variables, context), {
|
|
1369
|
+
next: (value) => it.push(value),
|
|
1370
|
+
error: (error) => {
|
|
1371
|
+
it.push({ errors: toErrors(error) });
|
|
1372
|
+
it.finish();
|
|
1373
|
+
},
|
|
1374
|
+
complete: () => it.finish()
|
|
1375
|
+
});
|
|
1376
|
+
return it;
|
|
1377
|
+
} };
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/persisted.ts
|
|
1383
|
+
function createPersistedResolver(operations, options = {}) {
|
|
1384
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
1385
|
+
const documents = /* @__PURE__ */ new Set();
|
|
1386
|
+
for (const op of Object.values(operations)) {
|
|
1387
|
+
documents.add(op.document);
|
|
1388
|
+
if (op.hash) byHash.set(op.hash, op.document);
|
|
1389
|
+
}
|
|
1390
|
+
return (body) => {
|
|
1391
|
+
const hash = body.extensions?.persistedQuery?.sha256Hash;
|
|
1392
|
+
if (hash) {
|
|
1393
|
+
const document = byHash.get(hash);
|
|
1394
|
+
if (document) return {
|
|
1395
|
+
kind: "ok",
|
|
1396
|
+
document
|
|
1397
|
+
};
|
|
1398
|
+
if (body.query) return documents.has(body.query) || options.allowUnpersisted ? {
|
|
1399
|
+
kind: "ok",
|
|
1400
|
+
document: body.query
|
|
1401
|
+
} : { kind: "rejected" };
|
|
1402
|
+
return { kind: "not-found" };
|
|
1403
|
+
}
|
|
1404
|
+
if (body.query) return documents.has(body.query) || options.allowUnpersisted ? {
|
|
1405
|
+
kind: "ok",
|
|
1406
|
+
document: body.query
|
|
1407
|
+
} : { kind: "rejected" };
|
|
1408
|
+
return { kind: "rejected" };
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
//#endregion
|
|
1412
|
+
export { GRAPH_REF, GRAPH_TRAIL, GRAPH_TYPE, GraphCache, GraphRuntime, GraphScope, MutationTransaction, absorbHydrationPayload, bindGraph, bindScope, buildRouteContext, createFetchAdapter, createGraphIntegration, createGraphProxy, createGraphWsAdapter, createMutator, createPersistedResolver, errorMessage, graphHydrationScriptContent, hydrateGraph, invalidateValue, isGraphRef, normalizeValue, pagePointer, persistRootLinks, pushPullIterator, readGraphHydrationPayload, refetch, renderGraphHydrationScript, resolveFromCache, responseKeyCandidates, result, runMutation, runRoute, seedResult, selectionOf, serializeGraph, setReadTracker, toArgMap, trailOf };
|