@gleanql/client 0.1.14 → 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
@@ -283,6 +283,14 @@ declare class GraphRuntime {
283
283
  * `reportMiss` (they aren't "unexpected" misses).
284
284
  */
285
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>>;
286
294
  /** Seed a record's fields (e.g. from the compiled operation result). */
287
295
  seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void;
288
296
  /** Normalize a full operation result into the cache; returns root refs. */
@@ -498,6 +506,12 @@ interface BindGraphOptions {
498
506
  * the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
499
507
  */
500
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>;
501
515
  }
502
516
  declare function bindGraph(options: BindGraphOptions): BoundGraph;
503
517
  //#endregion
package/dist/index.mjs CHANGED
@@ -510,6 +510,37 @@ var GraphRuntime = class GraphRuntime {
510
510
  });
511
511
  throw entry.promise;
512
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
+ }
513
544
  /** Seed a record's fields (e.g. from the compiled operation result). */
514
545
  seed(ref, fields) {
515
546
  this.cache.merge(ref, fields);
@@ -750,6 +781,7 @@ const handler = {
750
781
  type: state.type
751
782
  };
752
783
  if (typeof prop === "symbol") return void 0;
784
+ if (prop === "then") return void 0;
753
785
  if (prop === "__typename") return state.ref.__typename ?? readField(state, "__typename");
754
786
  const fieldName = prop;
755
787
  const fieldDef = state.binding.schema.getField(state.type, fieldName);
@@ -804,9 +836,8 @@ function bindGraph(options) {
804
836
  ...args ? { args } : {}
805
837
  }];
806
838
  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);
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);
810
841
  }
811
842
  const seeded = (typeof options.roots === "function" ? options.roots() : options.roots)?.[fieldName];
812
843
  if (fieldDef.list) return (Array.isArray(seeded) ? seeded : []).map((item) => wrap(binding, item, fieldDef.type, trail));
@@ -814,6 +845,40 @@ function bindGraph(options) {
814
845
  };
815
846
  return graph;
816
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
+ }
817
882
  //#endregion
818
883
  //#region src/cache-resolve.ts
819
884
  /**
@@ -1463,22 +1528,22 @@ function createGraphIntegration(options) {
1463
1528
  });
1464
1529
  ({variables, roots, errors} = r);
1465
1530
  }
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;
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;
1482
1547
  const active = {
1483
1548
  runtime,
1484
1549
  graph: bindGraph({
@@ -1486,7 +1551,8 @@ function createGraphIntegration(options) {
1486
1551
  getRuntime: () => runtime,
1487
1552
  roots,
1488
1553
  ...deferredRoots ? { deferredRoots } : {},
1489
- ...resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {}
1554
+ ...resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {},
1555
+ ...resolveDeferredAsync ? { resolveDeferredRootAsync: resolveDeferredAsync } : {}
1490
1556
  }),
1491
1557
  mutate: createMutator({
1492
1558
  operations: options.operations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleanql/client",
3
- "version": "0.1.14",
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.14"
29
+ "@gleanql/core": "0.1.15"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "react": ">=18"
@@ -166,25 +166,35 @@ export function createGraphIntegration<Ctx extends Record<string, unknown> = Rec
166
166
  }
167
167
 
168
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)).
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
+
170
191
  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
- }
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]
188
198
  : undefined;
189
199
 
190
200
  const graph = bindGraph({
@@ -193,6 +203,7 @@ export function createGraphIntegration<Ctx extends Record<string, unknown> = Rec
193
203
  roots,
194
204
  ...(deferredRoots ? { deferredRoots } : {}),
195
205
  ...(resolveDeferred ? { resolveDeferredRoot: resolveDeferred } : {}),
206
+ ...(resolveDeferredAsync ? { resolveDeferredRootAsync: resolveDeferredAsync } : {}),
196
207
  });
197
208
  const mutate = createMutator({ operations: options.operations, adapter: options.adapter, runtime, context: requestContext });
198
209
 
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");
@@ -265,6 +270,15 @@ export interface BindGraphOptions {
265
270
  * the integration over `runtime.resolveRoot` + `resolveDeferredRoot`.
266
271
  */
267
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>;
268
282
  }
269
283
 
270
284
  export function bindGraph(options: BindGraphOptions): BoundGraph {
@@ -282,16 +296,24 @@ export function bindGraph(options: BindGraphOptions): BoundGraph {
282
296
  const trail: PathStep[] = [{ name: fieldName, ...(args ? { args } : {}) }];
283
297
 
284
298
  // 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 [].
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 [].
288
304
  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);
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
+ );
295
317
  }
296
318
 
297
319
  const rootsNow =
@@ -313,3 +335,63 @@ export function bindGraph(options: BindGraphOptions): BoundGraph {
313
335
  }
314
336
  return graph as BoundGraph;
315
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/runtime.ts CHANGED
@@ -107,6 +107,46 @@ export class GraphRuntime {
107
107
  throw entry.promise;
108
108
  }
109
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
+
110
150
  /** Seed a record's fields (e.g. from the compiled operation result). */
111
151
  seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
112
152
  this.cache.merge(ref, fields);