@rindle/react 0.1.6 → 0.3.1
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 +72 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +519 -20
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +725 -24
package/dist/index.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { createContext, createElement, useCallback, useContext, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
2
|
-
import { stableKey } from "@rindle/client";
|
|
2
|
+
import { createLocalFragmentRefForTable, fragmentAst, isFragment, isFragmentRelationship, localQueryReadAst, localFragmentReadAst, localRootFragmentRefsAst, queryFromAst, stableKey, tableMeta, } from "@rindle/client";
|
|
3
|
+
export { fragmentKey } from "@rindle/client";
|
|
3
4
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
5
|
+
// Keep just-released coverage alive briefly so a changed filter/limit can re-materialize from the
|
|
6
|
+
// local base synchronously while the replacement server lease is still streaming its first answer.
|
|
7
|
+
const REACT_CACHE_RELEASE_DELAY_MS = 2_000;
|
|
8
|
+
function setReleaseTimeout(fn, ms) {
|
|
9
|
+
const timer = setTimeout(fn, ms);
|
|
10
|
+
timer.unref?.();
|
|
11
|
+
return timer;
|
|
12
|
+
}
|
|
4
13
|
class RindleContextValue {
|
|
5
14
|
store;
|
|
6
15
|
cache;
|
|
16
|
+
syncCache;
|
|
7
17
|
constructor(store) {
|
|
8
18
|
this.store = store;
|
|
9
|
-
this.cache = new QueryCache(store);
|
|
19
|
+
this.cache = new QueryCache(store, { releaseDelayMs: REACT_CACHE_RELEASE_DELAY_MS });
|
|
20
|
+
this.syncCache = new SyncQueryCache(store, { releaseDelayMs: REACT_CACHE_RELEASE_DELAY_MS });
|
|
10
21
|
}
|
|
11
22
|
}
|
|
12
23
|
const RindleContext = createContext(null);
|
|
@@ -62,31 +73,490 @@ export function useQueryStatus(query) {
|
|
|
62
73
|
const getServerSnapshot = useCallback(() => ctx.cache.serverResultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);
|
|
63
74
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
64
75
|
}
|
|
76
|
+
/** Retain a named server query for normalized/local-first sync coverage without subscribing React
|
|
77
|
+
* to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`
|
|
78
|
+
* until the backend reports that the retained coverage has hydrated. */
|
|
79
|
+
export function useSyncQuery(query) {
|
|
80
|
+
const ctx = useRindleContext();
|
|
81
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
82
|
+
const queryRef = useRef(query);
|
|
83
|
+
queryRef.current = query;
|
|
84
|
+
const subscribe = useCallback((onStoreChange) => {
|
|
85
|
+
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current);
|
|
86
|
+
const unsubscribe = ctx.syncCache.subscribe(descriptor.leaseKey, onStoreChange);
|
|
87
|
+
return () => {
|
|
88
|
+
unsubscribe();
|
|
89
|
+
ctx.syncCache.release(lease);
|
|
90
|
+
};
|
|
91
|
+
}, [ctx.syncCache, descriptor.leaseKey]);
|
|
92
|
+
const getSnapshot = useCallback(() => {
|
|
93
|
+
const live = ctx.syncCache.resultType(descriptor.leaseKey);
|
|
94
|
+
return live === "unknown" && ctx.cache.serverResultType(descriptor.viewKey) === "complete" ? "complete" : live;
|
|
95
|
+
}, [ctx.cache, ctx.syncCache, descriptor.leaseKey, descriptor.viewKey]);
|
|
96
|
+
const getServerSnapshot = useCallback(() => ctx.cache.serverResultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);
|
|
97
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
98
|
+
}
|
|
99
|
+
export function useRoot(queryOrNamed, ...args) {
|
|
100
|
+
const { query, fragment } = resolveRootQueryInput(queryOrNamed, args, "useRoot");
|
|
101
|
+
const stableQuery = useStableQuery(query);
|
|
102
|
+
const status = useSyncQuery(stableQuery);
|
|
103
|
+
const data = fragment === undefined
|
|
104
|
+
? useLocalRootQueryData(stableQuery)
|
|
105
|
+
: useRootRefData(stableQuery, fragment);
|
|
106
|
+
const details = useMemo(() => ({ status }), [status]);
|
|
107
|
+
return useMemo(() => [data, details], [data, details]);
|
|
108
|
+
}
|
|
109
|
+
function resolveRootQueryInput(queryOrNamed, args, hookName) {
|
|
110
|
+
const last = args[args.length - 1];
|
|
111
|
+
const fragment = isFragment(last) ? last : undefined;
|
|
112
|
+
const queryArgs = fragment === undefined ? args : args.slice(0, -1);
|
|
113
|
+
if (isNamedQuery(queryOrNamed)) {
|
|
114
|
+
const query = queryArgs.length === 0
|
|
115
|
+
? queryOrNamed()
|
|
116
|
+
: queryOrNamed(queryArgs[0], ...queryArgs.slice(1));
|
|
117
|
+
return { query, fragment };
|
|
118
|
+
}
|
|
119
|
+
if (queryArgs.length !== 0)
|
|
120
|
+
throw new Error(`${hookName}(): expected (query) or (query, fragment).`);
|
|
121
|
+
return { query: queryOrNamed, fragment };
|
|
122
|
+
}
|
|
123
|
+
function isNamedQuery(v) {
|
|
124
|
+
return typeof v === "function"
|
|
125
|
+
&& typeof v.queryName === "string"
|
|
126
|
+
&& typeof v.resolve === "function";
|
|
127
|
+
}
|
|
128
|
+
function useStableQuery(query) {
|
|
129
|
+
const descriptor = describeQuery(query);
|
|
130
|
+
const ref = useRef(undefined);
|
|
131
|
+
if (ref.current === undefined || ref.current.leaseKey !== descriptor.leaseKey) {
|
|
132
|
+
ref.current = { leaseKey: descriptor.leaseKey, query };
|
|
133
|
+
}
|
|
134
|
+
return ref.current.query;
|
|
135
|
+
}
|
|
136
|
+
function useLocalRootQueryData(query) {
|
|
137
|
+
const ctx = useRindleContext();
|
|
138
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
139
|
+
const ast = useMemo(() => localQueryReadAst(query, (table) => ctx.store.primaryKeyFor(table)), [ctx.store, query]);
|
|
140
|
+
const localQuery = useMemo(() => queryFromAst(ast), [ast]);
|
|
141
|
+
const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);
|
|
142
|
+
const projection = useMemo(() => new LocalFragmentProjection(ast, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)), [ctx.store, ast, descriptor.leaseKey, query]);
|
|
143
|
+
const subscribe = useCallback((onStoreChange) => {
|
|
144
|
+
const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);
|
|
145
|
+
const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);
|
|
146
|
+
return () => {
|
|
147
|
+
unsubscribeLocal();
|
|
148
|
+
ctx.cache.release(localLease);
|
|
149
|
+
};
|
|
150
|
+
}, [ctx.cache, localDescriptor.viewKey, localQuery]);
|
|
151
|
+
// Stale-while-revalidate: render whatever the local IVM view already holds (synced rows,
|
|
152
|
+
// optimistic writes, partially-covered rows) rather than blanking to empty until the server
|
|
153
|
+
// confirms coverage. `status` (from useSyncQuery) stays a SEPARATE signal callers can gate on;
|
|
154
|
+
// the data itself never waits on the round-trip, so navigating to a view that's locally warm
|
|
155
|
+
// shows rows immediately instead of flashing a loading state. (Eviction-on-release still drops
|
|
156
|
+
// a cold view; a release TTL to keep views warm across nav is future work.)
|
|
157
|
+
const getSnapshot = useCallback(() => {
|
|
158
|
+
if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {
|
|
159
|
+
return projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
160
|
+
}
|
|
161
|
+
return projectLocalRootSnapshot(ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
162
|
+
}, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);
|
|
163
|
+
const getServerSnapshot = useCallback(() => projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection), [ctx.cache, descriptor.one, descriptor.viewKey, projection]);
|
|
164
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
165
|
+
}
|
|
166
|
+
function useRootRefData(query, fragment) {
|
|
167
|
+
const ctx = useRindleContext();
|
|
168
|
+
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
169
|
+
const ast = useMemo(() => localRootFragmentRefsAst(fragment, query, (table) => ctx.store.primaryKeyFor(table)), [ctx.store, fragment, query]);
|
|
170
|
+
const localQuery = useMemo(() => queryFromAst(ast), [ast]);
|
|
171
|
+
const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);
|
|
172
|
+
const projection = useMemo(() => new RootRefProjection(fragment, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)), [ctx.store, descriptor.leaseKey, fragment, query]);
|
|
173
|
+
const subscribe = useCallback((onStoreChange) => {
|
|
174
|
+
const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);
|
|
175
|
+
const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);
|
|
176
|
+
return () => {
|
|
177
|
+
unsubscribeLocal();
|
|
178
|
+
ctx.cache.release(localLease);
|
|
179
|
+
};
|
|
180
|
+
}, [ctx.cache, localDescriptor.viewKey, localQuery]);
|
|
181
|
+
// Stale-while-revalidate (see useLocalRootQueryData): the root rows render from the live local
|
|
182
|
+
// view as soon as it holds anything; `status` is the separate axis for "server-authoritative yet".
|
|
183
|
+
const getSnapshot = useCallback(() => {
|
|
184
|
+
if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {
|
|
185
|
+
return projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);
|
|
186
|
+
}
|
|
187
|
+
const raw = ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one);
|
|
188
|
+
return descriptor.one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
189
|
+
}, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);
|
|
190
|
+
const getServerSnapshot = useCallback(() => projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection), [ctx.cache, descriptor.one, descriptor.viewKey, projection]);
|
|
191
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
192
|
+
}
|
|
65
193
|
/**
|
|
66
|
-
* Read a {@link Fragment}'s
|
|
194
|
+
* Read a {@link Fragment}'s local data from an opaque ref.
|
|
67
195
|
*
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* a descendant that opens its own `useQuery` reintroduces the waterfall at that boundary.
|
|
196
|
+
* The query boundary calls {@link useRoot} with a fragment argument to retain the full named
|
|
197
|
+
* coverage query and receive root refs. Descendants call `useFragment` with those refs (or child
|
|
198
|
+
* refs returned by a parent fragment read) to open narrow local-only reads for the fields their
|
|
199
|
+
* fragment owns.
|
|
73
200
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* the seam masking plugs into); masking (Phase 2) narrows the returned shape with no call-site
|
|
78
|
-
* change. Not a subscription — it adds no lease and never re-renders on its own.
|
|
201
|
+
* `ref` is an opaque token created by {@link useRoot} or returned from another local fragment read.
|
|
202
|
+
* The hook opens a narrow local-only query for this exact fragment and keeps the root coverage
|
|
203
|
+
* lease retained while mounted. Passing a legacy projected data object is unsupported.
|
|
79
204
|
*/
|
|
80
205
|
export function useFragment(fragment, ref) {
|
|
81
|
-
|
|
82
|
-
|
|
206
|
+
return useLocalFragment(fragment, ref);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Render-prop sugar over {@link useFragment}: does the `null` check once. `from` is a fragment ref
|
|
210
|
+
* (or null/undefined — an absent to-one relationship, an emptied `.one()`, or a row deleted out from
|
|
211
|
+
* under a live read); when the row is present `children(data)` renders, otherwise `fallback` (default
|
|
212
|
+
* nothing). Keeps the per-row subscription isolation — a child-only edit re-renders just this read.
|
|
213
|
+
*/
|
|
214
|
+
export function Frag({ of, from, fallback = null, children }) {
|
|
215
|
+
const data = useFragment(of, from);
|
|
216
|
+
return data == null ? fallback : children(data);
|
|
217
|
+
}
|
|
218
|
+
function useLocalFragment(fragment, ref) {
|
|
219
|
+
const ctx = useRindleContext();
|
|
220
|
+
const coverage = ref?.coverage;
|
|
221
|
+
const coverageDescriptor = useMemo(() => (coverage ? describeQuery(coverage.query) : undefined), [coverage]);
|
|
222
|
+
const ast = useMemo(() => (ref ? localFragmentReadAst(fragment, ref, (table) => ctx.store.primaryKeyFor(table)) : undefined), [ctx.store, fragment, ref]);
|
|
223
|
+
const query = useMemo(() => (ast ? queryFromAst(ast) : undefined), [ast]);
|
|
224
|
+
const descriptor = useMemo(() => (query ? describeQuery(query) : undefined), [query]);
|
|
225
|
+
const projection = useMemo(() => (coverage ? new LocalFragmentProjection(fragmentAst(fragment), coverage, (table) => ctx.store.primaryKeyFor(table)) : undefined), [ctx.store, coverage, fragment]);
|
|
226
|
+
const subscribe = useCallback((onStoreChange) => {
|
|
227
|
+
if (!coverage || !descriptor || !query)
|
|
228
|
+
return () => { };
|
|
229
|
+
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query);
|
|
230
|
+
const localLease = ctx.cache.retain(descriptor.viewKey, query);
|
|
231
|
+
const unsubscribeSync = ctx.syncCache.subscribe(coverage.key, onStoreChange);
|
|
232
|
+
const unsubscribeLocal = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
233
|
+
return () => {
|
|
234
|
+
unsubscribeLocal();
|
|
235
|
+
unsubscribeSync();
|
|
236
|
+
ctx.cache.release(localLease);
|
|
237
|
+
ctx.syncCache.release(syncLease);
|
|
238
|
+
};
|
|
239
|
+
}, [ctx.cache, ctx.syncCache, coverage, descriptor, query]);
|
|
240
|
+
// Stale-while-revalidate (see useLocalRootQueryData): project the fragment's local view as soon
|
|
241
|
+
// as the row exists locally instead of returning null until its coverage is server-complete —
|
|
242
|
+
// otherwise every nested fragment (UserBadge, TagChip, CommentCard, …) flashes empty for a
|
|
243
|
+
// round-trip on each navigation. A field not yet synced simply reads absent, not "loading".
|
|
244
|
+
const getSnapshot = useCallback(() => {
|
|
245
|
+
if (!coverage || !descriptor || !projection)
|
|
246
|
+
return null;
|
|
247
|
+
if (coverageDescriptor && ctx.cache.serverResultType(coverageDescriptor.viewKey) === "complete") {
|
|
248
|
+
return projectFragmentSeed(ctx, coverage.query.ast(), coverageDescriptor, ref, projection);
|
|
249
|
+
}
|
|
250
|
+
const data = projection.project(ctx.cache.snapshot(descriptor.viewKey, true));
|
|
251
|
+
return data;
|
|
252
|
+
}, [ctx, coverage, coverageDescriptor, descriptor, projection, ref]);
|
|
253
|
+
const getServerSnapshot = useCallback(() => projectFragmentSeed(ctx, coverage?.query.ast(), coverageDescriptor, ref, projection), [ctx, coverage, coverageDescriptor, projection, ref]);
|
|
254
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
255
|
+
}
|
|
256
|
+
function projectRootRefSnapshot(raw, one, projection) {
|
|
257
|
+
return one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
258
|
+
}
|
|
259
|
+
function projectLocalRootSnapshot(raw, one, projection) {
|
|
260
|
+
return one ? projection.projectOne(raw) : projection.projectMany(raw);
|
|
261
|
+
}
|
|
262
|
+
function projectFragmentSeed(ctx, coverageAst, coverageDescriptor, ref, projection) {
|
|
263
|
+
if (!coverageAst || !coverageDescriptor || !ref || !projection)
|
|
264
|
+
return null;
|
|
265
|
+
const raw = ctx.cache.serverSnapshot(coverageDescriptor.viewKey, coverageDescriptor.one);
|
|
266
|
+
const row = findFragmentSeedRow(coverageAst, raw, ref.table, ref.pk, (table) => ctx.store.primaryKeyFor(table));
|
|
267
|
+
return projection.project(row);
|
|
268
|
+
}
|
|
269
|
+
function findFragmentSeedRow(ast, raw, table, pk, primaryKeyFor) {
|
|
270
|
+
if (Array.isArray(raw)) {
|
|
271
|
+
for (const row of raw) {
|
|
272
|
+
const found = findFragmentSeedRowInObject(ast, row, table, pk, primaryKeyFor);
|
|
273
|
+
if (found !== null)
|
|
274
|
+
return found;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
return findFragmentSeedRowInObject(ast, raw, table, pk, primaryKeyFor);
|
|
279
|
+
}
|
|
280
|
+
function findFragmentSeedRowInObject(ast, raw, table, pk, primaryKeyFor) {
|
|
281
|
+
if (raw === null || raw === undefined || typeof raw !== "object")
|
|
282
|
+
return null;
|
|
283
|
+
const row = raw;
|
|
284
|
+
if (ast.table === table && rowMatchesPk(row, pk, primaryKeyFor(table)))
|
|
285
|
+
return row;
|
|
286
|
+
for (const rel of ast.related ?? []) {
|
|
287
|
+
if (rel.subquery.aggregate !== undefined)
|
|
288
|
+
continue;
|
|
289
|
+
const alias = rel.subquery.alias;
|
|
290
|
+
if (alias === undefined)
|
|
291
|
+
continue;
|
|
292
|
+
const found = findFragmentSeedRow(rel.subquery, row[alias], table, pk, primaryKeyFor);
|
|
293
|
+
if (found !== null)
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
function rowMatchesPk(row, pk, primaryKey) {
|
|
299
|
+
for (const col of primaryKey) {
|
|
300
|
+
if (!Object.prototype.hasOwnProperty.call(row, col))
|
|
301
|
+
return false;
|
|
302
|
+
if (stableKey(row[col]) !== stableKey(pk[col]))
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
class LocalFragmentProjection {
|
|
308
|
+
manyCache = new WeakMap();
|
|
309
|
+
cache = new WeakMap();
|
|
310
|
+
refCache = new Map();
|
|
311
|
+
ast;
|
|
312
|
+
coverage;
|
|
313
|
+
primaryKeyFor;
|
|
314
|
+
constructor(ast, coverage, primaryKeyFor) {
|
|
315
|
+
this.ast = ast;
|
|
316
|
+
this.coverage = coverage;
|
|
317
|
+
this.primaryKeyFor = primaryKeyFor;
|
|
318
|
+
}
|
|
319
|
+
projectOne(raw) {
|
|
320
|
+
return this.project(raw);
|
|
321
|
+
}
|
|
322
|
+
projectMany(raw) {
|
|
323
|
+
if (!Array.isArray(raw))
|
|
324
|
+
return EMPTY_ARRAY;
|
|
325
|
+
const cached = this.manyCache.get(raw);
|
|
326
|
+
if (cached)
|
|
327
|
+
return cached;
|
|
328
|
+
const out = raw.map((row) => this.project(row));
|
|
329
|
+
this.manyCache.set(raw, out);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
project(raw) {
|
|
333
|
+
if (raw === null || raw === undefined || typeof raw !== "object")
|
|
334
|
+
return null;
|
|
335
|
+
const cached = this.cache.get(raw);
|
|
336
|
+
if (cached !== undefined)
|
|
337
|
+
return cached;
|
|
338
|
+
const out = this.projectRow(this.ast, raw);
|
|
339
|
+
this.cache.set(raw, out);
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
projectRow(ast, raw) {
|
|
343
|
+
if (raw === null || raw === undefined || typeof raw !== "object")
|
|
344
|
+
return null;
|
|
345
|
+
const row = raw;
|
|
346
|
+
const out = {};
|
|
347
|
+
const relationshipAliases = new Set((ast.related ?? []).map((rel) => rel.subquery.alias).filter((a) => a !== undefined));
|
|
348
|
+
if (ast.select === undefined) {
|
|
349
|
+
for (const [key, value] of Object.entries(row)) {
|
|
350
|
+
if (!relationshipAliases.has(key))
|
|
351
|
+
out[key] = value;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
for (const col of ast.select) {
|
|
356
|
+
if (Object.prototype.hasOwnProperty.call(row, col))
|
|
357
|
+
out[col] = row[col];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const rel of ast.related ?? []) {
|
|
361
|
+
const alias = rel.subquery.alias;
|
|
362
|
+
if (alias === undefined)
|
|
363
|
+
continue;
|
|
364
|
+
const value = row[alias];
|
|
365
|
+
if (rel.subquery.aggregate !== undefined) {
|
|
366
|
+
out[alias] = value;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
out[alias] = isFragmentRelationship(rel) ? this.projectRelatedRefs(rel.subquery, value) : this.projectInline(rel.subquery, value);
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
projectInline(childAst, value) {
|
|
374
|
+
if (Array.isArray(value))
|
|
375
|
+
return value.map((row) => this.projectRow(childAst, row));
|
|
376
|
+
if (value === null || value === undefined)
|
|
377
|
+
return value ?? null;
|
|
378
|
+
return this.projectRow(childAst, value);
|
|
379
|
+
}
|
|
380
|
+
projectRelatedRefs(childAst, value) {
|
|
381
|
+
if (Array.isArray(value))
|
|
382
|
+
return value.map((row) => this.refForRow(childAst.table, row));
|
|
383
|
+
if (value === null || value === undefined)
|
|
384
|
+
return value ?? null;
|
|
385
|
+
return this.refForRow(childAst.table, value);
|
|
386
|
+
}
|
|
387
|
+
refForRow(table, value) {
|
|
388
|
+
if (value === null || typeof value !== "object") {
|
|
389
|
+
throw new Error(`useFragment(): cannot build a local fragment ref for "${table}" from a non-object row.`);
|
|
390
|
+
}
|
|
391
|
+
const row = value;
|
|
392
|
+
const pk = {};
|
|
393
|
+
for (const col of this.primaryKeyFor(table)) {
|
|
394
|
+
if (!Object.prototype.hasOwnProperty.call(row, col)) {
|
|
395
|
+
throw new Error(`useFragment(): local relationship row for "${table}" is missing primary key column "${col}".`);
|
|
396
|
+
}
|
|
397
|
+
pk[col] = row[col];
|
|
398
|
+
}
|
|
399
|
+
const key = stableKey({ table, pk });
|
|
400
|
+
const cached = this.refCache.get(key);
|
|
401
|
+
if (cached)
|
|
402
|
+
return cached;
|
|
403
|
+
const ref = createLocalFragmentRefForTable(table, pk, this.coverage);
|
|
404
|
+
this.refCache.set(key, ref);
|
|
405
|
+
return ref;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
class RootRefProjection {
|
|
409
|
+
cache = new WeakMap();
|
|
410
|
+
rowCache = new WeakMap();
|
|
411
|
+
keyCache = new Map();
|
|
412
|
+
table;
|
|
413
|
+
coverage;
|
|
414
|
+
primaryKeyFor;
|
|
415
|
+
constructor(fragment, coverage, primaryKeyFor) {
|
|
416
|
+
this.table = tableMeta(fragment.table).name;
|
|
417
|
+
this.coverage = coverage;
|
|
418
|
+
this.primaryKeyFor = primaryKeyFor;
|
|
419
|
+
}
|
|
420
|
+
projectOne(raw) {
|
|
421
|
+
if (raw === null || raw === undefined)
|
|
422
|
+
return null;
|
|
423
|
+
return this.refForRow(raw);
|
|
424
|
+
}
|
|
425
|
+
projectMany(raw) {
|
|
426
|
+
if (!Array.isArray(raw))
|
|
427
|
+
return EMPTY_ARRAY;
|
|
428
|
+
const cached = this.cache.get(raw);
|
|
429
|
+
if (cached)
|
|
430
|
+
return cached;
|
|
431
|
+
const refs = raw.map((row) => this.refForRow(row));
|
|
432
|
+
this.cache.set(raw, refs);
|
|
433
|
+
return refs;
|
|
434
|
+
}
|
|
435
|
+
refForRow(value) {
|
|
436
|
+
if (value === null || typeof value !== "object") {
|
|
437
|
+
throw new Error(`useRoot(): cannot build a fragment ref for "${this.table}" from a non-object row.`);
|
|
438
|
+
}
|
|
439
|
+
const cached = this.rowCache.get(value);
|
|
440
|
+
if (cached)
|
|
441
|
+
return cached;
|
|
442
|
+
const row = value;
|
|
443
|
+
const pk = {};
|
|
444
|
+
for (const col of this.primaryKeyFor(this.table)) {
|
|
445
|
+
if (!Object.prototype.hasOwnProperty.call(row, col)) {
|
|
446
|
+
throw new Error(`useRoot(): local root row for "${this.table}" is missing primary key column "${col}".`);
|
|
447
|
+
}
|
|
448
|
+
pk[col] = row[col];
|
|
449
|
+
}
|
|
450
|
+
const key = stableKey({ table: this.table, pk });
|
|
451
|
+
const existing = this.keyCache.get(key);
|
|
452
|
+
if (existing) {
|
|
453
|
+
this.rowCache.set(value, existing);
|
|
454
|
+
return existing;
|
|
455
|
+
}
|
|
456
|
+
const ref = createLocalFragmentRefForTable(this.table, pk, this.coverage);
|
|
457
|
+
this.keyCache.set(key, ref);
|
|
458
|
+
this.rowCache.set(value, ref);
|
|
459
|
+
return ref;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
export class SyncQueryCache {
|
|
463
|
+
entries = new Map();
|
|
464
|
+
nextLeaseId = 1;
|
|
465
|
+
store;
|
|
466
|
+
releaseDelayMs;
|
|
467
|
+
constructor(store, opts = {}) {
|
|
468
|
+
this.store = store;
|
|
469
|
+
this.releaseDelayMs = opts.releaseDelayMs ?? 0;
|
|
470
|
+
}
|
|
471
|
+
retain(coverageKey, query) {
|
|
472
|
+
let entry = this.entries.get(coverageKey);
|
|
473
|
+
if (!entry) {
|
|
474
|
+
const handle = this.createHandle(query);
|
|
475
|
+
entry = {
|
|
476
|
+
handle,
|
|
477
|
+
leases: [],
|
|
478
|
+
listeners: new Set(),
|
|
479
|
+
unsubscribe: () => { },
|
|
480
|
+
releaseTimer: undefined,
|
|
481
|
+
};
|
|
482
|
+
entry.unsubscribe = handle.subscribe(() => {
|
|
483
|
+
for (const listener of entry.listeners)
|
|
484
|
+
listener();
|
|
485
|
+
});
|
|
486
|
+
this.entries.set(coverageKey, entry);
|
|
487
|
+
}
|
|
488
|
+
else if (entry.releaseTimer !== undefined) {
|
|
489
|
+
clearTimeout(entry.releaseTimer);
|
|
490
|
+
entry.releaseTimer = undefined;
|
|
491
|
+
}
|
|
492
|
+
const lease = { id: this.nextLeaseId++, coverageKey };
|
|
493
|
+
entry.leases.push(lease);
|
|
494
|
+
return lease;
|
|
495
|
+
}
|
|
496
|
+
release(lease) {
|
|
497
|
+
const entry = this.entries.get(lease.coverageKey);
|
|
498
|
+
if (!entry)
|
|
499
|
+
return;
|
|
500
|
+
const index = entry.leases.findIndex((l) => l.id === lease.id);
|
|
501
|
+
if (index < 0)
|
|
502
|
+
return;
|
|
503
|
+
entry.leases.splice(index, 1);
|
|
504
|
+
if (entry.leases.length > 0)
|
|
505
|
+
return;
|
|
506
|
+
this.scheduleRelease(lease.coverageKey, entry);
|
|
507
|
+
}
|
|
508
|
+
subscribe(coverageKey, listener) {
|
|
509
|
+
const entry = this.entries.get(coverageKey);
|
|
510
|
+
if (!entry)
|
|
511
|
+
return () => { };
|
|
512
|
+
entry.listeners.add(listener);
|
|
513
|
+
listener();
|
|
514
|
+
return () => {
|
|
515
|
+
entry.listeners.delete(listener);
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
resultType(coverageKey) {
|
|
519
|
+
return this.entries.get(coverageKey)?.handle.resultType ?? "unknown";
|
|
520
|
+
}
|
|
521
|
+
size() {
|
|
522
|
+
return this.entries.size;
|
|
523
|
+
}
|
|
524
|
+
createHandle(query) {
|
|
525
|
+
if (this.store.canRetainRemoteQueries())
|
|
526
|
+
return this.store.retainSyncQuery(query);
|
|
527
|
+
const view = this.store.materialize(query);
|
|
528
|
+
return {
|
|
529
|
+
get resultType() {
|
|
530
|
+
return view.resultType;
|
|
531
|
+
},
|
|
532
|
+
subscribe: (listener) => view.subscribe(listener),
|
|
533
|
+
release: () => view.destroy(),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
scheduleRelease(coverageKey, entry) {
|
|
537
|
+
if (this.releaseDelayMs <= 0) {
|
|
538
|
+
this.finalizeRelease(coverageKey, entry);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry), this.releaseDelayMs);
|
|
542
|
+
}
|
|
543
|
+
finalizeRelease(coverageKey, entry) {
|
|
544
|
+
if (this.entries.get(coverageKey) !== entry || entry.leases.length > 0)
|
|
545
|
+
return;
|
|
546
|
+
entry.releaseTimer = undefined;
|
|
547
|
+
entry.unsubscribe();
|
|
548
|
+
entry.handle.release();
|
|
549
|
+
this.entries.delete(coverageKey);
|
|
550
|
+
}
|
|
83
551
|
}
|
|
84
552
|
export class QueryCache {
|
|
85
553
|
entries = new Map();
|
|
86
554
|
nextLeaseId = 1;
|
|
87
555
|
store;
|
|
88
|
-
|
|
556
|
+
releaseDelayMs;
|
|
557
|
+
constructor(store, opts = {}) {
|
|
89
558
|
this.store = store;
|
|
559
|
+
this.releaseDelayMs = opts.releaseDelayMs ?? 0;
|
|
90
560
|
}
|
|
91
561
|
retain(viewKey, query) {
|
|
92
562
|
let entry = this.entries.get(viewKey);
|
|
@@ -118,12 +588,20 @@ export class QueryCache {
|
|
|
118
588
|
return;
|
|
119
589
|
if (entry.mode === "split") {
|
|
120
590
|
const [released] = entry.leases.splice(index, 1);
|
|
121
|
-
|
|
591
|
+
if (entry.leases.length > 0 || this.releaseDelayMs <= 0) {
|
|
592
|
+
released.releaseRemote();
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
entry.pendingReleases.push(released);
|
|
596
|
+
this.scheduleSplitRelease(lease.viewKey, entry);
|
|
597
|
+
}
|
|
122
598
|
if (entry.leases.length > 0)
|
|
123
599
|
return;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
600
|
+
if (this.releaseDelayMs <= 0) {
|
|
601
|
+
entry.canonicalUnsubscribe();
|
|
602
|
+
entry.handle.destroy();
|
|
603
|
+
this.entries.delete(lease.viewKey);
|
|
604
|
+
}
|
|
127
605
|
return;
|
|
128
606
|
}
|
|
129
607
|
const [released] = entry.leases.splice(index, 1);
|
|
@@ -144,6 +622,7 @@ export class QueryCache {
|
|
|
144
622
|
if (!entry)
|
|
145
623
|
return () => { };
|
|
146
624
|
entry.listeners.add(listener);
|
|
625
|
+
listener();
|
|
147
626
|
return () => {
|
|
148
627
|
entry.listeners.delete(listener);
|
|
149
628
|
};
|
|
@@ -184,6 +663,8 @@ export class QueryCache {
|
|
|
184
663
|
mode: "split",
|
|
185
664
|
handle,
|
|
186
665
|
leases: [],
|
|
666
|
+
pendingReleases: [],
|
|
667
|
+
releaseTimer: undefined,
|
|
187
668
|
canonicalUnsubscribe: () => { },
|
|
188
669
|
listeners: new Set(),
|
|
189
670
|
};
|
|
@@ -228,6 +709,24 @@ export class QueryCache {
|
|
|
228
709
|
listener();
|
|
229
710
|
});
|
|
230
711
|
}
|
|
712
|
+
scheduleSplitRelease(viewKey, entry) {
|
|
713
|
+
if (entry.releaseTimer !== undefined)
|
|
714
|
+
clearTimeout(entry.releaseTimer);
|
|
715
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), this.releaseDelayMs);
|
|
716
|
+
}
|
|
717
|
+
finalizeSplitRelease(viewKey, entry) {
|
|
718
|
+
if (this.entries.get(viewKey) !== entry)
|
|
719
|
+
return;
|
|
720
|
+
entry.releaseTimer = undefined;
|
|
721
|
+
const pending = entry.pendingReleases.splice(0);
|
|
722
|
+
for (const lease of pending)
|
|
723
|
+
lease.releaseRemote();
|
|
724
|
+
if (entry.leases.length > 0)
|
|
725
|
+
return;
|
|
726
|
+
entry.canonicalUnsubscribe();
|
|
727
|
+
entry.handle.destroy();
|
|
728
|
+
this.entries.delete(viewKey);
|
|
729
|
+
}
|
|
231
730
|
}
|
|
232
731
|
export function queryCacheKey(query) {
|
|
233
732
|
return describeQuery(query).viewKey;
|