@gleanql/client 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +44 -0
- package/dist/index.mjs +360 -10
- package/package.json +3 -3
- package/src/integration.ts +72 -8
- package/src/paginate.ts +74 -0
- package/src/proxy.ts +110 -1
- package/src/route.ts +6 -0
- package/src/runtime.ts +74 -0
package/dist/index.d.mts
CHANGED
|
@@ -265,6 +265,7 @@ declare class GraphRuntime {
|
|
|
265
265
|
private readonly options;
|
|
266
266
|
readonly cache: GraphCache;
|
|
267
267
|
private readonly pending;
|
|
268
|
+
private readonly resolvedRoots;
|
|
268
269
|
private queue;
|
|
269
270
|
private flushScheduled;
|
|
270
271
|
constructor(options: GraphRuntimeOptions);
|
|
@@ -272,6 +273,24 @@ declare class GraphRuntime {
|
|
|
272
273
|
readField(ref: GraphRef, fieldKey: string, debug?: {
|
|
273
274
|
component?: string;
|
|
274
275
|
}): FieldValue;
|
|
276
|
+
/**
|
|
277
|
+
* Suspense primitive for a render-time ("two-sweep") root read: run `exec`
|
|
278
|
+
* once per `key` (it fetches the root with the call-site args and seeds the
|
|
279
|
+
* cache), throwing its promise until it resolves, then return the seeded root
|
|
280
|
+
* refs. Mirrors `readField`'s pending/throw/resume, keyed by the root field +
|
|
281
|
+
* its args. Because `exec` SEEDS before resolving, every subsequent field read
|
|
282
|
+
* on the returned refs is a cache hit — so a deferred root's fields never reach
|
|
283
|
+
* `reportMiss` (they aren't "unexpected" misses).
|
|
284
|
+
*/
|
|
285
|
+
resolveRoot(key: string, exec: () => Promise<Record<string, FieldValue>>): Record<string, FieldValue>;
|
|
286
|
+
/**
|
|
287
|
+
* Async twin of {@link resolveRoot} for non-React callers (server handlers,
|
|
288
|
+
* proxy): run `exec` once per `key` and RESOLVE to the seeded root refs instead
|
|
289
|
+
* of throwing a Suspense promise. Shares the `resolvedRoots` cache and the
|
|
290
|
+
* `pending` map with `resolveRoot`, so an `await` and a concurrent sync (thrown)
|
|
291
|
+
* read of the same root+args dedupe to a single in-flight fetch.
|
|
292
|
+
*/
|
|
293
|
+
resolveRootAsync(key: string, exec: () => Promise<Record<string, FieldValue>>): Promise<Record<string, FieldValue>>;
|
|
275
294
|
/** Seed a record's fields (e.g. from the compiled operation result). */
|
|
276
295
|
seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void;
|
|
277
296
|
/** Normalize a full operation result into the cache; returns root refs. */
|
|
@@ -328,6 +347,12 @@ interface CompiledOperation<RouteContext = unknown, TVariables = Record<string,
|
|
|
328
347
|
readonly readMap?: Record<string, readonly string[]>;
|
|
329
348
|
/** Merged selection tree; enables cache-first resolution when present. */
|
|
330
349
|
readonly selection?: SelectionSet;
|
|
350
|
+
/** True when one or more root reads take render-time ("two-sweep") args; those
|
|
351
|
+
* roots are executed at the call-site, not preloaded from ctx. See `runtimeVars`. */
|
|
352
|
+
readonly deferred?: boolean;
|
|
353
|
+
/** Operation variables ($vars) supplied at the render call-site (the factory
|
|
354
|
+
* omits them); used to prune the eager preload + identify deferred roots. */
|
|
355
|
+
readonly runtimeVars?: readonly string[];
|
|
331
356
|
}
|
|
332
357
|
interface RunRouteOptions {
|
|
333
358
|
/**
|
|
@@ -468,6 +493,25 @@ interface BindGraphOptions {
|
|
|
468
493
|
* the server/isomorphic accessor omits it.
|
|
469
494
|
*/
|
|
470
495
|
readonly tracker?: Set<string>;
|
|
496
|
+
/**
|
|
497
|
+
* Root fields whose args are computed at the render call-site ("two-sweep").
|
|
498
|
+
* They aren't preloaded; the callable executes them on demand via
|
|
499
|
+
* {@link BindGraphOptions.resolveDeferredRoot}.
|
|
500
|
+
*/
|
|
501
|
+
readonly deferredRoots?: ReadonlySet<string>;
|
|
502
|
+
/**
|
|
503
|
+
* Execute a deferred root with its call-site args and return the seeded value
|
|
504
|
+
* (a ref, or an array of refs for a list root). Suspends (throws a promise)
|
|
505
|
+
* until the fetch+seed completes, then returns synchronously on retry. Wired by
|
|
506
|
+
* the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
|
|
507
|
+
*/
|
|
508
|
+
readonly resolveDeferredRoot?: (rootField: string, args: Record<string, unknown> | undefined) => FieldValue;
|
|
509
|
+
/**
|
|
510
|
+
* Async twin of {@link resolveDeferredRoot}: resolve the seeded value without
|
|
511
|
+
* throwing, so a deferred root can be `await`ed in a non-React server handler
|
|
512
|
+
* (`const o = await glean.order({ id })`). Wired over `runtime.resolveRootAsync`.
|
|
513
|
+
*/
|
|
514
|
+
readonly resolveDeferredRootAsync?: (rootField: string, args: Record<string, unknown> | undefined) => Promise<FieldValue>;
|
|
471
515
|
}
|
|
472
516
|
declare function bindGraph(options: BindGraphOptions): BoundGraph;
|
|
473
517
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { argAliasSuffix, canonicalArgs, responseKey } from "@gleanql/core";
|
|
1
|
+
import { argAliasSuffix, canonicalArgs, printOperation, responseKey } from "@gleanql/core";
|
|
2
2
|
//#region src/adapter-shared.ts
|
|
3
3
|
/** Build a `GraphResult`, omitting empty `data`/`errors` keys. */
|
|
4
4
|
function result(data, errors) {
|
|
@@ -459,6 +459,7 @@ var GraphRuntime = class GraphRuntime {
|
|
|
459
459
|
options;
|
|
460
460
|
cache;
|
|
461
461
|
pending = /* @__PURE__ */ new Map();
|
|
462
|
+
resolvedRoots = /* @__PURE__ */ new Map();
|
|
462
463
|
queue = [];
|
|
463
464
|
flushScheduled = false;
|
|
464
465
|
constructor(options) {
|
|
@@ -482,6 +483,64 @@ var GraphRuntime = class GraphRuntime {
|
|
|
482
483
|
this.scheduleFlush();
|
|
483
484
|
throw entry.promise;
|
|
484
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Suspense primitive for a render-time ("two-sweep") root read: run `exec`
|
|
488
|
+
* once per `key` (it fetches the root with the call-site args and seeds the
|
|
489
|
+
* cache), throwing its promise until it resolves, then return the seeded root
|
|
490
|
+
* refs. Mirrors `readField`'s pending/throw/resume, keyed by the root field +
|
|
491
|
+
* its args. Because `exec` SEEDS before resolving, every subsequent field read
|
|
492
|
+
* on the returned refs is a cache hit — so a deferred root's fields never reach
|
|
493
|
+
* `reportMiss` (they aren't "unexpected" misses).
|
|
494
|
+
*/
|
|
495
|
+
resolveRoot(key, exec) {
|
|
496
|
+
const done = this.resolvedRoots.get(key);
|
|
497
|
+
if (done) return done;
|
|
498
|
+
const pkey = `@root:${key}`;
|
|
499
|
+
const existing = this.pending.get(pkey);
|
|
500
|
+
if (existing) throw existing.promise;
|
|
501
|
+
const entry = this.makeDeferred();
|
|
502
|
+
this.pending.set(pkey, entry);
|
|
503
|
+
exec().then((roots) => {
|
|
504
|
+
this.resolvedRoots.set(key, roots);
|
|
505
|
+
this.pending.delete(pkey);
|
|
506
|
+
entry.resolve();
|
|
507
|
+
}, (error) => {
|
|
508
|
+
this.pending.delete(pkey);
|
|
509
|
+
entry.reject(error);
|
|
510
|
+
});
|
|
511
|
+
throw entry.promise;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Async twin of {@link resolveRoot} for non-React callers (server handlers,
|
|
515
|
+
* proxy): run `exec` once per `key` and RESOLVE to the seeded root refs instead
|
|
516
|
+
* of throwing a Suspense promise. Shares the `resolvedRoots` cache and the
|
|
517
|
+
* `pending` map with `resolveRoot`, so an `await` and a concurrent sync (thrown)
|
|
518
|
+
* read of the same root+args dedupe to a single in-flight fetch.
|
|
519
|
+
*/
|
|
520
|
+
async resolveRootAsync(key, exec) {
|
|
521
|
+
const done = this.resolvedRoots.get(key);
|
|
522
|
+
if (done) return done;
|
|
523
|
+
const pkey = `@root:${key}`;
|
|
524
|
+
const existing = this.pending.get(pkey);
|
|
525
|
+
if (existing) {
|
|
526
|
+
await existing.promise;
|
|
527
|
+
return this.resolvedRoots.get(key) ?? {};
|
|
528
|
+
}
|
|
529
|
+
const entry = this.makeDeferred();
|
|
530
|
+
entry.promise.catch(() => {});
|
|
531
|
+
this.pending.set(pkey, entry);
|
|
532
|
+
try {
|
|
533
|
+
const roots = await exec();
|
|
534
|
+
this.resolvedRoots.set(key, roots);
|
|
535
|
+
this.pending.delete(pkey);
|
|
536
|
+
entry.resolve();
|
|
537
|
+
return roots;
|
|
538
|
+
} catch (error) {
|
|
539
|
+
this.pending.delete(pkey);
|
|
540
|
+
entry.reject(error);
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
485
544
|
/** Seed a record's fields (e.g. from the compiled operation result). */
|
|
486
545
|
seed(ref, fields) {
|
|
487
546
|
this.cache.merge(ref, fields);
|
|
@@ -722,6 +781,7 @@ const handler = {
|
|
|
722
781
|
type: state.type
|
|
723
782
|
};
|
|
724
783
|
if (typeof prop === "symbol") return void 0;
|
|
784
|
+
if (prop === "then") return void 0;
|
|
725
785
|
if (prop === "__typename") return state.ref.__typename ?? readField(state, "__typename");
|
|
726
786
|
const fieldName = prop;
|
|
727
787
|
const fieldDef = state.binding.schema.getField(state.type, fieldName);
|
|
@@ -771,16 +831,54 @@ function bindGraph(options) {
|
|
|
771
831
|
const rootFields = options.schema.getType(queryType)?.fields ?? {};
|
|
772
832
|
const graph = {};
|
|
773
833
|
for (const [fieldName, fieldDef] of Object.entries(rootFields)) graph[fieldName] = (args) => {
|
|
774
|
-
const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
|
|
775
834
|
const trail = [{
|
|
776
835
|
name: fieldName,
|
|
777
836
|
...args ? { args } : {}
|
|
778
837
|
}];
|
|
838
|
+
if (options.deferredRoots?.has(fieldName) && options.resolveDeferredRoot) {
|
|
839
|
+
const materialize = (seeded) => fieldDef.list ? (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail)) : wrap(binding, seeded, fieldDef.type, trail);
|
|
840
|
+
return makeDeferredRootValue(fieldName, args, fieldDef.list ?? false, materialize, options.resolveDeferredRoot, options.resolveDeferredRootAsync);
|
|
841
|
+
}
|
|
842
|
+
const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
|
|
779
843
|
if (fieldDef.list) return (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail));
|
|
780
844
|
return createGraphProxy(binding, isGraphRef(seeded) ? seeded : { path: `${queryType}.${fieldName}(${canonicalArgs(toArgMap(args))})` }, fieldDef.type, trail);
|
|
781
845
|
};
|
|
782
846
|
return graph;
|
|
783
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* A deferred ("two-sweep") root read is BOTH awaitable and directly readable, so
|
|
850
|
+
* the same call works in a React render and a plain server handler:
|
|
851
|
+
* - `await glean.order({ id })` → async-resolve, no Suspense (server handlers)
|
|
852
|
+
* - `glean.order({ id }).name` → sync-resolve, suspends until seeded (React)
|
|
853
|
+
*
|
|
854
|
+
* It's a proxy over an EMPTY array (list root) or object (singular root): `.then`
|
|
855
|
+
* drives the async executor (kept in the get-trap only, never an own property, so
|
|
856
|
+
* it is awaitable without leaking into enumeration/spread/JSON), and every other
|
|
857
|
+
* access sync-resolves (throwing a Suspense promise until the fetch+seed completes)
|
|
858
|
+
* then delegates to the materialized value. The array target keeps `Array.isArray`
|
|
859
|
+
* true for a list root, matching a non-deferred list read; the materialized value
|
|
860
|
+
* is memoized so repeated sync reads on the same returned value keep stable element
|
|
861
|
+
* identity. When no async executor is wired `.then` is absent, so the value is a
|
|
862
|
+
* plain (non-thenable) sync read — unchanged React behavior.
|
|
863
|
+
*/
|
|
864
|
+
function makeDeferredRootValue(rootField, args, isList, materialize, syncResolve, asyncResolve) {
|
|
865
|
+
const then = asyncResolve ? (onFulfilled, onRejected) => asyncResolve(rootField, args).then((seeded) => materialize(seeded)).then(onFulfilled, onRejected) : void 0;
|
|
866
|
+
let ready = false;
|
|
867
|
+
let value;
|
|
868
|
+
const syncValue = () => {
|
|
869
|
+
if (!ready) {
|
|
870
|
+
value = materialize(syncResolve(rootField, args));
|
|
871
|
+
ready = true;
|
|
872
|
+
}
|
|
873
|
+
return value;
|
|
874
|
+
};
|
|
875
|
+
return new Proxy(isList ? [] : {}, { get(_t, prop) {
|
|
876
|
+
if (prop === "then") return then;
|
|
877
|
+
const v = syncValue();
|
|
878
|
+
const got = v[prop];
|
|
879
|
+
return typeof got === "function" ? got.bind(v) : got;
|
|
880
|
+
} });
|
|
881
|
+
}
|
|
784
882
|
//#endregion
|
|
785
883
|
//#region src/cache-resolve.ts
|
|
786
884
|
/**
|
|
@@ -1158,6 +1256,211 @@ function buildRouteContext(requestInfo, options = {}) {
|
|
|
1158
1256
|
};
|
|
1159
1257
|
}
|
|
1160
1258
|
//#endregion
|
|
1259
|
+
//#region src/paginate.ts
|
|
1260
|
+
/**
|
|
1261
|
+
* Execute a single deferred ("two-sweep") root read with args computed at the
|
|
1262
|
+
* render call-site, and seed the cache — the runtime half of the deferred-args
|
|
1263
|
+
* feature. Reuses the pagination machinery (`buildPageOperation` builds a
|
|
1264
|
+
* single-root operation from the compiled selection, turning the call-site args
|
|
1265
|
+
* into `$vars` with schema-derived types) but seeds the result as a fresh root
|
|
1266
|
+
* (`seedResult`) instead of appending a connection page. Pure — exported for
|
|
1267
|
+
* testing. The caller (the bound-graph deferred branch) wraps this in
|
|
1268
|
+
* `runtime.resolveRoot(...)` for Suspense de-dup + resume.
|
|
1269
|
+
*/
|
|
1270
|
+
async function resolveDeferredRoot(params) {
|
|
1271
|
+
const { op, rootField, args, schema, adapter, runtime, context } = params;
|
|
1272
|
+
const built = buildPageOperation(op, [{
|
|
1273
|
+
name: rootField,
|
|
1274
|
+
args
|
|
1275
|
+
}], args, schema);
|
|
1276
|
+
if (!built) return { ok: false };
|
|
1277
|
+
let result;
|
|
1278
|
+
try {
|
|
1279
|
+
result = await adapter.execute({
|
|
1280
|
+
name: built.name,
|
|
1281
|
+
kind: "query",
|
|
1282
|
+
document: built.document
|
|
1283
|
+
}, args, context);
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
return {
|
|
1286
|
+
ok: false,
|
|
1287
|
+
error: errorMessage(err)
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (result?.errors?.length) return {
|
|
1291
|
+
ok: false,
|
|
1292
|
+
error: result.errors[0].message
|
|
1293
|
+
};
|
|
1294
|
+
return {
|
|
1295
|
+
ok: true,
|
|
1296
|
+
roots: result?.data ? runtime.seedResult(result.data) : {}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Split a deferred operation into the part that can be preloaded eagerly from
|
|
1301
|
+
* `ctx` and the set of root fields whose args are render-time (`runtimeVars`).
|
|
1302
|
+
* Returns a pruned eager op (only the non-deferred roots, with just the vars they
|
|
1303
|
+
* still use) — `undefined` when no eager roots remain (a pure two-sweep route) —
|
|
1304
|
+
* plus the deferred root field names. Pure — exported for testing.
|
|
1305
|
+
*/
|
|
1306
|
+
function splitDeferredRoots(op, runtimeVars) {
|
|
1307
|
+
const deferredRoots = /* @__PURE__ */ new Set();
|
|
1308
|
+
const eagerFields = [];
|
|
1309
|
+
for (const f of op.selection?.fields ?? []) {
|
|
1310
|
+
const vars = /* @__PURE__ */ new Set();
|
|
1311
|
+
for (const [, v] of f.args ?? []) collectArgValueVars(v, vars);
|
|
1312
|
+
if ([...vars].some((v) => runtimeVars.has(v))) deferredRoots.add(f.name);
|
|
1313
|
+
else eagerFields.push(f);
|
|
1314
|
+
}
|
|
1315
|
+
if (eagerFields.length === 0 || !op.selection) return { deferredRoots };
|
|
1316
|
+
const selection = {
|
|
1317
|
+
typeName: op.selection.typeName,
|
|
1318
|
+
fields: eagerFields
|
|
1319
|
+
};
|
|
1320
|
+
const used = collectVarNames(selection);
|
|
1321
|
+
const variables = parseVariableDefs(op.document).filter((v) => used.has(v.name));
|
|
1322
|
+
const name = `${op.name}_eager`;
|
|
1323
|
+
return {
|
|
1324
|
+
eager: {
|
|
1325
|
+
name,
|
|
1326
|
+
kind: "query",
|
|
1327
|
+
document: printOperation({
|
|
1328
|
+
kind: "query",
|
|
1329
|
+
name,
|
|
1330
|
+
variables,
|
|
1331
|
+
selection
|
|
1332
|
+
}),
|
|
1333
|
+
selection
|
|
1334
|
+
},
|
|
1335
|
+
deferredRoots
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
/** Variable names referenced in a single arg value (non-recursive into selections). */
|
|
1339
|
+
function collectArgValueVars(v, out) {
|
|
1340
|
+
if (v.kind === "var") out.add(v.name);
|
|
1341
|
+
else if (v.kind === "list") v.items.forEach((i) => collectArgValueVars(i, out));
|
|
1342
|
+
else if (v.kind === "object") v.fields.forEach(([, vv]) => collectArgValueVars(vv, out));
|
|
1343
|
+
}
|
|
1344
|
+
/** The op field whose response key matches a runtime path step (handles arg-aliasing). */
|
|
1345
|
+
function pickStepField(fields, step) {
|
|
1346
|
+
const named = fields.filter((f) => f.name === step.name);
|
|
1347
|
+
if (named.length <= 1) return named[0];
|
|
1348
|
+
const keys = responseKeyCandidates(step.name, toArgMap(step.args));
|
|
1349
|
+
return named.find((f) => keys.includes(f.alias ?? f.name)) ?? named[0];
|
|
1350
|
+
}
|
|
1351
|
+
/** Replace/add args on the connection field, turning each caller arg into a `$var`. */
|
|
1352
|
+
function withUserArgs(existing, args) {
|
|
1353
|
+
const map = new Map(existing ?? []);
|
|
1354
|
+
for (const name of Object.keys(args)) map.set(name, {
|
|
1355
|
+
kind: "var",
|
|
1356
|
+
name
|
|
1357
|
+
});
|
|
1358
|
+
return [...map.entries()];
|
|
1359
|
+
}
|
|
1360
|
+
/** Clone the single root→connection path out of `op`, overriding the connection's args. */
|
|
1361
|
+
function clonePathField(parent, trail, depth, args) {
|
|
1362
|
+
const field = pickStepField(parent.fields, trail[depth]);
|
|
1363
|
+
if (!field) return void 0;
|
|
1364
|
+
if (depth === trail.length - 1) return {
|
|
1365
|
+
...field,
|
|
1366
|
+
args: withUserArgs(field.args, args)
|
|
1367
|
+
};
|
|
1368
|
+
if (!field.selection) return void 0;
|
|
1369
|
+
const child = clonePathField(field.selection, trail, depth + 1, args);
|
|
1370
|
+
if (!child) return void 0;
|
|
1371
|
+
const identity = field.selection.fields.filter((f) => !f.selection && (f.name === "__typename" || f.name === "id"));
|
|
1372
|
+
return {
|
|
1373
|
+
...field,
|
|
1374
|
+
selection: {
|
|
1375
|
+
typeName: field.selection.typeName,
|
|
1376
|
+
fields: [...identity, child]
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/** Walk the schema along `trail` to the connection field's declared arg types. */
|
|
1381
|
+
function connectionArgTypes(trail, schema) {
|
|
1382
|
+
let parentType = schema.queryType;
|
|
1383
|
+
let fieldDef;
|
|
1384
|
+
for (const step of trail) {
|
|
1385
|
+
if (!parentType) return {};
|
|
1386
|
+
fieldDef = schema.getField(parentType, step.name);
|
|
1387
|
+
parentType = fieldDef?.type;
|
|
1388
|
+
}
|
|
1389
|
+
const out = {};
|
|
1390
|
+
for (const a of fieldDef?.args ?? []) out[a.name] = a.type;
|
|
1391
|
+
return out;
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Build a query for the NEXT page of the connection at `trail`: the single path from
|
|
1395
|
+
* a Query root to it (with its full node/pageInfo selection), with the caller's
|
|
1396
|
+
* `args` overriding the connection field's arguments as `$vars`. Returns `undefined`
|
|
1397
|
+
* if the path isn't in the op. Pure — exported for testing.
|
|
1398
|
+
*/
|
|
1399
|
+
function buildPageOperation(op, trail, args, schema) {
|
|
1400
|
+
if (!op.selection || trail.length === 0) return void 0;
|
|
1401
|
+
const pathField = clonePathField(op.selection, trail, 0, args);
|
|
1402
|
+
if (!pathField) return void 0;
|
|
1403
|
+
const selection = {
|
|
1404
|
+
typeName: op.selection.typeName,
|
|
1405
|
+
fields: [pathField]
|
|
1406
|
+
};
|
|
1407
|
+
const used = collectVarNames(selection);
|
|
1408
|
+
const argTypes = connectionArgTypes(trail, schema);
|
|
1409
|
+
const headerVars = parseVariableDefs(op.document).filter((v) => used.has(v.name));
|
|
1410
|
+
const argVars = Object.keys(args).map((name) => ({
|
|
1411
|
+
name,
|
|
1412
|
+
type: argTypes[name] ?? "String"
|
|
1413
|
+
}));
|
|
1414
|
+
const variables = dedupeVarsByName([...headerVars, ...argVars]);
|
|
1415
|
+
const name = `${op.name}_page`;
|
|
1416
|
+
return {
|
|
1417
|
+
name,
|
|
1418
|
+
kind: "query",
|
|
1419
|
+
document: printOperation({
|
|
1420
|
+
kind: "query",
|
|
1421
|
+
name,
|
|
1422
|
+
variables,
|
|
1423
|
+
selection
|
|
1424
|
+
})
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
function dedupeVarsByName(vars) {
|
|
1428
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1429
|
+
for (const v of vars) if (!seen.has(v.name)) seen.set(v.name, v);
|
|
1430
|
+
return [...seen.values()];
|
|
1431
|
+
}
|
|
1432
|
+
/** Names of operation variables referenced anywhere in a selection's arguments. */
|
|
1433
|
+
function collectVarNames(sel) {
|
|
1434
|
+
const out = /* @__PURE__ */ new Set();
|
|
1435
|
+
const fromValue = (v) => {
|
|
1436
|
+
if (v.kind === "var") out.add(v.name);
|
|
1437
|
+
else if (v.kind === "list") v.items.forEach(fromValue);
|
|
1438
|
+
else if (v.kind === "object") v.fields.forEach(([, vv]) => fromValue(vv));
|
|
1439
|
+
};
|
|
1440
|
+
const fromArgs = (args) => (args ?? []).forEach(([, v]) => fromValue(v));
|
|
1441
|
+
const walk = (s) => {
|
|
1442
|
+
for (const f of s.fields) {
|
|
1443
|
+
fromArgs(f.args);
|
|
1444
|
+
if (f.selection) walk(f.selection);
|
|
1445
|
+
}
|
|
1446
|
+
for (const fr of s.inlineFragments ?? []) walk(fr.selection);
|
|
1447
|
+
};
|
|
1448
|
+
walk(sel);
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
/** Parse `query Name($a: T!, $b: [U!])` → variable defs (operation header only). */
|
|
1452
|
+
function parseVariableDefs(document) {
|
|
1453
|
+
const m = /^\s*(?:query|mutation|subscription)\s+\w+\s*\(([^)]*)\)\s*\{/.exec(document);
|
|
1454
|
+
if (!m?.[1]) return [];
|
|
1455
|
+
return m[1].split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
|
|
1456
|
+
const colon = s.indexOf(":");
|
|
1457
|
+
return {
|
|
1458
|
+
name: s.slice(0, colon).trim().replace(/^\$/, ""),
|
|
1459
|
+
type: s.slice(colon + 1).trim()
|
|
1460
|
+
};
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
//#endregion
|
|
1161
1464
|
//#region src/integration.ts
|
|
1162
1465
|
/**
|
|
1163
1466
|
* RedwoodSDK integration.
|
|
@@ -1190,19 +1493,66 @@ function createGraphIntegration(options) {
|
|
|
1190
1493
|
if (!operation) return void 0;
|
|
1191
1494
|
const requestContext = buildRouteContext(requestInfo, options);
|
|
1192
1495
|
const runtime = makeRuntime(requestContext);
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1496
|
+
let variables;
|
|
1497
|
+
let roots;
|
|
1498
|
+
let errors;
|
|
1499
|
+
let deferredRoots;
|
|
1500
|
+
if (operation.deferred) {
|
|
1501
|
+
const split = splitDeferredRoots(operation, new Set(operation.runtimeVars ?? []));
|
|
1502
|
+
deferredRoots = split.deferredRoots;
|
|
1503
|
+
if (split.eager) {
|
|
1504
|
+
const r = await runRoute({
|
|
1505
|
+
operation: {
|
|
1506
|
+
...operation,
|
|
1507
|
+
name: split.eager.name,
|
|
1508
|
+
document: split.eager.document,
|
|
1509
|
+
selection: split.eager.selection
|
|
1510
|
+
},
|
|
1511
|
+
routeContext: requestContext,
|
|
1512
|
+
adapter: options.adapter,
|
|
1513
|
+
context: requestContext,
|
|
1514
|
+
runtime
|
|
1515
|
+
});
|
|
1516
|
+
({variables, roots, errors} = r);
|
|
1517
|
+
} else {
|
|
1518
|
+
variables = operation.variables(requestContext);
|
|
1519
|
+
roots = {};
|
|
1520
|
+
}
|
|
1521
|
+
} else {
|
|
1522
|
+
const r = await runRoute({
|
|
1523
|
+
operation,
|
|
1524
|
+
routeContext: requestContext,
|
|
1525
|
+
adapter: options.adapter,
|
|
1526
|
+
context: requestContext,
|
|
1527
|
+
runtime
|
|
1528
|
+
});
|
|
1529
|
+
({variables, roots, errors} = r);
|
|
1530
|
+
}
|
|
1531
|
+
const deferredKey = (rootField, args) => `${rootField}(${canonicalArgs(toArgMap(args ?? {}))})`;
|
|
1532
|
+
const deferredExec = (rootField, args) => async () => {
|
|
1533
|
+
const res = await resolveDeferredRoot({
|
|
1534
|
+
op: operation,
|
|
1535
|
+
rootField,
|
|
1536
|
+
args: args ?? {},
|
|
1537
|
+
schema: options.schema,
|
|
1538
|
+
adapter: options.adapter,
|
|
1539
|
+
runtime,
|
|
1540
|
+
context: requestContext
|
|
1541
|
+
});
|
|
1542
|
+
if (!res.ok && res.error) throw new Error(res.error);
|
|
1543
|
+
return res.roots ?? {};
|
|
1544
|
+
};
|
|
1545
|
+
const resolveDeferred = operation.deferred ? (rootField, args) => runtime.resolveRoot(deferredKey(rootField, args), deferredExec(rootField, args))[rootField] : void 0;
|
|
1546
|
+
const resolveDeferredAsync = operation.deferred ? async (rootField, args) => (await runtime.resolveRootAsync(deferredKey(rootField, args), deferredExec(rootField, args)))[rootField] : void 0;
|
|
1200
1547
|
const active = {
|
|
1201
1548
|
runtime,
|
|
1202
1549
|
graph: bindGraph({
|
|
1203
1550
|
schema: options.schema,
|
|
1204
1551
|
getRuntime: () => runtime,
|
|
1205
|
-
roots
|
|
1552
|
+
roots,
|
|
1553
|
+
...deferredRoots ? { deferredRoots } : {},
|
|
1554
|
+
...resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {},
|
|
1555
|
+
...resolveDeferredAsync ? { resolveDeferredRootAsync: resolveDeferredAsync } : {}
|
|
1206
1556
|
}),
|
|
1207
1557
|
mutate: createMutator({
|
|
1208
1558
|
operations: options.operations,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleanql/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Glean's runtime: normalized cache, fine-grained reactivity, mutations, subscriptions and React hooks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Liljengard",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@gleanql/core": "0.1.
|
|
29
|
+
"@gleanql/core": "0.1.15"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"react": ">=18"
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
},
|
|
53
53
|
"sideEffects": false,
|
|
54
54
|
"scripts": {
|
|
55
|
-
"build": "
|
|
55
|
+
"build": "tsdown src/index.ts --format esm --dts.eager"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/integration.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
invalidateValue,
|
|
6
6
|
runRoute,
|
|
7
7
|
runServerMutation,
|
|
8
|
+
toArgMap,
|
|
8
9
|
type MutationResult,
|
|
9
10
|
type BoundGraph,
|
|
10
11
|
type BoundMutations,
|
|
@@ -18,7 +19,8 @@ import {
|
|
|
18
19
|
type MissingFieldRead,
|
|
19
20
|
type MissingFieldResult,
|
|
20
21
|
} from "./index.js";
|
|
21
|
-
import
|
|
22
|
+
import { resolveDeferredRoot as runDeferredRoot, splitDeferredRoots } from "./paginate.js";
|
|
23
|
+
import { canonicalArgs, type SchemaModel } from "@gleanql/core";
|
|
22
24
|
import { buildRouteContext, type BuildRouteContextOptions, type GraphRouteContext, type RequestInfo } from "./context.js";
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -133,14 +135,76 @@ export function createGraphIntegration<Ctx extends Record<string, unknown> = Rec
|
|
|
133
135
|
|
|
134
136
|
const requestContext = buildRouteContext(requestInfo, options);
|
|
135
137
|
const runtime = makeRuntime(requestContext);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
|
|
139
|
+
// Two-sweep: preload only the ctx-derivable roots; render-time roots execute
|
|
140
|
+
// at the call-site (resolveDeferredRoot below). A pure two-sweep route has no
|
|
141
|
+
// eager roots to preload at all.
|
|
142
|
+
let variables: Record<string, unknown>;
|
|
143
|
+
let roots: Record<string, FieldValue>;
|
|
144
|
+
let errors: ReadonlyArray<{ message: string }> | undefined;
|
|
145
|
+
let deferredRoots: ReadonlySet<string> | undefined;
|
|
146
|
+
|
|
147
|
+
if (operation.deferred) {
|
|
148
|
+
const split = splitDeferredRoots(operation, new Set(operation.runtimeVars ?? []));
|
|
149
|
+
deferredRoots = split.deferredRoots;
|
|
150
|
+
if (split.eager) {
|
|
151
|
+
const eagerOp: CompiledOperation<GraphRouteContext> = {
|
|
152
|
+
...operation,
|
|
153
|
+
name: split.eager.name,
|
|
154
|
+
document: split.eager.document,
|
|
155
|
+
selection: split.eager.selection,
|
|
156
|
+
};
|
|
157
|
+
const r = await runRoute({ operation: eagerOp, routeContext: requestContext, adapter: options.adapter, context: requestContext, runtime });
|
|
158
|
+
({ variables, roots, errors } = r);
|
|
159
|
+
} else {
|
|
160
|
+
variables = operation.variables(requestContext); // ctx vars only (factory omits deferred)
|
|
161
|
+
roots = {};
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const r = await runRoute({ operation, routeContext: requestContext, adapter: options.adapter, context: requestContext, runtime });
|
|
165
|
+
({ variables, roots, errors } = r);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The deferred-root executor: fetch a render-time root with its call-site
|
|
169
|
+
// args, seed the cache, and resolve to the seeded ref(s). `runDeferredRoot` is
|
|
170
|
+
// the shared async fetch; the SYNC form (`resolveRoot`) throws a Suspense
|
|
171
|
+
// promise for React renders, the ASYNC form (`resolveRootAsync`) resolves for
|
|
172
|
+
// non-React callers (`await glean.order({ id })` in a server handler). Both
|
|
173
|
+
// dedupe on the same root+args key.
|
|
174
|
+
const deferredKey = (rootField: string, args: Record<string, unknown> | undefined) =>
|
|
175
|
+
`${rootField}(${canonicalArgs(toArgMap(args ?? {}))})`;
|
|
176
|
+
const deferredExec =
|
|
177
|
+
(rootField: string, args: Record<string, unknown> | undefined) => async (): Promise<Record<string, FieldValue>> => {
|
|
178
|
+
const res = await runDeferredRoot({
|
|
179
|
+
op: operation,
|
|
180
|
+
rootField,
|
|
181
|
+
args: args ?? {},
|
|
182
|
+
schema: options.schema,
|
|
183
|
+
adapter: options.adapter,
|
|
184
|
+
runtime,
|
|
185
|
+
context: requestContext,
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok && res.error) throw new Error(res.error);
|
|
188
|
+
return res.roots ?? {};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const resolveDeferred = operation.deferred
|
|
192
|
+
? (rootField: string, args: Record<string, unknown> | undefined): FieldValue =>
|
|
193
|
+
runtime.resolveRoot(deferredKey(rootField, args), deferredExec(rootField, args))[rootField]
|
|
194
|
+
: undefined;
|
|
195
|
+
const resolveDeferredAsync = operation.deferred
|
|
196
|
+
? async (rootField: string, args: Record<string, unknown> | undefined): Promise<FieldValue> =>
|
|
197
|
+
(await runtime.resolveRootAsync(deferredKey(rootField, args), deferredExec(rootField, args)))[rootField]
|
|
198
|
+
: undefined;
|
|
199
|
+
|
|
200
|
+
const graph = bindGraph({
|
|
201
|
+
schema: options.schema,
|
|
202
|
+
getRuntime: () => runtime,
|
|
203
|
+
roots,
|
|
204
|
+
...(deferredRoots ? { deferredRoots } : {}),
|
|
205
|
+
...(resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {}),
|
|
206
|
+
...(resolveDeferredAsync ? { resolveDeferredRootAsync: resolveDeferredAsync } : {}),
|
|
142
207
|
});
|
|
143
|
-
const graph = bindGraph({ schema: options.schema, getRuntime: () => runtime, roots });
|
|
144
208
|
const mutate = createMutator({ operations: options.operations, adapter: options.adapter, runtime, context: requestContext });
|
|
145
209
|
|
|
146
210
|
const active: ActiveRequestGraph = {
|
package/src/paginate.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
responseKeyCandidates,
|
|
14
14
|
toArgMap,
|
|
15
15
|
createGraphProxy,
|
|
16
|
+
type FieldValue,
|
|
16
17
|
type GraphClientAdapter,
|
|
17
18
|
type GraphPagePointer,
|
|
18
19
|
type GraphRef,
|
|
@@ -90,6 +91,79 @@ interface PageableOp {
|
|
|
90
91
|
readonly selection?: SelectionSet;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Execute a single deferred ("two-sweep") root read with args computed at the
|
|
96
|
+
* render call-site, and seed the cache — the runtime half of the deferred-args
|
|
97
|
+
* feature. Reuses the pagination machinery (`buildPageOperation` builds a
|
|
98
|
+
* single-root operation from the compiled selection, turning the call-site args
|
|
99
|
+
* into `$vars` with schema-derived types) but seeds the result as a fresh root
|
|
100
|
+
* (`seedResult`) instead of appending a connection page. Pure — exported for
|
|
101
|
+
* testing. The caller (the bound-graph deferred branch) wraps this in
|
|
102
|
+
* `runtime.resolveRoot(...)` for Suspense de-dup + resume.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveDeferredRoot(params: {
|
|
105
|
+
readonly op: PageableOp;
|
|
106
|
+
readonly rootField: string;
|
|
107
|
+
readonly args: Record<string, unknown>;
|
|
108
|
+
readonly schema: SchemaModel;
|
|
109
|
+
readonly adapter: GraphClientAdapter;
|
|
110
|
+
readonly runtime: GraphRuntime;
|
|
111
|
+
readonly context: GraphRequestContext;
|
|
112
|
+
}): Promise<{ ok: boolean; roots?: Record<string, FieldValue>; error?: string }> {
|
|
113
|
+
const { op, rootField, args, schema, adapter, runtime, context } = params;
|
|
114
|
+
const trail: PathStep[] = [{ name: rootField, args }];
|
|
115
|
+
const built = buildPageOperation(op, trail, args, schema);
|
|
116
|
+
if (!built) return { ok: false };
|
|
117
|
+
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await adapter.execute({ name: built.name, kind: "query", document: built.document }, args, context);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { ok: false, error: errorMessage(err) };
|
|
123
|
+
}
|
|
124
|
+
if (result?.errors?.length) return { ok: false, error: result.errors[0]!.message };
|
|
125
|
+
const roots = result?.data ? runtime.seedResult(result.data as Record<string, unknown>) : {};
|
|
126
|
+
return { ok: true, roots };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Split a deferred operation into the part that can be preloaded eagerly from
|
|
131
|
+
* `ctx` and the set of root fields whose args are render-time (`runtimeVars`).
|
|
132
|
+
* Returns a pruned eager op (only the non-deferred roots, with just the vars they
|
|
133
|
+
* still use) — `undefined` when no eager roots remain (a pure two-sweep route) —
|
|
134
|
+
* plus the deferred root field names. Pure — exported for testing.
|
|
135
|
+
*/
|
|
136
|
+
export function splitDeferredRoots(
|
|
137
|
+
op: { name: string; document: string; selection?: SelectionSet },
|
|
138
|
+
runtimeVars: ReadonlySet<string>,
|
|
139
|
+
): { eager?: { name: string; kind: "query"; document: string; selection: SelectionSet }; deferredRoots: Set<string> } {
|
|
140
|
+
const deferredRoots = new Set<string>();
|
|
141
|
+
const eagerFields: FieldSelection[] = [];
|
|
142
|
+
for (const f of op.selection?.fields ?? []) {
|
|
143
|
+
const vars = new Set<string>();
|
|
144
|
+
for (const [, v] of f.args ?? []) collectArgValueVars(v, vars);
|
|
145
|
+
if ([...vars].some((v) => runtimeVars.has(v))) deferredRoots.add(f.name);
|
|
146
|
+
else eagerFields.push(f);
|
|
147
|
+
}
|
|
148
|
+
if (eagerFields.length === 0 || !op.selection) return { deferredRoots };
|
|
149
|
+
|
|
150
|
+
const selection: SelectionSet = { typeName: op.selection.typeName, fields: eagerFields };
|
|
151
|
+
const used = collectVarNames(selection);
|
|
152
|
+
const variables = parseVariableDefs(op.document).filter((v) => used.has(v.name));
|
|
153
|
+
const name = `${op.name}_eager`;
|
|
154
|
+
return {
|
|
155
|
+
eager: { name, kind: "query", document: printOperation({ kind: "query", name, variables, selection }), selection },
|
|
156
|
+
deferredRoots,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Variable names referenced in a single arg value (non-recursive into selections). */
|
|
161
|
+
function collectArgValueVars(v: ArgValue, out: Set<string>): void {
|
|
162
|
+
if (v.kind === "var") out.add(v.name);
|
|
163
|
+
else if (v.kind === "list") v.items.forEach((i) => collectArgValueVars(i, out));
|
|
164
|
+
else if (v.kind === "object") v.fields.forEach(([, vv]) => collectArgValueVars(vv, out));
|
|
165
|
+
}
|
|
166
|
+
|
|
93
167
|
export interface PaginateConnectionParams {
|
|
94
168
|
readonly connection: unknown;
|
|
95
169
|
readonly args: Record<string, unknown>;
|
package/src/proxy.ts
CHANGED
|
@@ -175,6 +175,11 @@ const handler: ProxyHandler<{ state: ProxyState }> = {
|
|
|
175
175
|
return { ref: state.ref, type: state.type } satisfies GraphSelection;
|
|
176
176
|
}
|
|
177
177
|
if (typeof prop === "symbol") return undefined;
|
|
178
|
+
// Never look like a thenable: a graph proxy can be the resolved value of
|
|
179
|
+
// `await glean.order({ id })`, and Promise resolution probes `.then` — reading
|
|
180
|
+
// it as a (missing) field would suspend/throw mid-resolution. GraphQL has no
|
|
181
|
+
// `then` field in practice, so returning undefined is safe.
|
|
182
|
+
if (prop === "then") return undefined;
|
|
178
183
|
if (prop === "__typename") {
|
|
179
184
|
// Identity field: prefer the ref's own typename, fall back to a cache read.
|
|
180
185
|
return state.ref.__typename ?? readField(state, "__typename");
|
|
@@ -252,6 +257,28 @@ export interface BindGraphOptions {
|
|
|
252
257
|
* the server/isomorphic accessor omits it.
|
|
253
258
|
*/
|
|
254
259
|
readonly tracker?: Set<string>;
|
|
260
|
+
/**
|
|
261
|
+
* Root fields whose args are computed at the render call-site ("two-sweep").
|
|
262
|
+
* They aren't preloaded; the callable executes them on demand via
|
|
263
|
+
* {@link BindGraphOptions.resolveDeferredRoot}.
|
|
264
|
+
*/
|
|
265
|
+
readonly deferredRoots?: ReadonlySet<string>;
|
|
266
|
+
/**
|
|
267
|
+
* Execute a deferred root with its call-site args and return the seeded value
|
|
268
|
+
* (a ref, or an array of refs for a list root). Suspends (throws a promise)
|
|
269
|
+
* until the fetch+seed completes, then returns synchronously on retry. Wired by
|
|
270
|
+
* the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
|
|
271
|
+
*/
|
|
272
|
+
readonly resolveDeferredRoot?: (rootField: string, args: Record<string, unknown> | undefined) => FieldValue;
|
|
273
|
+
/**
|
|
274
|
+
* Async twin of {@link resolveDeferredRoot}: resolve the seeded value without
|
|
275
|
+
* throwing, so a deferred root can be `await`ed in a non-React server handler
|
|
276
|
+
* (`const o = await glean.order({ id })`). Wired over `runtime.resolveRootAsync`.
|
|
277
|
+
*/
|
|
278
|
+
readonly resolveDeferredRootAsync?: (
|
|
279
|
+
rootField: string,
|
|
280
|
+
args: Record<string, unknown> | undefined,
|
|
281
|
+
) => Promise<FieldValue>;
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
export function bindGraph(options: BindGraphOptions): BoundGraph {
|
|
@@ -266,10 +293,32 @@ export function bindGraph(options: BindGraphOptions): BoundGraph {
|
|
|
266
293
|
const graph: Record<string, (args?: Record<string, unknown>) => unknown> = {};
|
|
267
294
|
for (const [fieldName, fieldDef] of Object.entries(rootFields)) {
|
|
268
295
|
graph[fieldName] = (args?: Record<string, unknown>) => {
|
|
296
|
+
const trail: PathStep[] = [{ name: fieldName, ...(args ? { args } : {}) }];
|
|
297
|
+
|
|
298
|
+
// Deferred ("two-sweep") root: args are only known at the render call-site,
|
|
299
|
+
// so execute on demand instead of reading a preloaded root. The returned
|
|
300
|
+
// value is ISOMORPHIC — `await glean.nodes({ ids })` resolves it (server
|
|
301
|
+
// handlers), while `glean.nodes({ ids }).map(...)` reads it synchronously via
|
|
302
|
+
// Suspense (React). This also replaces the silent empty-array fallback below:
|
|
303
|
+
// a deferred list root fetches, it does not yield [].
|
|
304
|
+
if (options.deferredRoots?.has(fieldName) && options.resolveDeferredRoot) {
|
|
305
|
+
const materialize = (seeded: FieldValue): unknown =>
|
|
306
|
+
fieldDef.list
|
|
307
|
+
? (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail))
|
|
308
|
+
: wrap(binding, seeded, fieldDef.type, trail);
|
|
309
|
+
return makeDeferredRootValue(
|
|
310
|
+
fieldName,
|
|
311
|
+
args,
|
|
312
|
+
fieldDef.list ?? false,
|
|
313
|
+
materialize,
|
|
314
|
+
options.resolveDeferredRoot,
|
|
315
|
+
options.resolveDeferredRootAsync,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
269
319
|
const rootsNow =
|
|
270
320
|
typeof options.roots === "function" ? options.roots() : options.roots;
|
|
271
321
|
const seeded = rootsNow?.[fieldName];
|
|
272
|
-
const trail: PathStep[] = [{ name: fieldName, ...(args ? { args } : {}) }];
|
|
273
322
|
// A list root (`type Query { todos: [Todo!] }`) seeds an array of refs — wrap
|
|
274
323
|
// each as a child proxy so `glean.todos().map(...)` works without an object
|
|
275
324
|
// wrapper. Unseeded (pre-hydration / not yet fetched) -> empty array; the
|
|
@@ -286,3 +335,63 @@ export function bindGraph(options: BindGraphOptions): BoundGraph {
|
|
|
286
335
|
}
|
|
287
336
|
return graph as BoundGraph;
|
|
288
337
|
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A deferred ("two-sweep") root read is BOTH awaitable and directly readable, so
|
|
341
|
+
* the same call works in a React render and a plain server handler:
|
|
342
|
+
* - `await glean.order({ id })` → async-resolve, no Suspense (server handlers)
|
|
343
|
+
* - `glean.order({ id }).name` → sync-resolve, suspends until seeded (React)
|
|
344
|
+
*
|
|
345
|
+
* It's a proxy over an EMPTY array (list root) or object (singular root): `.then`
|
|
346
|
+
* drives the async executor (kept in the get-trap only, never an own property, so
|
|
347
|
+
* it is awaitable without leaking into enumeration/spread/JSON), and every other
|
|
348
|
+
* access sync-resolves (throwing a Suspense promise until the fetch+seed completes)
|
|
349
|
+
* then delegates to the materialized value. The array target keeps `Array.isArray`
|
|
350
|
+
* true for a list root, matching a non-deferred list read; the materialized value
|
|
351
|
+
* is memoized so repeated sync reads on the same returned value keep stable element
|
|
352
|
+
* identity. When no async executor is wired `.then` is absent, so the value is a
|
|
353
|
+
* plain (non-thenable) sync read — unchanged React behavior.
|
|
354
|
+
*/
|
|
355
|
+
function makeDeferredRootValue(
|
|
356
|
+
rootField: string,
|
|
357
|
+
args: Record<string, unknown> | undefined,
|
|
358
|
+
isList: boolean,
|
|
359
|
+
materialize: (seeded: FieldValue) => unknown,
|
|
360
|
+
syncResolve: (rootField: string, args: Record<string, unknown> | undefined) => FieldValue,
|
|
361
|
+
asyncResolve?: (rootField: string, args: Record<string, unknown> | undefined) => Promise<FieldValue>,
|
|
362
|
+
): unknown {
|
|
363
|
+
const then = asyncResolve
|
|
364
|
+
? (onFulfilled?: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
|
|
365
|
+
asyncResolve(rootField, args).then((seeded) => materialize(seeded)).then(onFulfilled, onRejected)
|
|
366
|
+
: undefined;
|
|
367
|
+
|
|
368
|
+
// Materialize once per resolved value: `syncResolve` throws the Suspense promise
|
|
369
|
+
// until seeded, so nothing is cached until it succeeds; afterwards repeated reads
|
|
370
|
+
// return the same array/proxies (stable identity, matching a plain array/object).
|
|
371
|
+
let ready = false;
|
|
372
|
+
let value: unknown;
|
|
373
|
+
const syncValue = (): Record<PropertyKey, unknown> => {
|
|
374
|
+
if (!ready) {
|
|
375
|
+
value = materialize(syncResolve(rootField, args));
|
|
376
|
+
ready = true;
|
|
377
|
+
}
|
|
378
|
+
return value as Record<PropertyKey, unknown>;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// A real array target for list roots (so `Array.isArray` is true and index/length
|
|
382
|
+
// reads behave like the array they materialize to); a plain object otherwise. Both
|
|
383
|
+
// are empty and carry no own keys, so enumeration/spread never leak internals.
|
|
384
|
+
const target: object = isList ? [] : {};
|
|
385
|
+
|
|
386
|
+
return new Proxy(target, {
|
|
387
|
+
get(_t, prop) {
|
|
388
|
+
if (prop === "then") return then;
|
|
389
|
+
// Any other read sync-resolves (Suspense) then delegates to the materialized
|
|
390
|
+
// value/array. Bind functions (Array.prototype.* / callable graph fields) to
|
|
391
|
+
// that value so `this` is correct.
|
|
392
|
+
const v = syncValue();
|
|
393
|
+
const got = v[prop];
|
|
394
|
+
return typeof got === "function" ? (got as (...a: unknown[]) => unknown).bind(v) : got;
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
package/src/route.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface CompiledOperation<RouteContext = unknown, TVariables = Record<s
|
|
|
20
20
|
readonly readMap?: Record<string, readonly string[]>;
|
|
21
21
|
/** Merged selection tree; enables cache-first resolution when present. */
|
|
22
22
|
readonly selection?: SelectionSet;
|
|
23
|
+
/** True when one or more root reads take render-time ("two-sweep") args; those
|
|
24
|
+
* roots are executed at the call-site, not preloaded from ctx. See `runtimeVars`. */
|
|
25
|
+
readonly deferred?: boolean;
|
|
26
|
+
/** Operation variables ($vars) supplied at the render call-site (the factory
|
|
27
|
+
* omits them); used to prune the eager preload + identify deferred roots. */
|
|
28
|
+
readonly runtimeVars?: readonly string[];
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface RunRouteOptions {
|
package/src/runtime.ts
CHANGED
|
@@ -48,6 +48,7 @@ interface PendingEntry {
|
|
|
48
48
|
export class GraphRuntime {
|
|
49
49
|
readonly cache: GraphCache;
|
|
50
50
|
private readonly pending = new Map<string, PendingEntry>();
|
|
51
|
+
private readonly resolvedRoots = new Map<string, Record<string, FieldValue>>();
|
|
51
52
|
private queue: MissingFieldRead[] = [];
|
|
52
53
|
private flushScheduled = false;
|
|
53
54
|
|
|
@@ -73,6 +74,79 @@ export class GraphRuntime {
|
|
|
73
74
|
throw entry.promise;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Suspense primitive for a render-time ("two-sweep") root read: run `exec`
|
|
79
|
+
* once per `key` (it fetches the root with the call-site args and seeds the
|
|
80
|
+
* cache), throwing its promise until it resolves, then return the seeded root
|
|
81
|
+
* refs. Mirrors `readField`'s pending/throw/resume, keyed by the root field +
|
|
82
|
+
* its args. Because `exec` SEEDS before resolving, every subsequent field read
|
|
83
|
+
* on the returned refs is a cache hit — so a deferred root's fields never reach
|
|
84
|
+
* `reportMiss` (they aren't "unexpected" misses).
|
|
85
|
+
*/
|
|
86
|
+
resolveRoot(key: string, exec: () => Promise<Record<string, FieldValue>>): Record<string, FieldValue> {
|
|
87
|
+
const done = this.resolvedRoots.get(key);
|
|
88
|
+
if (done) return done;
|
|
89
|
+
|
|
90
|
+
const pkey = `@root:${key}`;
|
|
91
|
+
const existing = this.pending.get(pkey);
|
|
92
|
+
if (existing) throw existing.promise; // dedupe in-flight / stable across retries
|
|
93
|
+
|
|
94
|
+
const entry = this.makeDeferred();
|
|
95
|
+
this.pending.set(pkey, entry);
|
|
96
|
+
exec().then(
|
|
97
|
+
(roots) => {
|
|
98
|
+
this.resolvedRoots.set(key, roots);
|
|
99
|
+
this.pending.delete(pkey);
|
|
100
|
+
entry.resolve();
|
|
101
|
+
},
|
|
102
|
+
(error) => {
|
|
103
|
+
this.pending.delete(pkey);
|
|
104
|
+
entry.reject(error);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
throw entry.promise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Async twin of {@link resolveRoot} for non-React callers (server handlers,
|
|
112
|
+
* proxy): run `exec` once per `key` and RESOLVE to the seeded root refs instead
|
|
113
|
+
* of throwing a Suspense promise. Shares the `resolvedRoots` cache and the
|
|
114
|
+
* `pending` map with `resolveRoot`, so an `await` and a concurrent sync (thrown)
|
|
115
|
+
* read of the same root+args dedupe to a single in-flight fetch.
|
|
116
|
+
*/
|
|
117
|
+
async resolveRootAsync(key: string, exec: () => Promise<Record<string, FieldValue>>): Promise<Record<string, FieldValue>> {
|
|
118
|
+
const done = this.resolvedRoots.get(key);
|
|
119
|
+
if (done) return done;
|
|
120
|
+
|
|
121
|
+
const pkey = `@root:${key}`;
|
|
122
|
+
const existing = this.pending.get(pkey);
|
|
123
|
+
if (existing) {
|
|
124
|
+
// A sync read already kicked off this fetch — await it, then read the cache.
|
|
125
|
+
await existing.promise;
|
|
126
|
+
return this.resolvedRoots.get(key) ?? {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const entry = this.makeDeferred();
|
|
130
|
+
// The barrier promise only exists to signal concurrent readers (a sync reader
|
|
131
|
+
// `throw`s it, an async reader `await`s it). On the solo path nothing attaches
|
|
132
|
+
// to it, so observe its rejection here — the error still propagates to THIS
|
|
133
|
+
// caller via the `throw` below; without this a failing `await glean.x()` would
|
|
134
|
+
// surface an unhandled rejection (noisy / isolate-fatal in a Worker).
|
|
135
|
+
entry.promise.catch(() => {});
|
|
136
|
+
this.pending.set(pkey, entry);
|
|
137
|
+
try {
|
|
138
|
+
const roots = await exec();
|
|
139
|
+
this.resolvedRoots.set(key, roots);
|
|
140
|
+
this.pending.delete(pkey);
|
|
141
|
+
entry.resolve();
|
|
142
|
+
return roots;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
this.pending.delete(pkey);
|
|
145
|
+
entry.reject(error);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
76
150
|
/** Seed a record's fields (e.g. from the compiled operation result). */
|
|
77
151
|
seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
|
|
78
152
|
this.cache.merge(ref, fields);
|