@rindle/react 0.1.3 → 0.2.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/README.md +28 -7
- package/dist/index.d.ts +50 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +443 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +634 -18
package/src/index.ts
CHANGED
|
@@ -8,16 +8,53 @@ import {
|
|
|
8
8
|
useSyncExternalStore,
|
|
9
9
|
} from "react";
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createLocalFragmentRefForTable,
|
|
13
|
+
fragmentAst,
|
|
14
|
+
isFragment,
|
|
15
|
+
isFragmentRelationship,
|
|
16
|
+
localQueryReadAst,
|
|
17
|
+
localFragmentReadAst,
|
|
18
|
+
localRootFragmentRefsAst,
|
|
19
|
+
queryFromAst,
|
|
20
|
+
stableKey,
|
|
21
|
+
tableMeta,
|
|
22
|
+
} from "@rindle/client";
|
|
23
|
+
import type {
|
|
24
|
+
AnyQuery,
|
|
25
|
+
Ast,
|
|
26
|
+
CachedQueryView,
|
|
27
|
+
ColsMap,
|
|
28
|
+
Fragment,
|
|
29
|
+
FragmentData,
|
|
30
|
+
FragmentCoverage,
|
|
31
|
+
FragmentRef,
|
|
32
|
+
LitValue,
|
|
33
|
+
LocalFragmentRef,
|
|
34
|
+
NamedQuery,
|
|
35
|
+
QueryLocalData,
|
|
36
|
+
ResultType,
|
|
37
|
+
Store,
|
|
38
|
+
} from "@rindle/client";
|
|
13
39
|
|
|
14
|
-
type AnyQuery = Query<any, any, any>;
|
|
15
40
|
type AnyView = ReturnType<AnyQuery["materialize"]>;
|
|
41
|
+
type AnyFragment = Fragment<any, any, any, any>;
|
|
42
|
+
type AnyNamedQuery = NamedQuery<any, readonly unknown[], AnyQuery>;
|
|
16
43
|
|
|
17
44
|
export type QueryData<Q extends AnyQuery> = ReturnType<Q["materialize"]>["data"];
|
|
45
|
+
export type RootData<Q extends AnyQuery> = QueryLocalData<Q>;
|
|
46
|
+
export type RootRefData<Q extends AnyQuery, F extends Fragment<any, any, any, any>> =
|
|
47
|
+
QueryData<Q> extends readonly unknown[] ? readonly FragmentRef<F>[] : FragmentRef<F> | null;
|
|
48
|
+
export interface RootDetails {
|
|
49
|
+
readonly status: ResultType;
|
|
50
|
+
}
|
|
51
|
+
export type RootResult<Q extends AnyQuery> = readonly [data: RootData<Q>, details: RootDetails];
|
|
52
|
+
export type RootRefResult<Q extends AnyQuery, F extends Fragment<any, any, any, any>> =
|
|
53
|
+
readonly [data: RootRefData<Q, F>, details: RootDetails];
|
|
18
54
|
|
|
19
55
|
export type { ResultType } from "@rindle/client";
|
|
20
|
-
export type { Fragment, FragmentRef } from "@rindle/client";
|
|
56
|
+
export type { Fragment, FragmentData, FragmentRef } from "@rindle/client";
|
|
57
|
+
export { fragmentKey } from "@rindle/client";
|
|
21
58
|
|
|
22
59
|
export interface RindleProps<S extends ColsMap = ColsMap> {
|
|
23
60
|
store: Store<S>;
|
|
@@ -35,6 +72,11 @@ interface QueryLease {
|
|
|
35
72
|
viewKey: string;
|
|
36
73
|
}
|
|
37
74
|
|
|
75
|
+
interface SyncLease {
|
|
76
|
+
id: number;
|
|
77
|
+
coverageKey: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
interface MaterializedLease extends QueryLease {
|
|
39
81
|
view: AnyView;
|
|
40
82
|
remote: boolean;
|
|
@@ -71,10 +113,12 @@ const EMPTY_ARRAY: readonly never[] = Object.freeze([]);
|
|
|
71
113
|
class RindleContextValue {
|
|
72
114
|
readonly store: Store<ColsMap>;
|
|
73
115
|
readonly cache: QueryCache;
|
|
116
|
+
readonly syncCache: SyncQueryCache;
|
|
74
117
|
|
|
75
118
|
constructor(store: Store<ColsMap>) {
|
|
76
119
|
this.store = store;
|
|
77
120
|
this.cache = new QueryCache(store);
|
|
121
|
+
this.syncCache = new SyncQueryCache(store);
|
|
78
122
|
}
|
|
79
123
|
}
|
|
80
124
|
|
|
@@ -160,24 +204,596 @@ export function useQueryStatus(query: AnyQuery): ResultType {
|
|
|
160
204
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
161
205
|
}
|
|
162
206
|
|
|
207
|
+
/** Retain a named server query for normalized/local-first sync coverage without subscribing React
|
|
208
|
+
* to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`
|
|
209
|
+
* until the backend reports that the retained coverage has hydrated. */
|
|
210
|
+
export function useSyncQuery(query: AnyQuery): ResultType {
|
|
211
|
+
const ctx = useRindleContext();
|
|
212
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
213
|
+
const queryRef = useRef(query);
|
|
214
|
+
queryRef.current = query;
|
|
215
|
+
|
|
216
|
+
const subscribe = useCallback(
|
|
217
|
+
(onStoreChange: () => void) => {
|
|
218
|
+
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current);
|
|
219
|
+
const unsubscribe = ctx.syncCache.subscribe(descriptor.leaseKey, onStoreChange);
|
|
220
|
+
return () => {
|
|
221
|
+
unsubscribe();
|
|
222
|
+
ctx.syncCache.release(lease);
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
[ctx.syncCache, descriptor.leaseKey],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const getSnapshot = useCallback(() => {
|
|
229
|
+
const live = ctx.syncCache.resultType(descriptor.leaseKey);
|
|
230
|
+
return live === "unknown" && ctx.cache.serverResultType(descriptor.viewKey) === "complete" ? "complete" : live;
|
|
231
|
+
}, [ctx.cache, ctx.syncCache, descriptor.leaseKey, descriptor.viewKey]);
|
|
232
|
+
|
|
233
|
+
const getServerSnapshot = useCallback(
|
|
234
|
+
() => ctx.cache.serverResultType(descriptor.viewKey),
|
|
235
|
+
[ctx.cache, descriptor.viewKey],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Run a named root query and expose its local React-facing data. Fragment child relationships are
|
|
242
|
+
* refs, so child components can keep owning their own local reads. Passing a root fragment as the
|
|
243
|
+
* final argument switches the result to opaque root refs for that fragment. */
|
|
244
|
+
export function useRoot<Q extends AnyQuery>(query: Q): RootResult<Q>;
|
|
245
|
+
export function useRoot<Q extends AnyQuery, F extends AnyFragment>(
|
|
246
|
+
query: Q,
|
|
247
|
+
fragment: F,
|
|
248
|
+
): RootRefResult<Q, F>;
|
|
249
|
+
export function useRoot<Q extends AnyQuery>(
|
|
250
|
+
query: NamedQuery<void, [], Q>,
|
|
251
|
+
): RootResult<Q>;
|
|
252
|
+
export function useRoot<Q extends AnyQuery, F extends AnyFragment>(
|
|
253
|
+
query: NamedQuery<void, [], Q>,
|
|
254
|
+
fragment: F,
|
|
255
|
+
): RootRefResult<Q, F>;
|
|
256
|
+
export function useRoot<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(
|
|
257
|
+
query: NamedQuery<Args, Ctx, Q>,
|
|
258
|
+
args: Args,
|
|
259
|
+
...ctx: Ctx
|
|
260
|
+
): RootResult<Q>;
|
|
261
|
+
export function useRoot<Args, Ctx extends readonly unknown[], Q extends AnyQuery, F extends AnyFragment>(
|
|
262
|
+
query: NamedQuery<Args, Ctx, Q>,
|
|
263
|
+
args: Args,
|
|
264
|
+
...ctxAndFragment: [...ctx: Ctx, fragment: F]
|
|
265
|
+
): RootRefResult<Q, F>;
|
|
266
|
+
export function useRoot<Q extends AnyQuery, F extends AnyFragment>(
|
|
267
|
+
queryOrNamed: Q | AnyNamedQuery,
|
|
268
|
+
...args: unknown[]
|
|
269
|
+
): RootResult<Q> | RootRefResult<Q, F> {
|
|
270
|
+
const { query, fragment } = resolveRootQueryInput(queryOrNamed, args, "useRoot");
|
|
271
|
+
const stableQuery = useStableQuery(query as Q);
|
|
272
|
+
const status = useSyncQuery(stableQuery);
|
|
273
|
+
const data = fragment === undefined
|
|
274
|
+
? useLocalRootQueryData(stableQuery)
|
|
275
|
+
: useRootRefData(stableQuery, fragment as F);
|
|
276
|
+
const details = useMemo<RootDetails>(() => ({ status }), [status]);
|
|
277
|
+
return useMemo(
|
|
278
|
+
() => [data, details] as const,
|
|
279
|
+
[data, details],
|
|
280
|
+
) as RootResult<Q> | RootRefResult<Q, F>;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveRootQueryInput(
|
|
284
|
+
queryOrNamed: AnyQuery | AnyNamedQuery,
|
|
285
|
+
args: readonly unknown[],
|
|
286
|
+
hookName: string,
|
|
287
|
+
): { query: AnyQuery; fragment?: AnyFragment } {
|
|
288
|
+
const last = args[args.length - 1];
|
|
289
|
+
const fragment = isFragment(last) ? last as AnyFragment : undefined;
|
|
290
|
+
const queryArgs = fragment === undefined ? args : args.slice(0, -1);
|
|
291
|
+
if (isNamedQuery(queryOrNamed)) {
|
|
292
|
+
const query = queryArgs.length === 0
|
|
293
|
+
? (queryOrNamed as unknown as () => AnyQuery)()
|
|
294
|
+
: queryOrNamed(queryArgs[0], ...queryArgs.slice(1));
|
|
295
|
+
return { query, fragment };
|
|
296
|
+
}
|
|
297
|
+
if (queryArgs.length !== 0) throw new Error(`${hookName}(): expected (query) or (query, fragment).`);
|
|
298
|
+
return { query: queryOrNamed, fragment };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isNamedQuery(v: unknown): v is AnyNamedQuery {
|
|
302
|
+
return typeof v === "function"
|
|
303
|
+
&& typeof (v as Partial<AnyNamedQuery>).queryName === "string"
|
|
304
|
+
&& typeof (v as Partial<AnyNamedQuery>).resolve === "function";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function useStableQuery<Q extends AnyQuery>(query: Q): Q {
|
|
308
|
+
const descriptor = describeQuery(query);
|
|
309
|
+
const ref = useRef<{ leaseKey: string; query: Q } | undefined>(undefined);
|
|
310
|
+
if (ref.current === undefined || ref.current.leaseKey !== descriptor.leaseKey) {
|
|
311
|
+
ref.current = { leaseKey: descriptor.leaseKey, query };
|
|
312
|
+
}
|
|
313
|
+
return ref.current.query;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function useLocalRootQueryData<Q extends AnyQuery>(
|
|
317
|
+
query: Q,
|
|
318
|
+
): RootData<Q> {
|
|
319
|
+
const ctx = useRindleContext();
|
|
320
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
321
|
+
const ast = useMemo(
|
|
322
|
+
() => localQueryReadAst(query, (table) => ctx.store.primaryKeyFor(table)),
|
|
323
|
+
[ctx.store, query],
|
|
324
|
+
);
|
|
325
|
+
const localQuery = useMemo(() => queryFromAst(ast), [ast]);
|
|
326
|
+
const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);
|
|
327
|
+
const projection = useMemo(
|
|
328
|
+
() => new LocalFragmentProjection(ast, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)),
|
|
329
|
+
[ctx.store, ast, descriptor.leaseKey, query],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const subscribe = useCallback(
|
|
333
|
+
(onStoreChange: () => void) => {
|
|
334
|
+
const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);
|
|
335
|
+
const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);
|
|
336
|
+
return () => {
|
|
337
|
+
unsubscribeLocal();
|
|
338
|
+
ctx.cache.release(localLease);
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
[ctx.cache, localDescriptor.viewKey, localQuery],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Stale-while-revalidate: render whatever the local IVM view already holds (synced rows,
|
|
345
|
+
// optimistic writes, partially-covered rows) rather than blanking to empty until the server
|
|
346
|
+
// confirms coverage. `status` (from useSyncQuery) stays a SEPARATE signal callers can gate on;
|
|
347
|
+
// the data itself never waits on the round-trip, so navigating to a view that's locally warm
|
|
348
|
+
// shows rows immediately instead of flashing a loading state. (Eviction-on-release still drops
|
|
349
|
+
// a cold view; a release TTL to keep views warm across nav is future work.)
|
|
350
|
+
const getSnapshot = useCallback(() => {
|
|
351
|
+
if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {
|
|
352
|
+
return projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
353
|
+
}
|
|
354
|
+
return projectLocalRootSnapshot(ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
355
|
+
}, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);
|
|
356
|
+
|
|
357
|
+
const getServerSnapshot = useCallback(
|
|
358
|
+
() => projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection),
|
|
359
|
+
[ctx.cache, descriptor.one, descriptor.viewKey, projection],
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) as RootData<Q>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function useRootRefData<Q extends AnyQuery, F extends Fragment<any, any, any, any>>(
|
|
366
|
+
query: Q,
|
|
367
|
+
fragment: F,
|
|
368
|
+
): RootRefData<Q, F> {
|
|
369
|
+
const ctx = useRindleContext();
|
|
370
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
371
|
+
const ast = useMemo(
|
|
372
|
+
() => localRootFragmentRefsAst(fragment, query, (table) => ctx.store.primaryKeyFor(table)),
|
|
373
|
+
[ctx.store, fragment, query],
|
|
374
|
+
);
|
|
375
|
+
const localQuery = useMemo(() => queryFromAst(ast), [ast]);
|
|
376
|
+
const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);
|
|
377
|
+
const projection = useMemo(
|
|
378
|
+
() => new RootRefProjection(fragment, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)),
|
|
379
|
+
[ctx.store, descriptor.leaseKey, fragment, query],
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const subscribe = useCallback(
|
|
383
|
+
(onStoreChange: () => void) => {
|
|
384
|
+
const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);
|
|
385
|
+
const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);
|
|
386
|
+
return () => {
|
|
387
|
+
unsubscribeLocal();
|
|
388
|
+
ctx.cache.release(localLease);
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
[ctx.cache, localDescriptor.viewKey, localQuery],
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Stale-while-revalidate (see useLocalRootQueryData): the root rows render from the live local
|
|
395
|
+
// view as soon as it holds anything; `status` is the separate axis for "server-authoritative yet".
|
|
396
|
+
const getSnapshot = useCallback(() => {
|
|
397
|
+
if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {
|
|
398
|
+
return projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
399
|
+
}
|
|
400
|
+
const raw = ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one);
|
|
401
|
+
return descriptor.one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
402
|
+
}, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);
|
|
403
|
+
|
|
404
|
+
const getServerSnapshot = useCallback(
|
|
405
|
+
() => projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection),
|
|
406
|
+
[ctx.cache, descriptor.one, descriptor.viewKey, projection],
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) as RootRefData<Q, F>;
|
|
410
|
+
}
|
|
411
|
+
|
|
163
412
|
/**
|
|
164
|
-
* Read a {@link Fragment}'s
|
|
413
|
+
* Read a {@link Fragment}'s local data from an opaque ref.
|
|
165
414
|
*
|
|
166
|
-
* The
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* a descendant that opens its own `useQuery` reintroduces the waterfall at that boundary.
|
|
415
|
+
* The query boundary calls {@link useRoot} with a fragment argument to retain the full named
|
|
416
|
+
* coverage query and receive root refs. Descendants call `useFragment` with those refs (or child
|
|
417
|
+
* refs returned by a parent fragment read) to open narrow local-only reads for the fields their
|
|
418
|
+
* fragment owns.
|
|
171
419
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* the seam masking plugs into); masking (Phase 2) narrows the returned shape with no call-site
|
|
176
|
-
* change. Not a subscription — it adds no lease and never re-renders on its own.
|
|
420
|
+
* `ref` is an opaque token created by {@link useRoot} or returned from another local fragment read.
|
|
421
|
+
* The hook opens a narrow local-only query for this exact fragment and keeps the root coverage
|
|
422
|
+
* lease retained while mounted. Passing a legacy projected data object is unsupported.
|
|
177
423
|
*/
|
|
178
|
-
export function useFragment<F extends Fragment<any, any, any>>(
|
|
179
|
-
|
|
180
|
-
|
|
424
|
+
export function useFragment<F extends Fragment<any, any, any, any>>(
|
|
425
|
+
fragment: F,
|
|
426
|
+
ref: FragmentRef<F> | null | undefined,
|
|
427
|
+
): FragmentData<F> | null {
|
|
428
|
+
return useLocalFragment(fragment, ref);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function useLocalFragment<F extends Fragment<any, any, any, any>>(
|
|
432
|
+
fragment: F,
|
|
433
|
+
ref: LocalFragmentRef<F> | null | undefined,
|
|
434
|
+
): FragmentData<F> | null {
|
|
435
|
+
const ctx = useRindleContext();
|
|
436
|
+
const coverage = ref?.coverage;
|
|
437
|
+
const coverageDescriptor = useMemo(() => (coverage ? describeQuery(coverage.query) : undefined), [coverage]);
|
|
438
|
+
const ast = useMemo(
|
|
439
|
+
() => (ref ? localFragmentReadAst(fragment, ref, (table) => ctx.store.primaryKeyFor(table)) : undefined),
|
|
440
|
+
[ctx.store, fragment, ref],
|
|
441
|
+
);
|
|
442
|
+
const query = useMemo(() => (ast ? queryFromAst(ast) : undefined), [ast]);
|
|
443
|
+
const descriptor = useMemo(() => (query ? describeQuery(query) : undefined), [query]);
|
|
444
|
+
const projection = useMemo(
|
|
445
|
+
() => (coverage ? new LocalFragmentProjection(fragmentAst(fragment), coverage, (table) => ctx.store.primaryKeyFor(table)) : undefined),
|
|
446
|
+
[ctx.store, coverage, fragment],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const subscribe = useCallback(
|
|
450
|
+
(onStoreChange: () => void) => {
|
|
451
|
+
if (!coverage || !descriptor || !query) return () => {};
|
|
452
|
+
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query);
|
|
453
|
+
const localLease = ctx.cache.retain(descriptor.viewKey, query);
|
|
454
|
+
const unsubscribeSync = ctx.syncCache.subscribe(coverage.key, onStoreChange);
|
|
455
|
+
const unsubscribeLocal = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
456
|
+
return () => {
|
|
457
|
+
unsubscribeLocal();
|
|
458
|
+
unsubscribeSync();
|
|
459
|
+
ctx.cache.release(localLease);
|
|
460
|
+
ctx.syncCache.release(syncLease);
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
[ctx.cache, ctx.syncCache, coverage, descriptor, query],
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// Stale-while-revalidate (see useLocalRootQueryData): project the fragment's local view as soon
|
|
467
|
+
// as the row exists locally instead of returning null until its coverage is server-complete —
|
|
468
|
+
// otherwise every nested fragment (UserBadge, TagChip, CommentCard, …) flashes empty for a
|
|
469
|
+
// round-trip on each navigation. A field not yet synced simply reads absent, not "loading".
|
|
470
|
+
const getSnapshot = useCallback(() => {
|
|
471
|
+
if (!coverage || !descriptor || !projection) return null;
|
|
472
|
+
if (coverageDescriptor && ctx.cache.serverResultType(coverageDescriptor.viewKey) === "complete") {
|
|
473
|
+
return projectFragmentSeed(ctx, coverage.query.ast(), coverageDescriptor, ref, projection);
|
|
474
|
+
}
|
|
475
|
+
const data = projection.project(ctx.cache.snapshot(descriptor.viewKey, true));
|
|
476
|
+
return data as FragmentData<F> | null;
|
|
477
|
+
}, [ctx, coverage, coverageDescriptor, descriptor, projection, ref]);
|
|
478
|
+
|
|
479
|
+
const getServerSnapshot = useCallback(
|
|
480
|
+
() => projectFragmentSeed(ctx, coverage?.query.ast(), coverageDescriptor, ref, projection),
|
|
481
|
+
[ctx, coverage, coverageDescriptor, projection, ref],
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function projectRootRefSnapshot(raw: unknown, one: boolean, projection: RootRefProjection): unknown {
|
|
488
|
+
return one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function projectLocalRootSnapshot(raw: unknown, one: boolean, projection: LocalFragmentProjection): unknown {
|
|
492
|
+
return one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function projectFragmentSeed<F extends Fragment<any, any, any, any>>(
|
|
496
|
+
ctx: RindleContextValue,
|
|
497
|
+
coverageAst: Ast | undefined,
|
|
498
|
+
coverageDescriptor: QueryDescriptor | undefined,
|
|
499
|
+
ref: LocalFragmentRef<F> | null | undefined,
|
|
500
|
+
projection: LocalFragmentProjection | undefined,
|
|
501
|
+
): FragmentData<F> | null {
|
|
502
|
+
if (!coverageAst || !coverageDescriptor || !ref || !projection) return null;
|
|
503
|
+
const raw = ctx.cache.serverSnapshot(coverageDescriptor.viewKey, coverageDescriptor.one);
|
|
504
|
+
const row = findFragmentSeedRow(coverageAst, raw, ref.table, ref.pk, (table) => ctx.store.primaryKeyFor(table));
|
|
505
|
+
return projection.project(row) as FragmentData<F> | null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function findFragmentSeedRow(
|
|
509
|
+
ast: Ast,
|
|
510
|
+
raw: unknown,
|
|
511
|
+
table: string,
|
|
512
|
+
pk: Readonly<Record<string, LitValue>>,
|
|
513
|
+
primaryKeyFor: (table: string) => readonly string[],
|
|
514
|
+
): unknown {
|
|
515
|
+
if (Array.isArray(raw)) {
|
|
516
|
+
for (const row of raw) {
|
|
517
|
+
const found = findFragmentSeedRowInObject(ast, row, table, pk, primaryKeyFor);
|
|
518
|
+
if (found !== null) return found;
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
return findFragmentSeedRowInObject(ast, raw, table, pk, primaryKeyFor);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function findFragmentSeedRowInObject(
|
|
526
|
+
ast: Ast,
|
|
527
|
+
raw: unknown,
|
|
528
|
+
table: string,
|
|
529
|
+
pk: Readonly<Record<string, LitValue>>,
|
|
530
|
+
primaryKeyFor: (table: string) => readonly string[],
|
|
531
|
+
): unknown {
|
|
532
|
+
if (raw === null || raw === undefined || typeof raw !== "object") return null;
|
|
533
|
+
const row = raw as Record<string, unknown>;
|
|
534
|
+
if (ast.table === table && rowMatchesPk(row, pk, primaryKeyFor(table))) return row;
|
|
535
|
+
for (const rel of ast.related ?? []) {
|
|
536
|
+
if (rel.subquery.aggregate !== undefined) continue;
|
|
537
|
+
const alias = rel.subquery.alias;
|
|
538
|
+
if (alias === undefined) continue;
|
|
539
|
+
const found = findFragmentSeedRow(rel.subquery, row[alias], table, pk, primaryKeyFor);
|
|
540
|
+
if (found !== null) return found;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function rowMatchesPk(row: Record<string, unknown>, pk: Readonly<Record<string, LitValue>>, primaryKey: readonly string[]): boolean {
|
|
546
|
+
for (const col of primaryKey) {
|
|
547
|
+
if (!Object.prototype.hasOwnProperty.call(row, col)) return false;
|
|
548
|
+
if (stableKey(row[col]) !== stableKey(pk[col])) return false;
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
class LocalFragmentProjection {
|
|
554
|
+
private readonly manyCache = new WeakMap<object, readonly unknown[]>();
|
|
555
|
+
private readonly cache = new WeakMap<object, unknown>();
|
|
556
|
+
private readonly refCache = new Map<string, LocalFragmentRef>();
|
|
557
|
+
private readonly ast: Ast;
|
|
558
|
+
private readonly coverage: FragmentCoverage;
|
|
559
|
+
private readonly primaryKeyFor: (table: string) => readonly string[];
|
|
560
|
+
|
|
561
|
+
constructor(
|
|
562
|
+
ast: Ast,
|
|
563
|
+
coverage: FragmentCoverage,
|
|
564
|
+
primaryKeyFor: (table: string) => readonly string[],
|
|
565
|
+
) {
|
|
566
|
+
this.ast = ast;
|
|
567
|
+
this.coverage = coverage;
|
|
568
|
+
this.primaryKeyFor = primaryKeyFor;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
projectOne(raw: unknown): unknown {
|
|
572
|
+
return this.project(raw);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
projectMany(raw: unknown): readonly unknown[] {
|
|
576
|
+
if (!Array.isArray(raw)) return EMPTY_ARRAY;
|
|
577
|
+
const cached = this.manyCache.get(raw);
|
|
578
|
+
if (cached) return cached;
|
|
579
|
+
const out = raw.map((row) => this.project(row));
|
|
580
|
+
this.manyCache.set(raw, out);
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
project(raw: unknown): unknown {
|
|
585
|
+
if (raw === null || raw === undefined || typeof raw !== "object") return null;
|
|
586
|
+
const cached = this.cache.get(raw);
|
|
587
|
+
if (cached !== undefined) return cached;
|
|
588
|
+
|
|
589
|
+
const out = this.projectRow(this.ast, raw);
|
|
590
|
+
this.cache.set(raw, out);
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private projectRow(ast: Ast, raw: unknown): unknown {
|
|
595
|
+
if (raw === null || raw === undefined || typeof raw !== "object") return null;
|
|
596
|
+
const row = raw as Record<string, unknown>;
|
|
597
|
+
const out: Record<string, unknown> = {};
|
|
598
|
+
const relationshipAliases = new Set((ast.related ?? []).map((rel) => rel.subquery.alias).filter((a): a is string => a !== undefined));
|
|
599
|
+
if (ast.select === undefined) {
|
|
600
|
+
for (const [key, value] of Object.entries(row)) {
|
|
601
|
+
if (!relationshipAliases.has(key)) out[key] = value;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
for (const col of ast.select) {
|
|
605
|
+
if (Object.prototype.hasOwnProperty.call(row, col)) out[col] = row[col];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for (const rel of ast.related ?? []) {
|
|
610
|
+
const alias = rel.subquery.alias;
|
|
611
|
+
if (alias === undefined) continue;
|
|
612
|
+
const value = row[alias];
|
|
613
|
+
if (rel.subquery.aggregate !== undefined) {
|
|
614
|
+
out[alias] = value;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
out[alias] = isFragmentRelationship(rel) ? this.projectRelatedRefs(rel.subquery, value) : this.projectInline(rel.subquery, value);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private projectInline(childAst: Ast, value: unknown): unknown {
|
|
624
|
+
if (Array.isArray(value)) return value.map((row) => this.projectRow(childAst, row));
|
|
625
|
+
if (value === null || value === undefined) return value ?? null;
|
|
626
|
+
return this.projectRow(childAst, value);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private projectRelatedRefs(childAst: Ast, value: unknown): unknown {
|
|
630
|
+
if (Array.isArray(value)) return value.map((row) => this.refForRow(childAst.table, row));
|
|
631
|
+
if (value === null || value === undefined) return value ?? null;
|
|
632
|
+
return this.refForRow(childAst.table, value);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private refForRow(table: string, value: unknown): LocalFragmentRef {
|
|
636
|
+
if (value === null || typeof value !== "object") {
|
|
637
|
+
throw new Error(`useFragment(): cannot build a local fragment ref for "${table}" from a non-object row.`);
|
|
638
|
+
}
|
|
639
|
+
const row = value as Record<string, unknown>;
|
|
640
|
+
const pk: Record<string, LitValue> = {};
|
|
641
|
+
for (const col of this.primaryKeyFor(table)) {
|
|
642
|
+
if (!Object.prototype.hasOwnProperty.call(row, col)) {
|
|
643
|
+
throw new Error(`useFragment(): local relationship row for "${table}" is missing primary key column "${col}".`);
|
|
644
|
+
}
|
|
645
|
+
pk[col] = row[col] as LitValue;
|
|
646
|
+
}
|
|
647
|
+
const key = stableKey({ table, pk });
|
|
648
|
+
const cached = this.refCache.get(key);
|
|
649
|
+
if (cached) return cached;
|
|
650
|
+
const ref = createLocalFragmentRefForTable(table, pk, this.coverage);
|
|
651
|
+
this.refCache.set(key, ref);
|
|
652
|
+
return ref;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
class RootRefProjection {
|
|
657
|
+
private readonly cache = new WeakMap<object, readonly LocalFragmentRef[]>();
|
|
658
|
+
private readonly rowCache = new WeakMap<object, LocalFragmentRef>();
|
|
659
|
+
private readonly keyCache = new Map<string, LocalFragmentRef>();
|
|
660
|
+
private readonly table: string;
|
|
661
|
+
private readonly coverage: FragmentCoverage;
|
|
662
|
+
private readonly primaryKeyFor: (table: string) => readonly string[];
|
|
663
|
+
|
|
664
|
+
constructor(
|
|
665
|
+
fragment: Fragment<any, any, any>,
|
|
666
|
+
coverage: FragmentCoverage,
|
|
667
|
+
primaryKeyFor: (table: string) => readonly string[],
|
|
668
|
+
) {
|
|
669
|
+
this.table = tableMeta(fragment.table).name;
|
|
670
|
+
this.coverage = coverage;
|
|
671
|
+
this.primaryKeyFor = primaryKeyFor;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
projectOne(raw: unknown): LocalFragmentRef | null {
|
|
675
|
+
if (raw === null || raw === undefined) return null;
|
|
676
|
+
return this.refForRow(raw);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
projectMany(raw: unknown): readonly LocalFragmentRef[] {
|
|
680
|
+
if (!Array.isArray(raw)) return EMPTY_ARRAY as readonly LocalFragmentRef[];
|
|
681
|
+
const cached = this.cache.get(raw);
|
|
682
|
+
if (cached) return cached;
|
|
683
|
+
const refs = raw.map((row) => this.refForRow(row));
|
|
684
|
+
this.cache.set(raw, refs);
|
|
685
|
+
return refs;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private refForRow(value: unknown): LocalFragmentRef {
|
|
689
|
+
if (value === null || typeof value !== "object") {
|
|
690
|
+
throw new Error(`useRoot(): cannot build a fragment ref for "${this.table}" from a non-object row.`);
|
|
691
|
+
}
|
|
692
|
+
const cached = this.rowCache.get(value);
|
|
693
|
+
if (cached) return cached;
|
|
694
|
+
const row = value as Record<string, unknown>;
|
|
695
|
+
const pk: Record<string, LitValue> = {};
|
|
696
|
+
for (const col of this.primaryKeyFor(this.table)) {
|
|
697
|
+
if (!Object.prototype.hasOwnProperty.call(row, col)) {
|
|
698
|
+
throw new Error(`useRoot(): local root row for "${this.table}" is missing primary key column "${col}".`);
|
|
699
|
+
}
|
|
700
|
+
pk[col] = row[col] as LitValue;
|
|
701
|
+
}
|
|
702
|
+
const key = stableKey({ table: this.table, pk });
|
|
703
|
+
const existing = this.keyCache.get(key);
|
|
704
|
+
if (existing) {
|
|
705
|
+
this.rowCache.set(value, existing);
|
|
706
|
+
return existing;
|
|
707
|
+
}
|
|
708
|
+
const ref = createLocalFragmentRefForTable(this.table, pk, this.coverage);
|
|
709
|
+
this.keyCache.set(key, ref);
|
|
710
|
+
this.rowCache.set(value, ref);
|
|
711
|
+
return ref;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface SyncCacheEntry {
|
|
716
|
+
handle: SyncQueryHandle;
|
|
717
|
+
leases: SyncLease[];
|
|
718
|
+
listeners: Set<() => void>;
|
|
719
|
+
unsubscribe: () => void;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
interface SyncQueryHandle {
|
|
723
|
+
readonly resultType: ResultType;
|
|
724
|
+
subscribe(listener: () => void): () => void;
|
|
725
|
+
release(): void;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export class SyncQueryCache {
|
|
729
|
+
private readonly entries = new Map<string, SyncCacheEntry>();
|
|
730
|
+
private nextLeaseId = 1;
|
|
731
|
+
private readonly store: Store<ColsMap>;
|
|
732
|
+
|
|
733
|
+
constructor(store: Store<ColsMap>) {
|
|
734
|
+
this.store = store;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
retain(coverageKey: string, query: AnyQuery): SyncLease {
|
|
738
|
+
let entry = this.entries.get(coverageKey);
|
|
739
|
+
if (!entry) {
|
|
740
|
+
const handle = this.createHandle(query);
|
|
741
|
+
entry = {
|
|
742
|
+
handle,
|
|
743
|
+
leases: [],
|
|
744
|
+
listeners: new Set(),
|
|
745
|
+
unsubscribe: () => {},
|
|
746
|
+
};
|
|
747
|
+
entry.unsubscribe = handle.subscribe(() => {
|
|
748
|
+
for (const listener of entry!.listeners) listener();
|
|
749
|
+
});
|
|
750
|
+
this.entries.set(coverageKey, entry);
|
|
751
|
+
}
|
|
752
|
+
const lease = { id: this.nextLeaseId++, coverageKey };
|
|
753
|
+
entry.leases.push(lease);
|
|
754
|
+
return lease;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
release(lease: SyncLease): void {
|
|
758
|
+
const entry = this.entries.get(lease.coverageKey);
|
|
759
|
+
if (!entry) return;
|
|
760
|
+
const index = entry.leases.findIndex((l) => l.id === lease.id);
|
|
761
|
+
if (index < 0) return;
|
|
762
|
+
entry.leases.splice(index, 1);
|
|
763
|
+
if (entry.leases.length > 0) return;
|
|
764
|
+
entry.unsubscribe();
|
|
765
|
+
entry.handle.release();
|
|
766
|
+
this.entries.delete(lease.coverageKey);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
subscribe(coverageKey: string, listener: () => void): () => void {
|
|
770
|
+
const entry = this.entries.get(coverageKey);
|
|
771
|
+
if (!entry) return () => {};
|
|
772
|
+
entry.listeners.add(listener);
|
|
773
|
+
return () => {
|
|
774
|
+
entry.listeners.delete(listener);
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
resultType(coverageKey: string): ResultType {
|
|
779
|
+
return this.entries.get(coverageKey)?.handle.resultType ?? "unknown";
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
size(): number {
|
|
783
|
+
return this.entries.size;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private createHandle(query: AnyQuery): SyncQueryHandle {
|
|
787
|
+
if (this.store.canRetainRemoteQueries()) return this.store.retainSyncQuery(query);
|
|
788
|
+
const view = this.store.materialize(query) as AnyView;
|
|
789
|
+
return {
|
|
790
|
+
get resultType() {
|
|
791
|
+
return view.resultType;
|
|
792
|
+
},
|
|
793
|
+
subscribe: (listener: () => void) => view.subscribe(listener),
|
|
794
|
+
release: () => view.destroy(),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
181
797
|
}
|
|
182
798
|
|
|
183
799
|
export class QueryCache {
|