@laguzjs/client 0.0.0-stage → 0.1.0

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/src/pool.ts ADDED
@@ -0,0 +1,441 @@
1
+ import {
2
+ collectRefs,
3
+ diff,
4
+ getPath,
5
+ merge,
6
+ mergeData,
7
+ name,
8
+ pathTree,
9
+ printArgs,
10
+ printTree,
11
+ printValue,
12
+ toTree,
13
+ touchesList,
14
+ type Fields,
15
+ type Ref,
16
+ type Tree,
17
+ } from "./selection.ts";
18
+
19
+ export type GqlError = { message: string; path?: (string | number)[] };
20
+ export type Response = {
21
+ data?: Record<string, unknown> | null;
22
+ errors?: GqlError[];
23
+ extensions?: Record<string, unknown>;
24
+ };
25
+ /** Sends a query. Returns one response, or a stream of partial responses. */
26
+ export type Fetcher = (query: string) => Promise<Response> | AsyncIterable<Response>;
27
+ export type Snapshot = { data?: any; error?: Error; has: Tree };
28
+ /** A root field name, or a predicate on the root field and its printed arguments. */
29
+ export type Match = string | ((root: string, args: string) => boolean);
30
+
31
+ export type Options = {
32
+ /** Milliseconds before cached data is refetched when a new consumer mounts. Default 0. */
33
+ staleTime?: number;
34
+ /** Milliseconds an entry without consumers stays cached. Default 5 minutes. */
35
+ gcTime?: number;
36
+ /** Retries before an error is reported. Default 3. */
37
+ retry?: number;
38
+ /** Delay before retry `attempt` (0-based). Default exponential, capped at 30 s. */
39
+ retryDelay?: (attempt: number) => number;
40
+ /** Maximum root fields per request, for servers that limit aliases. Default unlimited. */
41
+ maxBatch?: number;
42
+ /**
43
+ * Whether the server resolves refs itself (see @laguzjs/server), so dependent
44
+ * queries can share a request. `"auto"` (default) starts resolving refs on
45
+ * the client and switches once a response carries `extensions.laguz`.
46
+ */
47
+ serverRefs?: boolean | "auto";
48
+ };
49
+
50
+ type Sub = { tree: Tree; cb: () => void };
51
+
52
+ type Entry = {
53
+ key: string;
54
+ root: string;
55
+ /** Printed arguments, refs in their readable key form. */
56
+ args: string;
57
+ argv: object;
58
+ refs: Ref[];
59
+ /** Arguments, refs resolved, that the cached data was fetched with. */
60
+ sent?: string;
61
+ snap: Snapshot;
62
+ subs: Set<Sub>;
63
+ inflight: Set<Tree>;
64
+ /** Incremented by invalidate and set; responses from an older epoch are ignored. */
65
+ epoch: number;
66
+ /** The next flush refetches the whole selection. */
67
+ stale: boolean;
68
+ /** Time of the last successful response, 0 if none. */
69
+ at: number;
70
+ fails: number;
71
+ gc?: ReturnType<typeof setTimeout>;
72
+ };
73
+
74
+ type Req = { e: Entry; tree: Tree; full: boolean; epoch: number; args: string; forwarded: boolean };
75
+
76
+ const EMPTY: Snapshot = Object.freeze({ has: {} });
77
+
78
+ /** Whether `snap` holds everything `tree` selects. */
79
+ export const ready = (snap: Snapshot, tree: Tree): boolean =>
80
+ "data" in snap && !diff(tree, snap.has);
81
+
82
+ function unref<T>(timer: T): T {
83
+ (timer as { unref?: () => void })?.unref?.();
84
+ return timer;
85
+ }
86
+
87
+ const toError = (err: GqlError | Error) => (err instanceof Error ? err : new Error(err.message));
88
+
89
+ export function createPool(fetcher: Fetcher, options: Options = {}) {
90
+ const {
91
+ staleTime = 0,
92
+ gcTime = 5 * 60_000,
93
+ retry = 3,
94
+ retryDelay = (attempt: number) => Math.min(1000 * 2 ** attempt, 30_000),
95
+ maxBatch = Infinity,
96
+ serverRefs = "auto",
97
+ } = options;
98
+
99
+ let forward = serverRefs === true;
100
+ let scheduled = false;
101
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
102
+ const entries = new Map<string, Entry>();
103
+ const dirty = new Set<Entry>();
104
+ const retrying = new Set<Entry>();
105
+ const loads = new Map<string, Promise<void>>();
106
+
107
+ function entry(root: string, args: object): Entry {
108
+ const printed = printArgs(args);
109
+ const key = name(root) + printed;
110
+ let e = entries.get(key);
111
+ if (!e) {
112
+ e = {
113
+ key,
114
+ root,
115
+ args: printed,
116
+ argv: args,
117
+ refs: collectRefs(args),
118
+ snap: EMPTY,
119
+ subs: new Set(),
120
+ inflight: new Set(),
121
+ epoch: 0,
122
+ stale: false,
123
+ at: 0,
124
+ fails: 0,
125
+ };
126
+ entries.set(key, e);
127
+ }
128
+ return e;
129
+ }
130
+
131
+ // Everything touched before the current task ends is fetched together.
132
+ function touch(e: Entry) {
133
+ dirty.add(e);
134
+ if (scheduled) return;
135
+ scheduled = true;
136
+ queueMicrotask(flush);
137
+ }
138
+
139
+ function release(e: Entry) {
140
+ if (e.subs.size) return;
141
+ e.gc = unref(setTimeout(() => e.subs.size || entries.delete(e.key), gcTime));
142
+ }
143
+
144
+ function update(e: Entry, snap: Snapshot) {
145
+ e.snap = snap;
146
+ for (const sub of e.subs) sub.cb();
147
+ }
148
+
149
+ /** What `e` still has to fetch, or null. `full` replaces the cached data. */
150
+ function need(e: Entry): { tree: Tree; full: boolean } | null {
151
+ const want = [...e.subs].reduce((tree, sub) => merge(tree, sub.tree), {} as Tree);
152
+ // A refetch already in flight will call touch() again when it lands.
153
+ if (e.stale) return e.inflight.size ? null : { tree: want, full: true };
154
+ const covered = [...e.inflight].reduce(merge, e.snap.has);
155
+ let tree = diff(want, covered);
156
+ // A scalar root field selects nothing but still needs one fetch.
157
+ if (!tree && !e.at && !e.inflight.size) tree = want;
158
+ if (!tree) return null;
159
+ return touchesList(tree, e.snap.data) ? { tree: want, full: true } : { tree, full: false };
160
+ }
161
+
162
+ /**
163
+ * The printed arguments of `e` with its refs replaced by their values. A ref
164
+ * whose dependency is part of `batch` becomes a placeholder the server fills
165
+ * in. Returns null while a value is unknown.
166
+ */
167
+ function resolve(e: Entry, batch?: Map<Entry, number>): string | null {
168
+ let known = true;
169
+ const printed = printArgs(e.argv, ({ $ref: [root, args, path] }) => {
170
+ const dep = entry(root, args);
171
+ if (ready(dep.snap, pathTree(path))) return printValue(getPath(dep.snap.data, path));
172
+ const index = batch?.get(dep);
173
+ if (index !== undefined) return `$_ref__q${index}__${path.replaceAll(".", "__")}`;
174
+ known = false;
175
+ return "null";
176
+ });
177
+ return known ? printed : null;
178
+ }
179
+
180
+ function dependencyError(e: Entry): Error | undefined {
181
+ for (const {
182
+ $ref: [root, args, path],
183
+ } of e.refs) {
184
+ const snap = entry(root, args).snap;
185
+ if (snap.error && !ready(snap, pathTree(path))) return snap.error;
186
+ }
187
+ }
188
+
189
+ function flush() {
190
+ scheduled = false;
191
+ const todo: (Entry | undefined)[] = [...dirty].filter((e) => e.subs.size);
192
+ dirty.clear();
193
+ const reqs: Req[] = [];
194
+ const batch = new Map<Entry, number>();
195
+
196
+ // Loop until nothing changes: an entry whose ref points into this batch
197
+ // can only join after its dependency, so dependencies get lower aliases.
198
+ let progress = true;
199
+ while (progress) {
200
+ progress = false;
201
+ todo.forEach((e, i) => {
202
+ if (!e) return;
203
+ let args = e.args;
204
+ let forwarded = false;
205
+
206
+ if (e.refs.length) {
207
+ const known = resolve(e);
208
+ if (known !== null) {
209
+ if (e.at && known !== e.sent) {
210
+ // A dependency's value changed since the last fetch.
211
+ e.epoch++;
212
+ e.inflight.clear();
213
+ e.stale = true;
214
+ }
215
+ args = known;
216
+ } else {
217
+ const err = dependencyError(e);
218
+ if (err) {
219
+ todo[i] = undefined;
220
+ if (e.snap.error !== err) update(e, { ...e.snap, error: err });
221
+ return;
222
+ }
223
+ const placeholder = forward ? resolve(e, batch) : null;
224
+ // Otherwise wait: the dependency's watcher touches this entry when it lands.
225
+ if (placeholder === null) return;
226
+ args = placeholder;
227
+ forwarded = true;
228
+ }
229
+ }
230
+
231
+ todo[i] = undefined;
232
+ progress = true;
233
+ const n = need(e);
234
+ if (!n) return;
235
+ e.inflight.add(n.tree);
236
+ batch.set(e, reqs.length);
237
+ reqs.push({ e, ...n, epoch: e.epoch, args, forwarded });
238
+ });
239
+ }
240
+
241
+ // Placeholders name aliases across the whole batch, so it can't be split.
242
+ const step = reqs.some((r) => r.forwarded) ? Infinity : maxBatch;
243
+ for (let i = 0; i < reqs.length; i += step) send(reqs.slice(i, i + step));
244
+ }
245
+
246
+ async function send(reqs: Req[]) {
247
+ const query = `{ ${reqs.map((r, i) => `q${i}: ${r.e.root}${r.args}${printTree(r.tree)}`).join(" ")} }`;
248
+ const pending = new Map(reqs.map((r, i) => [`q${i}`, r]));
249
+ try {
250
+ const res = fetcher(query);
251
+ for await (const chunk of Symbol.asyncIterator in res ? res : [await res]) {
252
+ if (serverRefs === "auto" && Number(chunk.extensions?.laguz) >= 1) forward = true;
253
+
254
+ const fatal = !chunk.data && chunk.errors?.find((err) => !err.path);
255
+ if (fatal) {
256
+ if (pending.size === 1) {
257
+ for (const r of pending.values()) settle(r, undefined, fatal);
258
+ return;
259
+ }
260
+ // A single invalid field fails validation for the whole document.
261
+ // Resend each field alone so only that one reports the error.
262
+ for (const r of pending.values()) {
263
+ if (!r.forwarded) {
264
+ send([r]);
265
+ continue;
266
+ }
267
+ // Alone, a forwarded field has no dependency to read from: it
268
+ // waits and resolves on the client instead.
269
+ r.e.inflight.delete(r.tree);
270
+ touch(r.e);
271
+ }
272
+ return;
273
+ }
274
+
275
+ for (const [alias, r] of pending) {
276
+ const err = chunk.errors?.find((e) => e.path?.[0] === alias);
277
+ if (err || (chunk.data && alias in chunk.data)) {
278
+ pending.delete(alias);
279
+ settle(r, chunk.data?.[alias], err);
280
+ }
281
+ }
282
+ }
283
+ for (const r of pending.values()) settle(r, undefined, { message: "no data in response" });
284
+ } catch (err) {
285
+ for (const r of pending.values()) settle(r, undefined, err as Error);
286
+ }
287
+ }
288
+
289
+ function settle(r: Req, data: unknown, err?: GqlError | Error) {
290
+ const e = r.e;
291
+ e.inflight.delete(r.tree);
292
+ if (r.epoch !== e.epoch) return;
293
+
294
+ if (err) {
295
+ if (e.fails++ < retry) {
296
+ // One timer for every entry that failed in this window keeps the
297
+ // retries batched. They all wait for the first entry's delay.
298
+ retrying.add(e);
299
+ retryTimer ??= setTimeout(
300
+ () => {
301
+ retryTimer = undefined;
302
+ retrying.forEach(touch);
303
+ retrying.clear();
304
+ },
305
+ retryDelay(e.fails - 1),
306
+ );
307
+ return;
308
+ }
309
+ update(e, { ...e.snap, error: toError(err) });
310
+ return;
311
+ }
312
+
313
+ e.fails = 0;
314
+ e.at = Date.now();
315
+ // For a forwarded request the dependency arrived first, so the value is known now.
316
+ e.sent = r.forwarded ? (resolve(e) ?? r.args) : r.args;
317
+ if (r.full) e.stale = false;
318
+ update(
319
+ e,
320
+ r.full
321
+ ? { data, has: r.tree }
322
+ : { data: mergeData(e.snap.data, data), has: merge(e.snap.has, r.tree) },
323
+ );
324
+ // Consumers that subscribed while this was in flight may want more.
325
+ if (e.subs.size) touch(e);
326
+ }
327
+
328
+ /** Mark matching entries stale. Those in use refetch together, in one request. */
329
+ function invalidate(match?: Match) {
330
+ for (const e of entries.values()) {
331
+ const hit = !match || (typeof match === "string" ? e.root === match : match(e.root, e.args));
332
+ if (!hit) continue;
333
+ e.epoch++;
334
+ e.inflight.clear();
335
+ e.stale = true;
336
+ if (e.subs.size) touch(e);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Write to the cache, e.g. an optimistic update. In-flight reads of the entry
342
+ * are dropped so they can't overwrite it. Returns a function that restores
343
+ * the previous value.
344
+ */
345
+ function set(root: string, args: object, fn: (data: any) => any) {
346
+ const e = entry(root, args);
347
+ const prev = e.snap;
348
+ e.epoch++;
349
+ e.inflight.clear();
350
+ e.stale = false;
351
+ update(e, { ...prev, data: fn(prev.data) });
352
+ if (e.subs.size) touch(e);
353
+ else release(e);
354
+ return () => update(e, prev);
355
+ }
356
+
357
+ /**
358
+ * Subscribe to `fields` of `root(args)`. The fetch goes out at the end of the
359
+ * current task, together with everything else registered in it.
360
+ * Returns the unsubscribe function.
361
+ */
362
+ function watch(
363
+ root: string,
364
+ args: object,
365
+ fields: Fields | Tree,
366
+ cb: () => void,
367
+ minStaleTime = 0,
368
+ ) {
369
+ const e = entry(root, args);
370
+ clearTimeout(e.gc);
371
+ if (e.at && Date.now() - e.at > Math.max(staleTime, minStaleTime)) e.stale = true;
372
+ if (e.snap.error) e.fails = 0;
373
+ const sub = { tree: Array.isArray(fields) ? toTree(fields) : fields, cb };
374
+ e.subs.add(sub);
375
+ touch(e);
376
+ // A ref depends on another field: subscribe to it and recheck this entry when it changes.
377
+ const deps = e.refs.map(({ $ref: [depRoot, depArgs, path] }) =>
378
+ watch(depRoot, depArgs, pathTree(path), () => touch(e)),
379
+ );
380
+ return () => {
381
+ e.subs.delete(sub);
382
+ deps.forEach((off) => off());
383
+ release(e);
384
+ };
385
+ }
386
+
387
+ /** The current snapshot of `root(args)`. Stable until the entry changes. */
388
+ const read = (root: string, args: object): Snapshot =>
389
+ entries.get(name(root) + printArgs(args))?.snap ?? EMPTY;
390
+
391
+ /**
392
+ * Fetch without staying subscribed (Suspense, prefetching). Joins the current
393
+ * batch. Resolves once the data is ready or the fetch failed.
394
+ */
395
+ function load(root: string, args: object, fields: Fields | Tree): Promise<void> {
396
+ const tree = Array.isArray(fields) ? toTree(fields) : fields;
397
+ if (ready(read(root, args), tree)) return Promise.resolve();
398
+ const key = name(root) + printArgs(args) + JSON.stringify(tree);
399
+ const existing = loads.get(key);
400
+ if (existing) return existing;
401
+ const before = read(root, args);
402
+ const promise = new Promise<void>((resolve) => {
403
+ const stop = watch(root, args, tree, () => {
404
+ const snap = read(root, args);
405
+ const failed = snap.error && snap !== before;
406
+ if (!ready(snap, tree) && !failed) return;
407
+ stop();
408
+ loads.delete(key);
409
+ resolve();
410
+ });
411
+ });
412
+ loads.set(key, promise);
413
+ return promise;
414
+ }
415
+
416
+ /**
417
+ * Run a mutation. Optimistic writes apply at once and are rolled back if it
418
+ * throws; the `invalidate` matches refetch afterwards either way.
419
+ */
420
+ async function mutate<T>(
421
+ run: () => Promise<T>,
422
+ o: {
423
+ optimistic?: [root: string, args: object, fn: (data: any) => any][];
424
+ invalidate?: Match[];
425
+ } = {},
426
+ ): Promise<T> {
427
+ const undo = (o.optimistic ?? []).map(([root, args, fn]) => set(root, args, fn));
428
+ try {
429
+ return await run();
430
+ } catch (err) {
431
+ undo.reverse().forEach((restore) => restore());
432
+ throw err;
433
+ } finally {
434
+ o.invalidate?.forEach((m) => invalidate(m));
435
+ }
436
+ }
437
+
438
+ return { watch, read, load, invalidate, set, mutate };
439
+ }
440
+
441
+ export type Pool = ReturnType<typeof createPool>;
package/src/react.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { ready, type Pool, type Snapshot } from "./pool.ts";
3
+ import { toTree, view, type Fields } from "./selection.ts";
4
+
5
+ const IDLE: Snapshot = Object.freeze({ has: {} });
6
+ const unsubscribed = () => () => {};
7
+
8
+ // React runs the subscribe step of every useSyncExternalStore in a commit in
9
+ // one synchronous pass, so everything a commit renders lands in one batch.
10
+ // Subscribing happens after paint. Registering during render would start the
11
+ // request a little earlier, but leaks when React discards a concurrent render.
12
+ function useEntry(pool: Pool, root: string, args: object | null, fields: Fields, minStaleTime = 0) {
13
+ const key = JSON.stringify([root, args, fields]);
14
+ const tree = useMemo(() => toTree(fields), [key]);
15
+ const subscribe = useCallback(
16
+ (cb: () => void) => (args ? pool.watch(root, args, tree, cb, minStaleTime) : unsubscribed()),
17
+ [pool, key],
18
+ );
19
+ const snap = useSyncExternalStore(subscribe, () => (args ? pool.read(root, args) : IDLE));
20
+ const data = useMemo(() => view(snap.data, fields), [snap.data, key]);
21
+ return { snap, tree, data };
22
+ }
23
+
24
+ /**
25
+ * Read `fields` of `root(args)`. Pass `args: null` to skip the query, for
26
+ * example until the value it depends on has loaded.
27
+ */
28
+ export function useData<T = any>(pool: Pool, root: string, args: object | null, fields: Fields) {
29
+ const { snap, tree, data } = useEntry(pool, root, args, fields);
30
+ return {
31
+ data: data as T | undefined,
32
+ error: snap.error,
33
+ loading: !!args && !snap.error && !ready(snap, tree),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Like {@link useData}, but suspends until the data is ready and throws errors
39
+ * to the nearest error boundary. Call `pool.invalidate(root)` when the boundary
40
+ * resets to retry.
41
+ *
42
+ * Give each component its own `<Suspense>` boundary. React stops rendering the
43
+ * siblings of a component that suspends, so components sharing a boundary are
44
+ * fetched in two requests instead of one.
45
+ */
46
+ export function useSuspenseData<T = any>(
47
+ pool: Pool,
48
+ root: string,
49
+ args: object,
50
+ fields: Fields,
51
+ ): T {
52
+ // Data that just resolved shouldn't be refetched when the component commits.
53
+ const { snap, tree, data } = useEntry(pool, root, args, fields, 1000);
54
+ if (snap.error) throw snap.error;
55
+ if (!ready(snap, tree)) throw pool.load(root, args, tree);
56
+ return data;
57
+ }
58
+
59
+ export type Page = { root: string; args: object; fields: Fields };
60
+
61
+ /**
62
+ * An infinite list. `page(prev, index)` describes page `index` from the data of
63
+ * the previous page (its cursor), or returns null after the last page.
64
+ *
65
+ * Every page is a regular cache entry: refreshing refetches all loaded pages in
66
+ * one request, and pages whose cursor moved are fetched right after.
67
+ * `prefetch` keeps that many pages loaded ahead, so `loadMore` shows them at once.
68
+ */
69
+ export function useInfiniteData<T = any>(
70
+ pool: Pool,
71
+ page: (prev: T | undefined, index: number) => Page | null,
72
+ { prefetch = 0 } = {},
73
+ ) {
74
+ const [shown, setShown] = useState(1);
75
+
76
+ // Page n needs page n-1's data for its cursor, so walk the cache in order.
77
+ const pages: Page[] = [];
78
+ const views: T[] = [];
79
+ let end = false;
80
+ for (let i = 0; i < shown + prefetch; i++) {
81
+ const p = page(views[i - 1], i);
82
+ if (!p) {
83
+ end = true;
84
+ break;
85
+ }
86
+ pages.push(p);
87
+ const snap = pool.read(p.root, p.args);
88
+ if (!ready(snap, toTree(p.fields))) break;
89
+ views.push(view(snap.data, p.fields));
90
+ }
91
+
92
+ // Re-render when any page changes; the walk above then reads the new data.
93
+ const notify = useRef(() => {});
94
+ const subscribe = useCallback((cb: () => void) => {
95
+ notify.current = cb;
96
+ return () => {
97
+ notify.current = () => {};
98
+ };
99
+ }, []);
100
+ const lastSnaps = useRef<Snapshot[]>([]);
101
+ useSyncExternalStore(subscribe, () => {
102
+ const snaps = pages.map((p) => pool.read(p.root, p.args));
103
+ const prev = lastSnaps.current;
104
+ if (snaps.length === prev.length && snaps.every((s, i) => s === prev[i])) return prev;
105
+ lastSnaps.current = snaps;
106
+ return snaps;
107
+ });
108
+
109
+ // Keep one subscription per page for as long as the page is in the list.
110
+ // Resubscribing every page when the list grows would count as a new mount
111
+ // for each of them and refetch them all.
112
+ const watches = useRef(new Map<string, () => void>());
113
+ const key = JSON.stringify(pages);
114
+ useEffect(() => {
115
+ const next = new Map<string, () => void>();
116
+ for (const p of pages) {
117
+ const k = JSON.stringify(p);
118
+ next.set(
119
+ k,
120
+ watches.current.get(k) ?? pool.watch(p.root, p.args, p.fields, () => notify.current()),
121
+ );
122
+ watches.current.delete(k);
123
+ }
124
+ // Whatever is left belongs to pages that dropped out, e.g. after a cursor moved.
125
+ watches.current.forEach((unwatch) => unwatch());
126
+ watches.current = next;
127
+ }, [pool, key]);
128
+ useEffect(
129
+ () => () => {
130
+ watches.current.forEach((unwatch) => unwatch());
131
+ watches.current = new Map();
132
+ },
133
+ [pool],
134
+ );
135
+
136
+ const loaded = Math.min(views.length, shown);
137
+ const hasMore =
138
+ loaded === shown &&
139
+ (views.length > shown || (!end && page(views[loaded - 1], loaded) !== null));
140
+ const error = pages.map((p) => pool.read(p.root, p.args).error).find(Boolean);
141
+ return {
142
+ pages: views.slice(0, shown),
143
+ hasMore,
144
+ loading: views.length === 0 && !error,
145
+ loadingMore: views.length > 0 && views.length < shown && !error,
146
+ error,
147
+ loadMore: () => {
148
+ if (hasMore) setShown(loaded + 1);
149
+ },
150
+ };
151
+ }