@gleanql/client 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/cache.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Graph cache with two storage identities (per the brief):
3
+ * - Normalized entity storage, keyed by `__typename + id`.
4
+ * - Operation/path storage, keyed by `root + args + path`, for objects with
5
+ * no `id`.
6
+ * Two query paths returning the same `__typename + id` resolve to one record,
7
+ * so an update through any path is visible through all of them.
8
+ */
9
+
10
+ /** A reference to a cached record: an identified entity or a path-anchored object. */
11
+ export interface GraphRef {
12
+ readonly __typename?: string;
13
+ readonly id?: string | number;
14
+ /** Path identity, e.g. `Query.product(handle).featuredImage`. */
15
+ readonly path?: string;
16
+ }
17
+
18
+ export type FieldValue = unknown;
19
+
20
+ export type FieldLookup =
21
+ | { readonly status: "ready"; readonly value: FieldValue }
22
+ | { readonly status: "missing" };
23
+
24
+ /** Separator joining a record key + field key into one field-tracking key (NUL — never in a key). */
25
+ const FIELD_SEP = "\u0000";
26
+
27
+ function fieldTrackingKey(recordKey: string, fieldKey: string): string {
28
+ return recordKey + FIELD_SEP + fieldKey;
29
+ }
30
+
31
+ export class GraphCache {
32
+ private readonly records = new Map<string, Map<string, FieldValue>>();
33
+
34
+ /**
35
+ * Optional LRU cap. The client cache accumulates entities across navigations; a
36
+ * long session would otherwise grow without bound. When set, the least-recently
37
+ * used records are evicted past the cap. Unset (default) = unbounded, so the
38
+ * server's per-request cache and existing callers are unchanged.
39
+ */
40
+ constructor(private readonly maxRecords?: number) {}
41
+
42
+ /**
43
+ * Reactivity substrate. Every write bumps `version` and notifies listeners, so
44
+ * UI can re-render after a mutation, refetch, or peer-tab/subscription update.
45
+ * `version` + `subscribe` are exactly the `useSyncExternalStore` contract.
46
+ */
47
+ private _version = 0;
48
+ private readonly listeners = new Set<() => void>();
49
+
50
+ /**
51
+ * Version counters for fine-grained reactivity, at two granularities. A component
52
+ * tracks the keys it read during render; its `useSyncExternalStore` snapshot is a
53
+ * digest of those keys' versions, so a global notify only re-renders the components
54
+ * whose keys actually changed (valtio's approach — no per-key subscription fan-out).
55
+ *
56
+ * - `recordVersions` bumps on ANY write to a record (record-level trackers, e.g.
57
+ * `usePaginated` watching a connection).
58
+ * - `fieldVersions` bumps only the written field (field-level trackers, e.g.
59
+ * `useGlean`, so reading `product.title` ignores a write to `product.views`).
60
+ *
61
+ * The global `version`/`subscribe` stay the notify channel; both granularities are
62
+ * resolved through {@link trackedVersion}.
63
+ */
64
+ private readonly recordVersions = new Map<string, number>();
65
+ private readonly fieldVersions = new Map<string, number>();
66
+
67
+ /** Current version of a record (0 if never written). */
68
+ recordVersion(key: string): number {
69
+ return this.recordVersions.get(key) ?? 0;
70
+ }
71
+
72
+ /** Current version of a single field on a record (0 if never written). */
73
+ fieldVersion(recordKey: string, fieldKey: string): number {
74
+ return this.fieldVersions.get(fieldTrackingKey(recordKey, fieldKey)) ?? 0;
75
+ }
76
+
77
+ /** The opaque tracking key a field read records; resolve it with {@link trackedVersion}. */
78
+ fieldTrackingKey(recordKey: string, fieldKey: string): string {
79
+ return fieldTrackingKey(recordKey, fieldKey);
80
+ }
81
+
82
+ /** Version of a tracked key: a bare record key, or `record\0field` for a single field. */
83
+ trackedVersion(trackingKey: string): number {
84
+ return trackingKey.includes(FIELD_SEP)
85
+ ? this.fieldVersions.get(trackingKey) ?? 0
86
+ : this.recordVersion(trackingKey);
87
+ }
88
+
89
+ /** Bump a record's version + (optionally) one of its fields' versions. */
90
+ private bumpRecord(key: string, fieldKey?: string): void {
91
+ this.recordVersions.set(key, (this.recordVersions.get(key) ?? 0) + 1);
92
+ if (fieldKey !== undefined) this.bumpField(key, fieldKey);
93
+ }
94
+
95
+ private bumpField(recordKey: string, fieldKey: string): void {
96
+ const k = fieldTrackingKey(recordKey, fieldKey);
97
+ this.fieldVersions.set(k, (this.fieldVersions.get(k) ?? 0) + 1);
98
+ }
99
+
100
+ get version(): number {
101
+ return this._version;
102
+ }
103
+
104
+ subscribe(listener: () => void): () => void {
105
+ this.listeners.add(listener);
106
+ return () => this.listeners.delete(listener);
107
+ }
108
+
109
+ private bump(): void {
110
+ this._version++;
111
+ for (const listener of this.listeners) listener();
112
+ }
113
+
114
+ /** Public notify: bump the version + run listeners (e.g. after `absorbRecords`). */
115
+ notify(): void {
116
+ this.bump();
117
+ }
118
+
119
+ /** Stable storage key for a ref: entity identity wins over path identity. */
120
+ recordKey(ref: GraphRef): string {
121
+ if (ref.__typename != null && ref.id != null) return `${ref.__typename}:${ref.id}`;
122
+ if (ref.path != null) return `path:${ref.path}`;
123
+ throw new Error("GraphRef requires either (__typename + id) or path");
124
+ }
125
+
126
+ /**
127
+ * Reference-counted retention (Relay-style). A mounted reader retains the
128
+ * records it displays; retained records are never LRU-evicted and survive
129
+ * {@link gc}. The tracking hooks do this automatically — each component
130
+ * retains what it read while mounted — so `gc()` is safe to call any time
131
+ * (e.g. on navigation): it can only drop records nothing on screen reads.
132
+ */
133
+ private readonly retainCounts = new Map<string, number>();
134
+
135
+ /** Pin a record. Returns the matching release; calling it twice is a no-op. */
136
+ retain(key: string): () => void {
137
+ this.stamp(key); // a mount counts as activity for staleness-aware gc
138
+ this.retainCounts.set(key, (this.retainCounts.get(key) ?? 0) + 1);
139
+ let released = false;
140
+ return () => {
141
+ if (released) return;
142
+ released = true;
143
+ const n = this.retainCounts.get(key) ?? 0;
144
+ if (n <= 1) this.retainCounts.delete(key);
145
+ else this.retainCounts.set(key, n - 1);
146
+ };
147
+ }
148
+
149
+ isRetained(key: string): boolean {
150
+ return this.retainCounts.has(key);
151
+ }
152
+
153
+ /** The record key a tracked key belongs to (strips the `\0field` part, if any). */
154
+ trackedRecordKey(trackingKey: string): string {
155
+ const i = trackingKey.indexOf(FIELD_SEP);
156
+ return i === -1 ? trackingKey : trackingKey.slice(0, i);
157
+ }
158
+
159
+ /**
160
+ * Generation clock for staleness-aware GC. The glue advances it on each page
161
+ * navigation; every read/write/retain stamps the record with the current
162
+ * epoch. "Unretained" alone is NOT a reason to drop data (a back-navigation
163
+ * should hit a warm cache) — `gc({ keepEpochs })` drops only records that are
164
+ * unretained AND haven't been touched for that many generations.
165
+ */
166
+ private epoch = 0;
167
+ private readonly lastActive = new Map<string, number>();
168
+
169
+ /** Advance the generation clock (call on navigation). Returns the new epoch. */
170
+ advanceEpoch(): number {
171
+ return ++this.epoch;
172
+ }
173
+
174
+ private stamp(key: string): void {
175
+ this.lastActive.set(key, this.epoch);
176
+ }
177
+
178
+ /**
179
+ * Drop unretained records; returns how many were dropped. Version counters
180
+ * survive, so if a dropped record is refetched its trackers still see
181
+ * monotonic versions.
182
+ *
183
+ * - `gc()` — drop EVERY unretained record (a full reset, e.g. logout).
184
+ * - `gc({ keepEpochs: N })` — drop only records also untouched for ≥ N
185
+ * generations (see {@link advanceEpoch}); recently-used data stays warm
186
+ * for back-navigation even though nothing on screen retains it.
187
+ */
188
+ gc(options: { keepEpochs?: number } = {}): number {
189
+ const { keepEpochs } = options;
190
+ let dropped = 0;
191
+ for (const key of [...this.records.keys()]) {
192
+ if (this.retainCounts.has(key)) continue;
193
+ if (keepEpochs != null && this.epoch - (this.lastActive.get(key) ?? 0) < keepEpochs) continue;
194
+ this.records.delete(key);
195
+ this.lastActive.delete(key);
196
+ dropped++;
197
+ }
198
+ if (dropped > 0) this.bump();
199
+ return dropped;
200
+ }
201
+
202
+ hasRecord(ref: GraphRef): boolean {
203
+ return this.records.has(this.recordKey(ref));
204
+ }
205
+
206
+ /** Mark a key most-recently-used (Map keeps insertion order; re-insert to bump). No-op when unbounded. */
207
+ private touch(key: string): void {
208
+ if (!this.maxRecords) return;
209
+ const rec = this.records.get(key);
210
+ if (rec) {
211
+ this.records.delete(key);
212
+ this.records.set(key, rec);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Evict least-recently-used records past the cap (Map's first key is the
218
+ * oldest), skipping retained records — a record someone on screen reads is
219
+ * never the eviction victim, even if it's the coldest. If retained records
220
+ * alone exceed the cap, the cache temporarily runs over it.
221
+ */
222
+ private evict(): void {
223
+ if (!this.maxRecords) return;
224
+ let over = this.records.size - this.maxRecords;
225
+ if (over <= 0) return;
226
+ for (const key of [...this.records.keys()]) {
227
+ if (over <= 0) break;
228
+ if (this.retainCounts.has(key)) continue;
229
+ this.records.delete(key);
230
+ over--;
231
+ }
232
+ }
233
+
234
+ /** Get-or-create the record map for a storage key. */
235
+ private ensureRecord(key: string): Map<string, FieldValue> {
236
+ this.stamp(key); // every write is activity
237
+ let rec = this.records.get(key);
238
+ if (!rec) {
239
+ rec = new Map();
240
+ this.records.set(key, rec);
241
+ } else {
242
+ this.touch(key);
243
+ }
244
+ return rec;
245
+ }
246
+
247
+ getField(ref: GraphRef, fieldKey: string): FieldLookup {
248
+ const key = this.recordKey(ref);
249
+ const rec = this.records.get(key);
250
+ if (rec && rec.has(fieldKey)) {
251
+ this.touch(key); // a read marks the record recently-used (LRU)
252
+ this.stamp(key); // ...and current-generation (staleness-aware gc)
253
+ return { status: "ready", value: rec.get(fieldKey) };
254
+ }
255
+ return { status: "missing" };
256
+ }
257
+
258
+ setField(ref: GraphRef, fieldKey: string, value: FieldValue): void {
259
+ const key = this.recordKey(ref);
260
+ this.ensureRecord(key).set(fieldKey, value);
261
+ this.bumpRecord(key, fieldKey);
262
+ this.evict();
263
+ this.bump();
264
+ }
265
+
266
+ /** Merge a flat record of fields into the entity/path record. */
267
+ merge(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
268
+ const key = this.recordKey(ref);
269
+ const rec = this.ensureRecord(key);
270
+ for (const [k, v] of Object.entries(fields)) {
271
+ rec.set(k, v);
272
+ this.bumpField(key, k);
273
+ }
274
+ this.bumpRecord(key);
275
+ this.evict();
276
+ this.bump();
277
+ }
278
+
279
+ /** Drop a whole record (mutation invalidation). */
280
+ invalidate(ref: GraphRef): void {
281
+ const key = this.recordKey(ref);
282
+ // Bump every field a reader might have tracked before dropping it, so field-level
283
+ // trackers re-render (the data is gone → the next read re-fetches).
284
+ for (const fieldKey of this.records.get(key)?.keys() ?? []) this.bumpField(key, fieldKey);
285
+ this.records.delete(key);
286
+ this.bumpRecord(key);
287
+ this.bump();
288
+ }
289
+
290
+ /** Drop a single field so the next read re-fetches it. */
291
+ invalidateField(ref: GraphRef, fieldKey: string): void {
292
+ const key = this.recordKey(ref);
293
+ this.records.get(key)?.delete(fieldKey);
294
+ this.bumpRecord(key, fieldKey);
295
+ this.bump();
296
+ }
297
+
298
+ /**
299
+ * Fold a serialized snapshot into THIS cache, field-by-field, WITHOUT replacing
300
+ * existing records and WITHOUT notifying. Returns whether anything was
301
+ * added/changed. The caller decides when to `notify()` — so a render-phase merge
302
+ * can write records (visible to synchronous reads) yet defer the subscriber bump
303
+ * to a commit-phase effect. Idempotent: re-absorbing the same snapshot is a no-op.
304
+ */
305
+ absorbRecords(snapshot: Record<string, Record<string, FieldValue>>): boolean {
306
+ let changed = false;
307
+ for (const [key, fields] of Object.entries(snapshot)) {
308
+ const rec = this.ensureRecord(key);
309
+ let recordChanged = false;
310
+ for (const [k, v] of Object.entries(fields)) {
311
+ if (!rec.has(k) || rec.get(k) !== v) {
312
+ rec.set(k, v);
313
+ this.bumpField(key, k);
314
+ recordChanged = true;
315
+ }
316
+ }
317
+ if (recordChanged) {
318
+ this.bumpRecord(key); // version bumps now; the caller decides when to notify()
319
+ changed = true;
320
+ }
321
+ }
322
+ this.evict();
323
+ return changed;
324
+ }
325
+
326
+ /** Serialize the whole cache (for hydration). */
327
+ snapshot(): Record<string, Record<string, FieldValue>> {
328
+ const out: Record<string, Record<string, FieldValue>> = {};
329
+ for (const [key, rec] of this.records) out[key] = Object.fromEntries(rec);
330
+ return out;
331
+ }
332
+
333
+ static fromSnapshot(snapshot: Record<string, Record<string, FieldValue>>, maxRecords?: number): GraphCache {
334
+ const cache = new GraphCache(maxRecords);
335
+ for (const [key, rec] of Object.entries(snapshot)) {
336
+ cache.records.set(key, new Map(Object.entries(rec)));
337
+ }
338
+ cache.evict();
339
+ return cache;
340
+ }
341
+ }
package/src/context.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { GraphRequestContext } from "./adapter.js";
2
+
3
+ /**
4
+ * Structural RWSDK types.
5
+ *
6
+ * This package never imports `rwsdk` — like `@gleanql/vite`, it is decoupled from
7
+ * the host framework and matches its shapes structurally, so it can be tested in
8
+ * isolation and won't pin a framework version. A RedwoodSDK route handler / Page
9
+ * receives a `RequestInfo`:
10
+ * route("/product/:handle", ({ request, params, ctx }) => <ProductRoute ... />)
11
+ */
12
+ export interface RequestInfo<Ctx extends Record<string, unknown> = Record<string, unknown>> {
13
+ readonly request: Request;
14
+ /** Dynamic route segments, e.g. `params.handle`, `params.$0`. */
15
+ readonly params: Record<string, string>;
16
+ /** Per-request mutable app context populated by middleware. */
17
+ readonly ctx: Ctx;
18
+ /** RedwoodSDK-specific context (opaque here). */
19
+ readonly rw?: unknown;
20
+ /** Cloudflare ExecutionContext. */
21
+ readonly cf?: unknown;
22
+ /** Mutable ResponseInit (status/headers). */
23
+ readonly response?: ResponseInit;
24
+ }
25
+
26
+ /**
27
+ * The route context object handed to the compiled variables factory *and* used
28
+ * as the transport `GraphRequestContext`. The compiler emits factories that read
29
+ * `ctx.params.handle`, `ctx.search.get(...)`, etc., so this shape is the contract
30
+ * between the generated code and the adapter.
31
+ */
32
+ export interface GraphRouteContext extends GraphRequestContext {
33
+ readonly params: Record<string, string>;
34
+ readonly search: URLSearchParams;
35
+ readonly request: Request;
36
+ /** Application context contributed by `options.context` (auth, locale, env, ...). */
37
+ readonly [key: string]: unknown;
38
+ }
39
+
40
+ export interface BuildRouteContextOptions<Ctx extends Record<string, unknown>> {
41
+ /**
42
+ * Contribute application context (shop domain, access token, locale, market,
43
+ * preview mode, Cloudflare env). Anything returned here is available to the
44
+ * variables factory and to the transport adapter's header builder.
45
+ */
46
+ readonly context?: (requestInfo: RequestInfo<Ctx>) => Record<string, unknown>;
47
+ }
48
+
49
+ /**
50
+ * Build the route/request context from a RequestInfo. `params` and `search` come
51
+ * from the URL; everything else is contributed by `options.context`. The raw
52
+ * `request` is included for header derivation but is *not* serialized to the
53
+ * client (see `serializeGraph`).
54
+ */
55
+ export function buildRouteContext<Ctx extends Record<string, unknown>>(
56
+ requestInfo: RequestInfo<Ctx>,
57
+ options: BuildRouteContextOptions<Ctx> = {},
58
+ ): GraphRouteContext {
59
+ const url = new URL(requestInfo.request.url);
60
+ const app = options.context?.(requestInfo) ?? {};
61
+ return {
62
+ ...app,
63
+ params: requestInfo.params,
64
+ search: url.searchParams,
65
+ request: requestInfo.request,
66
+ };
67
+ }