@gleanql/client 0.1.13 → 0.1.14

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,16 @@ 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>;
275
286
  /** Seed a record's fields (e.g. from the compiled operation result). */
276
287
  seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void;
277
288
  /** Normalize a full operation result into the cache; returns root refs. */
@@ -328,6 +339,12 @@ interface CompiledOperation<RouteContext = unknown, TVariables = Record<string,
328
339
  readonly readMap?: Record<string, readonly string[]>;
329
340
  /** Merged selection tree; enables cache-first resolution when present. */
330
341
  readonly selection?: SelectionSet;
342
+ /** True when one or more root reads take render-time ("two-sweep") args; those
343
+ * roots are executed at the call-site, not preloaded from ctx. See `runtimeVars`. */
344
+ readonly deferred?: boolean;
345
+ /** Operation variables ($vars) supplied at the render call-site (the factory
346
+ * omits them); used to prune the eager preload + identify deferred roots. */
347
+ readonly runtimeVars?: readonly string[];
331
348
  }
332
349
  interface RunRouteOptions {
333
350
  /**
@@ -468,6 +485,19 @@ interface BindGraphOptions {
468
485
  * the server/isomorphic accessor omits it.
469
486
  */
470
487
  readonly tracker?: Set<string>;
488
+ /**
489
+ * Root fields whose args are computed at the render call-site ("two-sweep").
490
+ * They aren't preloaded; the callable executes them on demand via
491
+ * {@link BindGraphOptions.resolveDeferredRoot}.
492
+ */
493
+ readonly deferredRoots?: ReadonlySet<string>;
494
+ /**
495
+ * Execute a deferred root with its call-site args and return the seeded value
496
+ * (a ref, or an array of refs for a list root). Suspends (throws a promise)
497
+ * until the fetch+seed completes, then returns synchronously on retry. Wired by
498
+ * the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
499
+ */
500
+ readonly resolveDeferredRoot?: (rootField: string, args: Record<string, unknown> | undefined) => FieldValue;
471
501
  }
472
502
  declare function bindGraph(options: BindGraphOptions): BoundGraph;
473
503
  //#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,33 @@ 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
+ }
485
513
  /** Seed a record's fields (e.g. from the compiled operation result). */
486
514
  seed(ref, fields) {
487
515
  this.cache.merge(ref, fields);
@@ -771,11 +799,16 @@ function bindGraph(options) {
771
799
  const rootFields = options.schema.getType(queryType)?.fields ?? {};
772
800
  const graph = {};
773
801
  for (const [fieldName, fieldDef] of Object.entries(rootFields)) graph[fieldName] = (args) => {
774
- const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
775
802
  const trail = [{
776
803
  name: fieldName,
777
804
  ...args ? { args } : {}
778
805
  }];
806
+ if (options.deferredRoots?.has(fieldName) && options.resolveDeferredRoot) {
807
+ const seededDeferred = options.resolveDeferredRoot(fieldName, args);
808
+ if (fieldDef.list) return (Array.isArray(seededDeferred) ? seededDeferred : []).map((item) => wrap(binding, item, fieldDef.type, trail));
809
+ return wrap(binding, seededDeferred, fieldDef.type, trail);
810
+ }
811
+ const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
779
812
  if (fieldDef.list) return (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail));
780
813
  return createGraphProxy(binding, isGraphRef(seeded) ? seeded : { path: `${queryType}.${fieldName}(${canonicalArgs(toArgMap(args))})` }, fieldDef.type, trail);
781
814
  };
@@ -1158,6 +1191,211 @@ function buildRouteContext(requestInfo, options = {}) {
1158
1191
  };
1159
1192
  }
1160
1193
  //#endregion
1194
+ //#region src/paginate.ts
1195
+ /**
1196
+ * Execute a single deferred ("two-sweep") root read with args computed at the
1197
+ * render call-site, and seed the cache — the runtime half of the deferred-args
1198
+ * feature. Reuses the pagination machinery (`buildPageOperation` builds a
1199
+ * single-root operation from the compiled selection, turning the call-site args
1200
+ * into `$vars` with schema-derived types) but seeds the result as a fresh root
1201
+ * (`seedResult`) instead of appending a connection page. Pure — exported for
1202
+ * testing. The caller (the bound-graph deferred branch) wraps this in
1203
+ * `runtime.resolveRoot(...)` for Suspense de-dup + resume.
1204
+ */
1205
+ async function resolveDeferredRoot(params) {
1206
+ const { op, rootField, args, schema, adapter, runtime, context } = params;
1207
+ const built = buildPageOperation(op, [{
1208
+ name: rootField,
1209
+ args
1210
+ }], args, schema);
1211
+ if (!built) return { ok: false };
1212
+ let result;
1213
+ try {
1214
+ result = await adapter.execute({
1215
+ name: built.name,
1216
+ kind: "query",
1217
+ document: built.document
1218
+ }, args, context);
1219
+ } catch (err) {
1220
+ return {
1221
+ ok: false,
1222
+ error: errorMessage(err)
1223
+ };
1224
+ }
1225
+ if (result?.errors?.length) return {
1226
+ ok: false,
1227
+ error: result.errors[0].message
1228
+ };
1229
+ return {
1230
+ ok: true,
1231
+ roots: result?.data ? runtime.seedResult(result.data) : {}
1232
+ };
1233
+ }
1234
+ /**
1235
+ * Split a deferred operation into the part that can be preloaded eagerly from
1236
+ * `ctx` and the set of root fields whose args are render-time (`runtimeVars`).
1237
+ * Returns a pruned eager op (only the non-deferred roots, with just the vars they
1238
+ * still use) — `undefined` when no eager roots remain (a pure two-sweep route) —
1239
+ * plus the deferred root field names. Pure — exported for testing.
1240
+ */
1241
+ function splitDeferredRoots(op, runtimeVars) {
1242
+ const deferredRoots = /* @__PURE__ */ new Set();
1243
+ const eagerFields = [];
1244
+ for (const f of op.selection?.fields ?? []) {
1245
+ const vars = /* @__PURE__ */ new Set();
1246
+ for (const [, v] of f.args ?? []) collectArgValueVars(v, vars);
1247
+ if ([...vars].some((v) => runtimeVars.has(v))) deferredRoots.add(f.name);
1248
+ else eagerFields.push(f);
1249
+ }
1250
+ if (eagerFields.length === 0 || !op.selection) return { deferredRoots };
1251
+ const selection = {
1252
+ typeName: op.selection.typeName,
1253
+ fields: eagerFields
1254
+ };
1255
+ const used = collectVarNames(selection);
1256
+ const variables = parseVariableDefs(op.document).filter((v) => used.has(v.name));
1257
+ const name = `${op.name}_eager`;
1258
+ return {
1259
+ eager: {
1260
+ name,
1261
+ kind: "query",
1262
+ document: printOperation({
1263
+ kind: "query",
1264
+ name,
1265
+ variables,
1266
+ selection
1267
+ }),
1268
+ selection
1269
+ },
1270
+ deferredRoots
1271
+ };
1272
+ }
1273
+ /** Variable names referenced in a single arg value (non-recursive into selections). */
1274
+ function collectArgValueVars(v, out) {
1275
+ if (v.kind === "var") out.add(v.name);
1276
+ else if (v.kind === "list") v.items.forEach((i) => collectArgValueVars(i, out));
1277
+ else if (v.kind === "object") v.fields.forEach(([, vv]) => collectArgValueVars(vv, out));
1278
+ }
1279
+ /** The op field whose response key matches a runtime path step (handles arg-aliasing). */
1280
+ function pickStepField(fields, step) {
1281
+ const named = fields.filter((f) => f.name === step.name);
1282
+ if (named.length <= 1) return named[0];
1283
+ const keys = responseKeyCandidates(step.name, toArgMap(step.args));
1284
+ return named.find((f) => keys.includes(f.alias ?? f.name)) ?? named[0];
1285
+ }
1286
+ /** Replace/add args on the connection field, turning each caller arg into a `$var`. */
1287
+ function withUserArgs(existing, args) {
1288
+ const map = new Map(existing ?? []);
1289
+ for (const name of Object.keys(args)) map.set(name, {
1290
+ kind: "var",
1291
+ name
1292
+ });
1293
+ return [...map.entries()];
1294
+ }
1295
+ /** Clone the single root→connection path out of `op`, overriding the connection's args. */
1296
+ function clonePathField(parent, trail, depth, args) {
1297
+ const field = pickStepField(parent.fields, trail[depth]);
1298
+ if (!field) return void 0;
1299
+ if (depth === trail.length - 1) return {
1300
+ ...field,
1301
+ args: withUserArgs(field.args, args)
1302
+ };
1303
+ if (!field.selection) return void 0;
1304
+ const child = clonePathField(field.selection, trail, depth + 1, args);
1305
+ if (!child) return void 0;
1306
+ const identity = field.selection.fields.filter((f) => !f.selection && (f.name === "__typename" || f.name === "id"));
1307
+ return {
1308
+ ...field,
1309
+ selection: {
1310
+ typeName: field.selection.typeName,
1311
+ fields: [...identity, child]
1312
+ }
1313
+ };
1314
+ }
1315
+ /** Walk the schema along `trail` to the connection field's declared arg types. */
1316
+ function connectionArgTypes(trail, schema) {
1317
+ let parentType = schema.queryType;
1318
+ let fieldDef;
1319
+ for (const step of trail) {
1320
+ if (!parentType) return {};
1321
+ fieldDef = schema.getField(parentType, step.name);
1322
+ parentType = fieldDef?.type;
1323
+ }
1324
+ const out = {};
1325
+ for (const a of fieldDef?.args ?? []) out[a.name] = a.type;
1326
+ return out;
1327
+ }
1328
+ /**
1329
+ * Build a query for the NEXT page of the connection at `trail`: the single path from
1330
+ * a Query root to it (with its full node/pageInfo selection), with the caller's
1331
+ * `args` overriding the connection field's arguments as `$vars`. Returns `undefined`
1332
+ * if the path isn't in the op. Pure — exported for testing.
1333
+ */
1334
+ function buildPageOperation(op, trail, args, schema) {
1335
+ if (!op.selection || trail.length === 0) return void 0;
1336
+ const pathField = clonePathField(op.selection, trail, 0, args);
1337
+ if (!pathField) return void 0;
1338
+ const selection = {
1339
+ typeName: op.selection.typeName,
1340
+ fields: [pathField]
1341
+ };
1342
+ const used = collectVarNames(selection);
1343
+ const argTypes = connectionArgTypes(trail, schema);
1344
+ const headerVars = parseVariableDefs(op.document).filter((v) => used.has(v.name));
1345
+ const argVars = Object.keys(args).map((name) => ({
1346
+ name,
1347
+ type: argTypes[name] ?? "String"
1348
+ }));
1349
+ const variables = dedupeVarsByName([...headerVars, ...argVars]);
1350
+ const name = `${op.name}_page`;
1351
+ return {
1352
+ name,
1353
+ kind: "query",
1354
+ document: printOperation({
1355
+ kind: "query",
1356
+ name,
1357
+ variables,
1358
+ selection
1359
+ })
1360
+ };
1361
+ }
1362
+ function dedupeVarsByName(vars) {
1363
+ const seen = /* @__PURE__ */ new Map();
1364
+ for (const v of vars) if (!seen.has(v.name)) seen.set(v.name, v);
1365
+ return [...seen.values()];
1366
+ }
1367
+ /** Names of operation variables referenced anywhere in a selection's arguments. */
1368
+ function collectVarNames(sel) {
1369
+ const out = /* @__PURE__ */ new Set();
1370
+ const fromValue = (v) => {
1371
+ if (v.kind === "var") out.add(v.name);
1372
+ else if (v.kind === "list") v.items.forEach(fromValue);
1373
+ else if (v.kind === "object") v.fields.forEach(([, vv]) => fromValue(vv));
1374
+ };
1375
+ const fromArgs = (args) => (args ?? []).forEach(([, v]) => fromValue(v));
1376
+ const walk = (s) => {
1377
+ for (const f of s.fields) {
1378
+ fromArgs(f.args);
1379
+ if (f.selection) walk(f.selection);
1380
+ }
1381
+ for (const fr of s.inlineFragments ?? []) walk(fr.selection);
1382
+ };
1383
+ walk(sel);
1384
+ return out;
1385
+ }
1386
+ /** Parse `query Name($a: T!, $b: [U!])` → variable defs (operation header only). */
1387
+ function parseVariableDefs(document) {
1388
+ const m = /^\s*(?:query|mutation|subscription)\s+\w+\s*\(([^)]*)\)\s*\{/.exec(document);
1389
+ if (!m?.[1]) return [];
1390
+ return m[1].split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
1391
+ const colon = s.indexOf(":");
1392
+ return {
1393
+ name: s.slice(0, colon).trim().replace(/^\$/, ""),
1394
+ type: s.slice(colon + 1).trim()
1395
+ };
1396
+ });
1397
+ }
1398
+ //#endregion
1161
1399
  //#region src/integration.ts
1162
1400
  /**
1163
1401
  * RedwoodSDK integration.
@@ -1190,19 +1428,65 @@ function createGraphIntegration(options) {
1190
1428
  if (!operation) return void 0;
1191
1429
  const requestContext = buildRouteContext(requestInfo, options);
1192
1430
  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
- });
1431
+ let variables;
1432
+ let roots;
1433
+ let errors;
1434
+ let deferredRoots;
1435
+ if (operation.deferred) {
1436
+ const split = splitDeferredRoots(operation, new Set(operation.runtimeVars ?? []));
1437
+ deferredRoots = split.deferredRoots;
1438
+ if (split.eager) {
1439
+ const r = await runRoute({
1440
+ operation: {
1441
+ ...operation,
1442
+ name: split.eager.name,
1443
+ document: split.eager.document,
1444
+ selection: split.eager.selection
1445
+ },
1446
+ routeContext: requestContext,
1447
+ adapter: options.adapter,
1448
+ context: requestContext,
1449
+ runtime
1450
+ });
1451
+ ({variables, roots, errors} = r);
1452
+ } else {
1453
+ variables = operation.variables(requestContext);
1454
+ roots = {};
1455
+ }
1456
+ } else {
1457
+ const r = await runRoute({
1458
+ operation,
1459
+ routeContext: requestContext,
1460
+ adapter: options.adapter,
1461
+ context: requestContext,
1462
+ runtime
1463
+ });
1464
+ ({variables, roots, errors} = r);
1465
+ }
1466
+ const resolveDeferred = operation.deferred ? (rootField, args) => {
1467
+ const key = `${rootField}(${canonicalArgs(toArgMap(args ?? {}))})`;
1468
+ return runtime.resolveRoot(key, async () => {
1469
+ const res = await resolveDeferredRoot({
1470
+ op: operation,
1471
+ rootField,
1472
+ args: args ?? {},
1473
+ schema: options.schema,
1474
+ adapter: options.adapter,
1475
+ runtime,
1476
+ context: requestContext
1477
+ });
1478
+ if (!res.ok && res.error) throw new Error(res.error);
1479
+ return res.roots ?? {};
1480
+ })[rootField];
1481
+ } : void 0;
1200
1482
  const active = {
1201
1483
  runtime,
1202
1484
  graph: bindGraph({
1203
1485
  schema: options.schema,
1204
1486
  getRuntime: () => runtime,
1205
- roots
1487
+ roots,
1488
+ ...deferredRoots ? { deferredRoots } : {},
1489
+ ...resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {}
1206
1490
  }),
1207
1491
  mutate: createMutator({
1208
1492
  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.14",
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.14"
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,65 @@ 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 (suspends via runtime.resolveRoot, seeds, then resolves to the ref(s)).
170
+ const resolveDeferred = operation.deferred
171
+ ? (rootField: string, args: Record<string, unknown> | undefined): FieldValue => {
172
+ const key = `${rootField}(${canonicalArgs(toArgMap(args ?? {}))})`;
173
+ const seededRoots = runtime.resolveRoot(key, async () => {
174
+ const res = await runDeferredRoot({
175
+ op: operation,
176
+ rootField,
177
+ args: args ?? {},
178
+ schema: options.schema,
179
+ adapter: options.adapter,
180
+ runtime,
181
+ context: requestContext,
182
+ });
183
+ if (!res.ok && res.error) throw new Error(res.error);
184
+ return res.roots ?? {};
185
+ });
186
+ return seededRoots[rootField];
187
+ }
188
+ : undefined;
189
+
190
+ const graph = bindGraph({
191
+ schema: options.schema,
192
+ getRuntime: () => runtime,
193
+ roots,
194
+ ...(deferredRoots ? { deferredRoots } : {}),
195
+ ...(resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {}),
142
196
  });
143
- const graph = bindGraph({ schema: options.schema, getRuntime: () => runtime, roots });
144
197
  const mutate = createMutator({ operations: options.operations, adapter: options.adapter, runtime, context: requestContext });
145
198
 
146
199
  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
@@ -252,6 +252,19 @@ export interface BindGraphOptions {
252
252
  * the server/isomorphic accessor omits it.
253
253
  */
254
254
  readonly tracker?: Set<string>;
255
+ /**
256
+ * Root fields whose args are computed at the render call-site ("two-sweep").
257
+ * They aren't preloaded; the callable executes them on demand via
258
+ * {@link BindGraphOptions.resolveDeferredRoot}.
259
+ */
260
+ readonly deferredRoots?: ReadonlySet<string>;
261
+ /**
262
+ * Execute a deferred root with its call-site args and return the seeded value
263
+ * (a ref, or an array of refs for a list root). Suspends (throws a promise)
264
+ * until the fetch+seed completes, then returns synchronously on retry. Wired by
265
+ * the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
266
+ */
267
+ readonly resolveDeferredRoot?: (rootField: string, args: Record<string, unknown> | undefined) => FieldValue;
255
268
  }
256
269
 
257
270
  export function bindGraph(options: BindGraphOptions): BoundGraph {
@@ -266,10 +279,24 @@ export function bindGraph(options: BindGraphOptions): BoundGraph {
266
279
  const graph: Record<string, (args?: Record<string, unknown>) => unknown> = {};
267
280
  for (const [fieldName, fieldDef] of Object.entries(rootFields)) {
268
281
  graph[fieldName] = (args?: Record<string, unknown>) => {
282
+ const trail: PathStep[] = [{ name: fieldName, ...(args ? { args } : {}) }];
283
+
284
+ // Deferred ("two-sweep") root: args are only known at the render call-site,
285
+ // so execute on demand (suspends until fetched + seeded) instead of reading
286
+ // a preloaded root. This replaces the silent empty-array fallback below for
287
+ // deferred list roots — `glean.nodes({ ids }).map(...)` fetches, not yields [].
288
+ if (options.deferredRoots?.has(fieldName) && options.resolveDeferredRoot) {
289
+ const seededDeferred = options.resolveDeferredRoot(fieldName, args);
290
+ if (fieldDef.list) {
291
+ const items = Array.isArray(seededDeferred) ? seededDeferred : [];
292
+ return items.map((item) => wrap(binding, item, fieldDef.type, trail));
293
+ }
294
+ return wrap(binding, seededDeferred, fieldDef.type, trail);
295
+ }
296
+
269
297
  const rootsNow =
270
298
  typeof options.roots === "function" ? options.roots() : options.roots;
271
299
  const seeded = rootsNow?.[fieldName];
272
- const trail: PathStep[] = [{ name: fieldName, ...(args ? { args } : {}) }];
273
300
  // A list root (`type Query { todos: [Todo!] }`) seeds an array of refs — wrap
274
301
  // each as a child proxy so `glean.todos().map(...)` works without an object
275
302
  // wrapper. Unseeded (pre-hydration / not yet fetched) -> empty array; the
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,39 @@ 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
+
76
110
  /** Seed a record's fields (e.g. from the compiled operation result). */
77
111
  seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
78
112
  this.cache.merge(ref, fields);