@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 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
- const { variables, roots, errors } = await runRoute({
1194
- operation,
1195
- routeContext: requestContext,
1196
- adapter: options.adapter,
1197
- context: requestContext,
1198
- runtime
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.13",
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.13"
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": "NODE_OPTIONS=--max-old-space-size=4096 tsdown src/index.ts --format esm --dts.eager"
55
+ "build": "tsdown src/index.ts --format esm --dts.eager"
56
56
  }
57
57
  }
@@ -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 type { SchemaModel } from "@gleanql/core";
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
- const { variables, roots, errors } = await runRoute({
137
- operation,
138
- routeContext: requestContext,
139
- adapter: options.adapter,
140
- context: requestContext,
141
- runtime,
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);