@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 +30 -0
- package/dist/index.mjs +294 -10
- package/package.json +3 -3
- package/src/integration.ts +61 -8
- package/src/paginate.ts +74 -0
- package/src/proxy.ts +28 -1
- package/src/route.ts +6 -0
- package/src/runtime.ts +34 -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,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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
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,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
|
-
|
|
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 (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);
|