API index and search · Build metadata
Source snapshot
packages/react/src/index.ts
1import {2 createContext,3 createElement,4 useCallback,5 useContext,6 useEffect,7 useMemo,8 useRef,9 useState,10 useSyncExternalStore,11} from "react";12import type { ReactNode } from "react";13import {14 createLocalFragmentRefForTable,15 fragmentAst,16 isFragment,17 isFragmentRelationship,18 localQueryReadAst,19 localFragmentReadAst,20 localRootFragmentRefsAst,21 OneShotBackend,22 queryFromAst,23 stableKey,24 Store,25 tableMeta,26} from "@rindle/client";27import type {28 AnyQuery,29 Ast,30 CachedQueryView,31 ColsMap,32 DehydratedState,33 Fragment,34 FragmentData,35 FragmentCoverage,36 FragmentRef,37 LitValue,38 LocalFragmentRef,39 NamedQuery,40 QueryLocalData,41 ResultType,42 Schema,43} from "@rindle/client";4445type AnyView = ReturnType<AnyQuery["materialize"]>;46type AnyFragment = Fragment<any, any, any, any>;47type AnyNamedQuery = NamedQuery<any, readonly unknown[], AnyQuery>;4849export type QueryData<Q extends AnyQuery> = ReturnType<Q["materialize"]>["data"];50export type RootData<Q extends AnyQuery> = QueryLocalData<Q>;51export type RootRefData<Q extends AnyQuery, F extends Fragment<any, any, any, any>> =52 QueryData<Q> extends readonly unknown[] ? readonly FragmentRef<F>[] : FragmentRef<F> | null;53export interface RootDetails {54 readonly status: ResultType;55}56export type RootResult<Q extends AnyQuery> = readonly [data: RootData<Q>, details: RootDetails];57export type RootRefResult<Q extends AnyQuery, F extends Fragment<any, any, any, any>> =58 readonly [data: RootRefData<Q, F>, details: RootDetails];5960export type { ResultType } from "@rindle/client";61export type { Fragment, FragmentData, FragmentRef } from "@rindle/client";62export { fragmentKey } from "@rindle/client";6364// The LM stream plane's client half (LM-STREAM-CHECKPOINT-DESIGN.md): the durable side arrives through65// an ordinary query, `useStreamedText` adds the live tail and merges the two. The pure reassembly66// helpers come from `@rindle/client` and are re-exported so a component needs ONE import.67export {68 DEFAULT_STREAM_ENDPOINT,69 eventSourceTransport,70 streamSubscribeUrl,71 useStreamedText,72} from "./stream.ts";73export type { StreamTransport, UseStreamedTextInput, UseStreamedTextOptions } from "./stream.ts";74export { assembleDurableText, spliceStreamText } from "@rindle/client";75export type { StreamFrame, StreamStatus } from "@rindle/client";7677export interface RindleProps<S extends ColsMap = ColsMap> {78 store: Store<S>;79 /** Default grace window (ms) for every query in this tree — how long a view + its server lease are80 * kept warm after the last subscriber unmounts. Defaults to 2s; see {@link QueryReleaseOptions}81 * for why, and for the per-call-site override. Treat as a constant: changing it rebuilds the82 * caches and tears down every live view. */83 releaseDelayMs?: number;84 children?: ReactNode;85}8687interface QueryDescriptor {88 viewKey: string;89 leaseKey: string;90 one: boolean;91}9293interface QueryLease {94 id: number;95 viewKey: string;96}9798interface SyncLease {99 id: number;100 coverageKey: string;101 /** Resolved grace window for THIS lease (see {@link QueryReleaseOptions}). */102 releaseDelayMs: number;103}104105interface MaterializedLease extends QueryLease {106 view: AnyView;107 remote: boolean;108}109110interface SplitLease extends QueryLease {111 releaseRemote: () => void;112 /** Resolved grace window for THIS lease (see {@link QueryReleaseOptions}). */113 releaseDelayMs: number;114}115116type CacheLease = MaterializedLease | SplitLease;117118interface BaseCacheEntry {119 leases: CacheLease[];120 canonicalUnsubscribe: () => void;121 listeners: Set<() => void>;122}123124interface SplitCacheEntry extends BaseCacheEntry {125 mode: "split";126 handle: CachedQueryView<AnyQuery>;127 leases: SplitLease[];128 pendingReleases: SplitLease[];129 releaseTimer: ReleaseTimer | undefined;130 /** Absolute monotonic ms this entry's warm window expires at — the latest `release time + that131 * lease's delay` asked for by ANY lease on this entry (see {@link QueryReleaseOptions}). Written132 * only in `release`, so the clock starts when a subscriber LEAVES. Monotone, and it expires on its133 * own, which is what makes a later lease inherit only the residue of an older window. */134 releaseDeadline: number;135}136137interface MaterializedCacheEntry extends BaseCacheEntry {138 mode: "materialized";139 leases: MaterializedLease[];140 canonical: MaterializedLease;141}142143type CacheEntry = SplitCacheEntry | MaterializedCacheEntry;144145const EMPTY_ARRAY: readonly never[] = Object.freeze([]);146// Keep just-released coverage alive briefly so a changed filter/limit can re-materialize from the147// local base synchronously while the replacement server lease is still streaming its first answer.148// Overridable per tree (`<Rindle releaseDelayMs>`) and per call site ({@link QueryReleaseOptions}).149const REACT_CACHE_RELEASE_DELAY_MS = 2_000;150151type ReleaseTimer = ReturnType<typeof setTimeout>;152153interface QueryCacheOptions {154 releaseDelayMs?: number;155}156157/**158 * Per-call-site override for how long a query is kept warm after its LAST subscriber unmounts.159 *160 * The default (2s, or whatever `<Rindle releaseDelayMs>` sets) exists so a changed filter/limit can161 * re-materialize from the still-warm local base while the replacement server lease streams its first162 * answer — it's what keeps navigation from flashing empty. That grace window is wrong for queries you163 * KNOW you will never come back to, the canonical case being typeahead search: every keystroke is a164 * distinct query, so a 2s window leaves one dead view + server subscription open per character typed.165 * Pass `0` there to tear down on unmount:166 *167 * ```tsx168 * const results = useQuery(searchIssues(term), { releaseDelayMs: 0 });169 * ```170 *171 * Treat the value as a constant per call site — changing it re-leases the query (drops the old lease172 * and takes a fresh one), which is wasted work if it changes every render.173 *174 * The rule for a query several components share with DIFFERENT delays is a DEADLINE, not a duration:175 * every release stamps `now + that lease's delay`, and the query stays warm until the latest deadline176 * any of its leases asked for (max-wins over what REMAINS, matching the SSR preload TTL rule in177 * `@rindle/client`'s `ssr.ts`). Two consequences worth internalizing:178 *179 * - The clock starts when a subscriber LEAVES, never when it arrives — a mounted reader is never180 * timed out, however long it stays.181 * - A deadline expires on its own, so a later lease inherits at most the RESIDUE of an older window,182 * never a fresh copy of it. Unmount a 2s reader, remount a `releaseDelayMs: 0` one 1.9s later and183 * drop it: teardown lands at the original 2s mark, not 1.9s past it.184 *185 * Only meaningful against a backend that can retain remote queries. A local-only store (the SSR seed186 * over `OneShotBackend`, or a store with no remote leg) always tears its views down on release, so187 * there is no window to shorten.188 */189export interface QueryReleaseOptions {190 /** ms to keep this query warm after the last subscriber unmounts. `0` = release immediately.191 * Defaults to the provider's `releaseDelayMs` (2s). */192 releaseDelayMs?: number;193}194195/** Monotonic clock for release deadlines. Deliberately NOT `Date.now()`: a wall-clock step backwards196 * would extend every live warm window, and a step forwards would truncate them. */197function nowMs(): number {198 return performance.now();199}200201function setReleaseTimeout(fn: () => void, ms: number): ReleaseTimer {202 const timer = setTimeout(fn, ms);203 (timer as { unref?: () => void }).unref?.();204 return timer;205}206207class RindleContextValue {208 readonly store: Store<ColsMap>;209 readonly cache: QueryCache;210 readonly syncCache: SyncQueryCache;211212 constructor(store: Store<ColsMap>, releaseDelayMs: number) {213 this.store = store;214 this.cache = new QueryCache(store, { releaseDelayMs });215 this.syncCache = new SyncQueryCache(store, { releaseDelayMs });216 }217}218219const RindleContext = createContext<RindleContextValue | null>(null);220221export function Rindle<S extends ColsMap>({ store, releaseDelayMs, children }: RindleProps<S>) {222 const delay = releaseDelayMs ?? REACT_CACHE_RELEASE_DELAY_MS;223 // `releaseDelayMs` is tree CONFIG, not state: changing it rebuilds the caches exactly like swapping224 // `store` does, tearing down every live view. Pass a constant; use the per-hook option to vary it.225 const value = useMemo(226 () => new RindleContextValue(store as unknown as Store<ColsMap>, delay),227 [store, delay],228 );229 return createElement(RindleContext.Provider, { value }, children);230}231232export const RindleProvider = Rindle;233234export function useRindleStore<S extends ColsMap = ColsMap>(): Store<S> {235 return useRindleContext().store as unknown as Store<S>;236}237238export interface RindleSSRProps<S extends ColsMap = ColsMap> {239 /** The app schema — used to build the transport-less seed {@link Store} that backs the server240 * render and the matching client hydration pass. */241 schema: Schema<S>;242 /** The dehydrated first-paint cache from the route loader (`ServerStore.dehydrate()`), embedded in243 * the HTML. Read on BOTH the server render and the client's first (hydration) render. */244 ssrState: DehydratedState;245 /** Boots the live (wasm-backed) client in the BROWSER — the app's `bootClient`. Called once, after246 * hydration, and must resolve to the live optimistic store. Never invoked during the server247 * render (SSR seeds are a first-paint concern only). */248 boot: () => Promise<{ store: Store<S> }>;249 children?: ReactNode;250}251252/**253 * The SSR→SPA store handoff (SSR-DESIGN.md §6.1). Renders `<Rindle>` with a store that swaps from a254 * transport-less SSR seed to the live engine WITHOUT changing a single `useQuery` caller — bind the255 * app's `schema` + `bootClient` and drop it in above the tree:256 *257 * - Server render + browser HYDRATION: a seed {@link Store} over a {@link OneShotBackend}, hydrated258 * from `ssrState`. `useQuery` reads its seed via `getServerSnapshot`, so the server and the259 * client's first render produce identical markup with NO engine on either side — first paint is260 * the server-rendered data, never a "Starting…" splash that would break hydration.261 * - After hydration: `boot()` starts the wasm IVM engine (browser only), its views are seeded from262 * the SAME snapshot (so the swap shows the SSR rows with no flash), and the live `subscribe`263 * reconciles — the page is now a live SPA.264 *265 * Framework-agnostic of the app: everything but `schema`/`boot`/`ssrState` is owned here (previously266 * hand-rolled per app as `src/RindleApp.tsx`).267 */268export function RindleSSR<S extends ColsMap>({ schema, ssrState, boot, children }: RindleSSRProps<S>) {269 // Built ONCE from the first-render snapshot (SSR seeds are an initial-load concern only — after270 // hydration the live store owns every read). Backs the server render AND the matching client271 // hydration pass — same seeds in, same markup out.272 const [seedStore] = useState(() => {273 const store = new Store(schema, new OneShotBackend());274 store.hydrate(ssrState);275 return store;276 });277278 // The live wasm store — booted only in the browser, only after hydration.279 const [liveStore, setLiveStore] = useState<Store<S> | null>(null);280 // Pin `ssrState`/`boot` through refs so the boot effect runs EXACTLY once (empty deps) yet always281 // sees the latest values — a caller may pass an inline `boot`, which as a dep would re-boot the282 // client every render.283 const ssrStateRef = useRef(ssrState);284 ssrStateRef.current = ssrState;285 const bootRef = useRef(boot);286 bootRef.current = boot;287 useEffect(() => {288 let alive = true;289 void bootRef.current().then((app) => {290 if (!alive) return;291 app.store.hydrate(ssrStateRef.current); // seed the live views so the swap doesn't flash empty292 setLiveStore(app.store);293 });294 return () => {295 alive = false;296 };297 }, []);298299 return createElement(Rindle<S>, { store: liveStore ?? seedStore }, children);300}301302export function useQuery<Q extends AnyQuery>(query: Q, opts?: QueryReleaseOptions): QueryData<Q> {303 const ctx = useRindleContext();304 const descriptor = useMemo(() => describeQuery(query), [query]);305 const queryRef = useRef(query);306 queryRef.current = query;307 const releaseDelayMs = opts?.releaseDelayMs;308309 const subscribe = useCallback(310 (onStoreChange: () => void) => {311 const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);312 const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);313 return () => {314 unsubscribe();315 ctx.cache.release(lease);316 };317 },318 [ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs],319 );320321 const getSnapshot = useCallback(322 () => ctx.cache.snapshot(descriptor.viewKey, descriptor.one) as QueryData<Q>,323 [ctx.cache, descriptor.viewKey, descriptor.one],324 );325326 // SSR (SSR-DESIGN.md §6): the server calls `getServerSnapshot` (never `subscribe`), so it must327 // surface the dehydrated/preloaded seed directly — not the live cache, which is never retained328 // on the server. The client's hydration pass reads the same seed, matching the SSR markup.329 const getServerSnapshot = useCallback(330 () => ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one) as QueryData<Q>,331 [ctx.cache, descriptor.viewKey, descriptor.one],332 );333334 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);335}336337/** The SERVER-CHANNEL state of a query's view (`@rindle/client` {@link ResultType}): `unknown` while338 * it loads (not yet server-authoritative), `complete` once the server has answered. A pending339 * optimistic mutation no longer moves this — that is a separate axis now (FOLDED-MUTATIONS-DESIGN340 * §7); the `error` variant is reserved and currently unproduced. Shares the same cached/leased view341 * as {@link useQuery} (so reading both for one query is one subscription), and re-renders only when342 * the status changes. */343export function useQueryStatus(query: AnyQuery, opts?: QueryReleaseOptions): ResultType {344 const ctx = useRindleContext();345 const descriptor = useMemo(() => describeQuery(query), [query]);346 const queryRef = useRef(query);347 queryRef.current = query;348 const releaseDelayMs = opts?.releaseDelayMs;349350 const subscribe = useCallback(351 (onStoreChange: () => void) => {352 const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);353 const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);354 return () => {355 unsubscribe();356 ctx.cache.release(lease);357 };358 },359 [ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs],360 );361362 const getSnapshot = useCallback(() => ctx.cache.resultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);363364 // SSR: a seeded query is server-authoritative for first paint (`complete`); otherwise `unknown`.365 const getServerSnapshot = useCallback(366 () => ctx.cache.serverResultType(descriptor.viewKey),367 [ctx.cache, descriptor.viewKey],368 );369370 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);371}372373/** Retain a named server query for normalized/local-first sync coverage without subscribing React374 * to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`375 * until the backend reports that the retained coverage has hydrated. */376export function useSyncQuery(query: AnyQuery, opts?: QueryReleaseOptions): ResultType {377 const ctx = useRindleContext();378 const descriptor = useMemo(() => describeQuery(query), [query]);379 const queryRef = useRef(query);380 queryRef.current = query;381 const releaseDelayMs = opts?.releaseDelayMs;382383 const subscribe = useCallback(384 (onStoreChange: () => void) => {385 const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current, releaseDelayMs);386 const unsubscribe = ctx.syncCache.subscribe(descriptor.leaseKey, onStoreChange);387 return () => {388 unsubscribe();389 ctx.syncCache.release(lease);390 };391 },392 [ctx.syncCache, descriptor.leaseKey, releaseDelayMs],393 );394395 const getSnapshot = useCallback(() => {396 const live = ctx.syncCache.resultType(descriptor.leaseKey);397 return live === "unknown" && ctx.cache.serverResultType(descriptor.viewKey) === "complete" ? "complete" : live;398 }, [ctx.cache, ctx.syncCache, descriptor.leaseKey, descriptor.viewKey]);399400 const getServerSnapshot = useCallback(401 () => ctx.cache.serverResultType(descriptor.viewKey),402 [ctx.cache, descriptor.viewKey],403 );404405 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);406}407408/** Run a named root query and expose its local React-facing data. Fragment child relationships are409 * refs, so child components can keep owning their own local reads. Passing a root fragment as the410 * final argument switches the result to opaque root refs for that fragment. */411export function useRoot<Q extends AnyQuery>(query: Q): RootResult<Q>;412export function useRoot<Q extends AnyQuery, F extends AnyFragment>(413 query: Q,414 fragment: F,415): RootRefResult<Q, F>;416export function useRoot<Q extends AnyQuery>(417 query: NamedQuery<void, [], Q>,418): RootResult<Q>;419export function useRoot<Q extends AnyQuery, F extends AnyFragment>(420 query: NamedQuery<void, [], Q>,421 fragment: F,422): RootRefResult<Q, F>;423export function useRoot<Args, Ctx extends readonly unknown[], Q extends AnyQuery>(424 query: NamedQuery<Args, Ctx, Q>,425 args: Args,426 ...ctx: Ctx427): RootResult<Q>;428export function useRoot<Args, Ctx extends readonly unknown[], Q extends AnyQuery, F extends AnyFragment>(429 query: NamedQuery<Args, Ctx, Q>,430 args: Args,431 ...ctxAndFragment: [...ctx: Ctx, fragment: F]432): RootRefResult<Q, F>;433export function useRoot<Q extends AnyQuery, F extends AnyFragment>(434 queryOrNamed: Q | AnyNamedQuery,435 ...args: unknown[]436): RootResult<Q> | RootRefResult<Q, F> {437 const { query, fragment } = resolveRootQueryInput(queryOrNamed, args, "useRoot");438 const stableQuery = useStableQuery(query as Q);439 const status = useSyncQuery(stableQuery);440 const data = fragment === undefined441 ? useLocalRootQueryData(stableQuery)442 : useRootRefData(stableQuery, fragment as F);443 const details = useMemo<RootDetails>(() => ({ status }), [status]);444 return useMemo(445 () => [data, details] as const,446 [data, details],447 ) as RootResult<Q> | RootRefResult<Q, F>;448}449450function resolveRootQueryInput(451 queryOrNamed: AnyQuery | AnyNamedQuery,452 args: readonly unknown[],453 hookName: string,454): { query: AnyQuery; fragment?: AnyFragment } {455 const last = args[args.length - 1];456 const fragment = isFragment(last) ? last as AnyFragment : undefined;457 const queryArgs = fragment === undefined ? args : args.slice(0, -1);458 if (isNamedQuery(queryOrNamed)) {459 const query = queryArgs.length === 0460 ? (queryOrNamed as unknown as () => AnyQuery)()461 : queryOrNamed(queryArgs[0], ...queryArgs.slice(1));462 return { query, fragment };463 }464 if (queryArgs.length !== 0) throw new Error(`${hookName}(): expected (query) or (query, fragment).`);465 return { query: queryOrNamed, fragment };466}467468function isNamedQuery(v: unknown): v is AnyNamedQuery {469 return typeof v === "function"470 && typeof (v as Partial<AnyNamedQuery>).queryName === "string"471 && typeof (v as Partial<AnyNamedQuery>).resolve === "function";472}473474function useStableQuery<Q extends AnyQuery>(query: Q): Q {475 const descriptor = describeQuery(query);476 const ref = useRef<{ leaseKey: string; query: Q } | undefined>(undefined);477 if (ref.current === undefined || ref.current.leaseKey !== descriptor.leaseKey) {478 ref.current = { leaseKey: descriptor.leaseKey, query };479 }480 return ref.current.query;481}482483function useLocalRootQueryData<Q extends AnyQuery>(484 query: Q,485): RootData<Q> {486 const ctx = useRindleContext();487 const descriptor = useMemo(() => describeQuery(query), [query]);488 const ast = useMemo(489 () => localQueryReadAst(query, (table) => ctx.store.primaryKeyFor(table)),490 [ctx.store, query],491 );492 const localQuery = useMemo(() => queryFromAst(ast), [ast]);493 const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);494 const projection = useMemo(495 () => new LocalFragmentProjection(ast, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)),496 [ctx.store, ast, descriptor.leaseKey, query],497 );498499 const subscribe = useCallback(500 (onStoreChange: () => void) => {501 const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);502 const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);503 return () => {504 unsubscribeLocal();505 ctx.cache.release(localLease);506 };507 },508 [ctx.cache, localDescriptor.viewKey, localQuery],509 );510511 // Stale-while-revalidate: render whatever the local IVM view already holds (synced rows,512 // optimistic writes, partially-covered rows) rather than blanking to empty until the server513 // confirms coverage. `status` (from useSyncQuery) stays a SEPARATE signal callers can gate on;514 // the data itself never waits on the round-trip, so navigating to a view that's locally warm515 // shows rows immediately instead of flashing a loading state. (Eviction-on-release still drops516 // a cold view; a release TTL to keep views warm across nav is future work.)517 const getSnapshot = useCallback(() => {518 if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {519 return projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);520 }521 return projectLocalRootSnapshot(ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one), descriptor.one, projection);522 }, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);523524 const getServerSnapshot = useCallback(525 () => projectLocalRootSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection),526 [ctx.cache, descriptor.one, descriptor.viewKey, projection],527 );528529 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) as RootData<Q>;530}531532function useRootRefData<Q extends AnyQuery, F extends Fragment<any, any, any, any>>(533 query: Q,534 fragment: F,535): RootRefData<Q, F> {536 const ctx = useRindleContext();537 const descriptor = useMemo(() => describeQuery(query), [query]);538 const ast = useMemo(539 () => localRootFragmentRefsAst(fragment, query, (table) => ctx.store.primaryKeyFor(table)),540 [ctx.store, fragment, query],541 );542 const localQuery = useMemo(() => queryFromAst(ast), [ast]);543 const localDescriptor = useMemo(() => describeQuery(localQuery), [localQuery]);544 const projection = useMemo(545 () => new RootRefProjection(fragment, { key: descriptor.leaseKey, query }, (table) => ctx.store.primaryKeyFor(table)),546 [ctx.store, descriptor.leaseKey, fragment, query],547 );548549 const subscribe = useCallback(550 (onStoreChange: () => void) => {551 const localLease = ctx.cache.retain(localDescriptor.viewKey, localQuery);552 const unsubscribeLocal = ctx.cache.subscribe(localDescriptor.viewKey, onStoreChange);553 return () => {554 unsubscribeLocal();555 ctx.cache.release(localLease);556 };557 },558 [ctx.cache, localDescriptor.viewKey, localQuery],559 );560561 // Stale-while-revalidate (see useLocalRootQueryData): the root rows render from the live local562 // view as soon as it holds anything; `status` is the separate axis for "server-authoritative yet".563 const getSnapshot = useCallback(() => {564 if (ctx.cache.serverResultType(descriptor.viewKey) === "complete") {565 return projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection);566 }567 const raw = ctx.cache.snapshot(localDescriptor.viewKey, descriptor.one);568 return descriptor.one ? projection.projectOne(raw) : projection.projectMany(raw);569 }, [ctx.cache, descriptor.one, descriptor.viewKey, localDescriptor.viewKey, projection]);570571 const getServerSnapshot = useCallback(572 () => projectRootRefSnapshot(ctx.cache.serverSnapshot(descriptor.viewKey, descriptor.one), descriptor.one, projection),573 [ctx.cache, descriptor.one, descriptor.viewKey, projection],574 );575576 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) as RootRefData<Q, F>;577}578579/**580 * Read a {@link Fragment}'s local data from an opaque ref.581 *582 * The query boundary calls {@link useRoot} with a fragment argument to retain the full named583 * coverage query and receive root refs. Descendants call `useFragment` with those refs (or child584 * refs returned by a parent fragment read) to open narrow local-only reads for the fields their585 * fragment owns.586 *587 * `ref` is an opaque token created by {@link useRoot} or returned from another local fragment read.588 * The hook opens a narrow local-only query for this exact fragment and keeps the root coverage589 * lease retained while mounted. Passing a legacy projected data object is unsupported.590 */591export function useFragment<F extends Fragment<any, any, any, any>>(592 fragment: F,593 ref: FragmentRef<F> | null | undefined,594 opts?: QueryReleaseOptions,595): FragmentData<F> | null {596 return useLocalFragment(fragment, ref, opts);597}598599/**600 * Render-prop sugar over {@link useFragment}: does the `null` check once. `from` is a fragment ref601 * (or null/undefined — an absent to-one relationship, an emptied `.one()`, or a row deleted out from602 * under a live read); when the row is present `children(data)` renders, otherwise `fallback` (default603 * nothing). Keeps the per-row subscription isolation — a child-only edit re-renders just this read.604 */605export function Frag<F extends AnyFragment>(606 { of, from, fallback = null, releaseDelayMs, children }: {607 of: F;608 from: FragmentRef<F> | null | undefined;609 fallback?: ReactNode;610 /** Per-call-site grace window — see {@link QueryReleaseOptions}. */611 releaseDelayMs?: number;612 children: (data: FragmentData<F>) => ReactNode;613 },614): ReactNode {615 const data = useFragment(of, from, { releaseDelayMs });616 return data == null ? fallback : children(data);617}618619function useLocalFragment<F extends Fragment<any, any, any, any>>(620 fragment: F,621 ref: LocalFragmentRef<F> | null | undefined,622 opts?: QueryReleaseOptions,623): FragmentData<F> | null {624 const ctx = useRindleContext();625 const coverage = ref?.coverage;626 const coverageDescriptor = useMemo(() => (coverage ? describeQuery(coverage.query) : undefined), [coverage]);627 const ast = useMemo(628 () => (ref ? localFragmentReadAst(fragment, ref, (table) => ctx.store.primaryKeyFor(table)) : undefined),629 [ctx.store, fragment, ref],630 );631 const query = useMemo(() => (ast ? queryFromAst(ast) : undefined), [ast]);632 const descriptor = useMemo(() => (query ? describeQuery(query) : undefined), [query]);633 const projection = useMemo(634 () => (coverage ? new LocalFragmentProjection(fragmentAst(fragment), coverage, (table) => ctx.store.primaryKeyFor(table)) : undefined),635 [ctx.store, coverage, fragment],636 );637638 const releaseDelayMs = opts?.releaseDelayMs;639 const subscribe = useCallback(640 (onStoreChange: () => void) => {641 if (!coverage || !descriptor || !query) return () => {};642 const syncLease = ctx.syncCache.retain(coverage.key, coverage.query, releaseDelayMs);643 const localLease = ctx.cache.retain(descriptor.viewKey, query, releaseDelayMs);644 const unsubscribeSync = ctx.syncCache.subscribe(coverage.key, onStoreChange);645 const unsubscribeLocal = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);646 return () => {647 unsubscribeLocal();648 unsubscribeSync();649 ctx.cache.release(localLease);650 ctx.syncCache.release(syncLease);651 };652 },653 [ctx.cache, ctx.syncCache, coverage, descriptor, query, releaseDelayMs],654 );655656 // Stale-while-revalidate (see useLocalRootQueryData): project the fragment's local view as soon657 // as the row exists locally instead of returning null until its coverage is server-complete —658 // otherwise every nested fragment (UserBadge, TagChip, CommentCard, …) flashes empty for a659 // round-trip on each navigation. A field not yet synced simply reads absent, not "loading".660 const getSnapshot = useCallback(() => {661 if (!coverage || !descriptor || !projection) return null;662 if (coverageDescriptor && ctx.cache.serverResultType(coverageDescriptor.viewKey) === "complete") {663 return projectFragmentSeed(ctx, coverage.query.ast(), coverageDescriptor, ref, projection);664 }665 const data = projection.project(ctx.cache.snapshot(descriptor.viewKey, true));666 return data as FragmentData<F> | null;667 }, [ctx, coverage, coverageDescriptor, descriptor, projection, ref]);668669 const getServerSnapshot = useCallback(670 () => projectFragmentSeed(ctx, coverage?.query.ast(), coverageDescriptor, ref, projection),671 [ctx, coverage, coverageDescriptor, projection, ref],672 );673674 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);675}676677function projectRootRefSnapshot(raw: unknown, one: boolean, projection: RootRefProjection): unknown {678 return one ? projection.projectOne(raw) : projection.projectMany(raw);679}680681function projectLocalRootSnapshot(raw: unknown, one: boolean, projection: LocalFragmentProjection): unknown {682 return one ? projection.projectOne(raw) : projection.projectMany(raw);683}684685function projectFragmentSeed<F extends Fragment<any, any, any, any>>(686 ctx: RindleContextValue,687 coverageAst: Ast | undefined,688 coverageDescriptor: QueryDescriptor | undefined,689 ref: LocalFragmentRef<F> | null | undefined,690 projection: LocalFragmentProjection | undefined,691): FragmentData<F> | null {692 if (!coverageAst || !coverageDescriptor || !ref || !projection) return null;693 const raw = ctx.cache.serverSnapshot(coverageDescriptor.viewKey, coverageDescriptor.one);694 const row = findFragmentSeedRow(coverageAst, raw, ref.table, ref.pk, (table) => ctx.store.primaryKeyFor(table));695 return projection.project(row) as FragmentData<F> | null;696}697698function findFragmentSeedRow(699 ast: Ast,700 raw: unknown,701 table: string,702 pk: Readonly<Record<string, LitValue>>,703 primaryKeyFor: (table: string) => readonly string[],704): unknown {705 if (Array.isArray(raw)) {706 for (const row of raw) {707 const found = findFragmentSeedRowInObject(ast, row, table, pk, primaryKeyFor);708 if (found !== null) return found;709 }710 return null;711 }712 return findFragmentSeedRowInObject(ast, raw, table, pk, primaryKeyFor);713}714715function findFragmentSeedRowInObject(716 ast: Ast,717 raw: unknown,718 table: string,719 pk: Readonly<Record<string, LitValue>>,720 primaryKeyFor: (table: string) => readonly string[],721): unknown {722 if (raw === null || raw === undefined || typeof raw !== "object") return null;723 const row = raw as Record<string, unknown>;724 if (ast.table === table && rowMatchesPk(row, pk, primaryKeyFor(table))) return row;725 for (const rel of ast.related ?? []) {726 if (rel.subquery.aggregate !== undefined) continue;727 const alias = rel.subquery.alias;728 if (alias === undefined) continue;729 const found = findFragmentSeedRow(rel.subquery, row[alias], table, pk, primaryKeyFor);730 if (found !== null) return found;731 }732 return null;733}734735function rowMatchesPk(row: Record<string, unknown>, pk: Readonly<Record<string, LitValue>>, primaryKey: readonly string[]): boolean {736 for (const col of primaryKey) {737 if (!Object.prototype.hasOwnProperty.call(row, col)) return false;738 if (stableKey(row[col]) !== stableKey(pk[col])) return false;739 }740 return true;741}742743class LocalFragmentProjection {744 private readonly manyCache = new WeakMap<object, readonly unknown[]>();745 private readonly cache = new WeakMap<object, unknown>();746 private readonly refCache = new Map<string, LocalFragmentRef>();747 private readonly ast: Ast;748 private readonly coverage: FragmentCoverage;749 private readonly primaryKeyFor: (table: string) => readonly string[];750751 constructor(752 ast: Ast,753 coverage: FragmentCoverage,754 primaryKeyFor: (table: string) => readonly string[],755 ) {756 this.ast = ast;757 this.coverage = coverage;758 this.primaryKeyFor = primaryKeyFor;759 }760761 projectOne(raw: unknown): unknown {762 return this.project(raw);763 }764765 projectMany(raw: unknown): readonly unknown[] {766 if (!Array.isArray(raw)) return EMPTY_ARRAY;767 const cached = this.manyCache.get(raw);768 if (cached) return cached;769 const out = raw.map((row) => this.project(row));770 this.manyCache.set(raw, out);771 return out;772 }773774 project(raw: unknown): unknown {775 if (raw === null || raw === undefined || typeof raw !== "object") return null;776 const cached = this.cache.get(raw);777 if (cached !== undefined) return cached;778779 const out = this.projectRow(this.ast, raw);780 this.cache.set(raw, out);781 return out;782 }783784 private projectRow(ast: Ast, raw: unknown): unknown {785 if (raw === null || raw === undefined || typeof raw !== "object") return null;786 const row = raw as Record<string, unknown>;787 const out: Record<string, unknown> = {};788 const relationshipAliases = new Set((ast.related ?? []).map((rel) => rel.subquery.alias).filter((a): a is string => a !== undefined));789 if (ast.select === undefined) {790 for (const [key, value] of Object.entries(row)) {791 if (!relationshipAliases.has(key)) out[key] = value;792 }793 } else {794 for (const col of ast.select) {795 if (Object.prototype.hasOwnProperty.call(row, col)) out[col] = row[col];796 }797 }798799 for (const rel of ast.related ?? []) {800 const alias = rel.subquery.alias;801 if (alias === undefined) continue;802 const value = row[alias];803 if (rel.subquery.aggregate !== undefined) {804 out[alias] = value;805 continue;806 }807 out[alias] = isFragmentRelationship(rel) ? this.projectRelatedRefs(rel.subquery, value) : this.projectInline(rel.subquery, value);808 }809810 return out;811 }812813 private projectInline(childAst: Ast, value: unknown): unknown {814 if (Array.isArray(value)) return value.map((row) => this.projectRow(childAst, row));815 if (value === null || value === undefined) return value ?? null;816 return this.projectRow(childAst, value);817 }818819 private projectRelatedRefs(childAst: Ast, value: unknown): unknown {820 if (Array.isArray(value)) return value.map((row) => this.refForRow(childAst.table, row));821 if (value === null || value === undefined) return value ?? null;822 return this.refForRow(childAst.table, value);823 }824825 private refForRow(table: string, value: unknown): LocalFragmentRef {826 if (value === null || typeof value !== "object") {827 throw new Error(`useFragment(): cannot build a local fragment ref for "${table}" from a non-object row.`);828 }829 const row = value as Record<string, unknown>;830 const pk: Record<string, LitValue> = {};831 for (const col of this.primaryKeyFor(table)) {832 if (!Object.prototype.hasOwnProperty.call(row, col)) {833 throw new Error(`useFragment(): local relationship row for "${table}" is missing primary key column "${col}".`);834 }835 pk[col] = row[col] as LitValue;836 }837 const key = stableKey({ table, pk });838 const cached = this.refCache.get(key);839 if (cached) return cached;840 const ref = createLocalFragmentRefForTable(table, pk, this.coverage);841 this.refCache.set(key, ref);842 return ref;843 }844}845846class RootRefProjection {847 private readonly cache = new WeakMap<object, readonly LocalFragmentRef[]>();848 private readonly rowCache = new WeakMap<object, LocalFragmentRef>();849 private readonly keyCache = new Map<string, LocalFragmentRef>();850 private readonly table: string;851 private readonly coverage: FragmentCoverage;852 private readonly primaryKeyFor: (table: string) => readonly string[];853854 constructor(855 fragment: Fragment<any, any, any>,856 coverage: FragmentCoverage,857 primaryKeyFor: (table: string) => readonly string[],858 ) {859 this.table = tableMeta(fragment.table).name;860 this.coverage = coverage;861 this.primaryKeyFor = primaryKeyFor;862 }863864 projectOne(raw: unknown): LocalFragmentRef | null {865 if (raw === null || raw === undefined) return null;866 return this.refForRow(raw);867 }868869 projectMany(raw: unknown): readonly LocalFragmentRef[] {870 if (!Array.isArray(raw)) return EMPTY_ARRAY as readonly LocalFragmentRef[];871 const cached = this.cache.get(raw);872 if (cached) return cached;873 const refs = raw.map((row) => this.refForRow(row));874 this.cache.set(raw, refs);875 return refs;876 }877878 private refForRow(value: unknown): LocalFragmentRef {879 if (value === null || typeof value !== "object") {880 throw new Error(`useRoot(): cannot build a fragment ref for "${this.table}" from a non-object row.`);881 }882 const cached = this.rowCache.get(value);883 if (cached) return cached;884 const row = value as Record<string, unknown>;885 const pk: Record<string, LitValue> = {};886 for (const col of this.primaryKeyFor(this.table)) {887 if (!Object.prototype.hasOwnProperty.call(row, col)) {888 throw new Error(`useRoot(): local root row for "${this.table}" is missing primary key column "${col}".`);889 }890 pk[col] = row[col] as LitValue;891 }892 const key = stableKey({ table: this.table, pk });893 const existing = this.keyCache.get(key);894 if (existing) {895 this.rowCache.set(value, existing);896 return existing;897 }898 const ref = createLocalFragmentRefForTable(this.table, pk, this.coverage);899 this.keyCache.set(key, ref);900 this.rowCache.set(value, ref);901 return ref;902 }903}904905interface SyncCacheEntry {906 handle: SyncQueryHandle;907 leases: SyncLease[];908 listeners: Set<() => void>;909 unsubscribe: () => void;910 releaseTimer: ReleaseTimer | undefined;911 /** Absolute monotonic ms this coverage's warm window expires at — same deadline rule as912 * {@link SplitCacheEntry.releaseDeadline}, so both caches implement the one contract documented on913 * {@link QueryReleaseOptions}. */914 releaseDeadline: number;915}916917interface SyncQueryHandle {918 readonly resultType: ResultType;919 subscribe(listener: () => void): () => void;920 release(): void;921}922923export class SyncQueryCache {924 private readonly entries = new Map<string, SyncCacheEntry>();925 private nextLeaseId = 1;926 private readonly store: Store<ColsMap>;927 private readonly defaultReleaseDelayMs: number;928929 constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {930 this.store = store;931 this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);932 }933934 /** `releaseDelayMs` overrides the cache default for THIS lease only (see935 * {@link QueryReleaseOptions}) — `0` asks for no warm window of its own, though an unexpired936 * deadline from an earlier lease on this coverage still applies. */937 retain(coverageKey: string, query: AnyQuery, releaseDelayMs?: number): SyncLease {938 let entry = this.entries.get(coverageKey);939 if (!entry) {940 const handle = this.createHandle(query);941 entry = {942 handle,943 leases: [],944 listeners: new Set(),945 unsubscribe: () => {},946 releaseTimer: undefined,947 releaseDeadline: 0,948 };949 entry.unsubscribe = handle.subscribe(() => {950 for (const listener of entry!.listeners) listener();951 });952 this.entries.set(coverageKey, entry);953 } else if (entry.releaseTimer !== undefined) {954 // Cancel the pending teardown — but NOT `releaseDeadline`. The timer is stale (it was armed for955 // an entry that is live again); the deadline is an outstanding claim that must survive, or a956 // remount would silently refresh a window that should only ever decay.957 clearTimeout(entry.releaseTimer);958 entry.releaseTimer = undefined;959 }960 const lease = { id: this.nextLeaseId++, coverageKey, releaseDelayMs: this.resolveDelay(releaseDelayMs) };961 entry.leases.push(lease);962 return lease;963 }964965 release(lease: SyncLease): void {966 const entry = this.entries.get(lease.coverageKey);967 if (!entry) return;968 const index = entry.leases.findIndex((l) => l.id === lease.id);969 if (index < 0) return;970 // Read the delay off the STORED lease, not the caller's handle — a caller can't widen its window971 // after the fact by mutating the object `retain` handed back.972 const [released] = entry.leases.splice(index, 1);973 // Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted974 // must not discard the window this lease just asked for, or the result would depend on unmount975 // order. Recording it can never cause a premature teardown — only the last-out branch below arms976 // a timer.977 entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);978 if (entry.leases.length > 0) return;979 this.scheduleRelease(lease.coverageKey, entry);980 }981982 subscribe(coverageKey: string, listener: () => void): () => void {983 const entry = this.entries.get(coverageKey);984 if (!entry) return () => {};985 entry.listeners.add(listener);986 listener();987 return () => {988 entry.listeners.delete(listener);989 };990 }991992 resultType(coverageKey: string): ResultType {993 return this.entries.get(coverageKey)?.handle.resultType ?? "unknown";994 }995996 size(): number {997 return this.entries.size;998 }9991000 private createHandle(query: AnyQuery): SyncQueryHandle {1001 if (this.store.canRetainRemoteQueries()) return this.store.retainSyncQuery(query);1002 const view = this.store.materialize(query) as AnyView;1003 return {1004 get resultType() {1005 return view.resultType;1006 },1007 subscribe: (listener: () => void) => view.subscribe(listener),1008 release: () => view.destroy(),1009 };1010 }10111012 private resolveDelay(releaseDelayMs: number | undefined): number {1013 return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);1014 }10151016 /** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE1017 * instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of1018 * restarting the window. */1019 private scheduleRelease(coverageKey: string, entry: SyncCacheEntry): void {1020 if (entry.releaseTimer !== undefined) {1021 clearTimeout(entry.releaseTimer);1022 entry.releaseTimer = undefined;1023 }1024 const remaining = entry.releaseDeadline - nowMs();1025 if (remaining <= 0) {1026 this.finalizeRelease(coverageKey, entry);1027 return;1028 }1029 entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry), remaining);1030 }10311032 private finalizeRelease(coverageKey: string, entry: SyncCacheEntry): void {1033 // `entry.leases.length > 0` is the guard that makes a live subscriber safe from a stale timer: a1034 // remount inside the window revives the entry, and the timer armed before it may still fire. Do1035 // not "simplify" this away.1036 if (this.entries.get(coverageKey) !== entry || entry.leases.length > 0) return;1037 entry.releaseTimer = undefined;1038 entry.unsubscribe();1039 entry.handle.release();1040 this.entries.delete(coverageKey);1041 }1042}10431044export class QueryCache {1045 private readonly entries = new Map<string, CacheEntry>();1046 private nextLeaseId = 1;1047 private readonly store: Store<ColsMap>;1048 private readonly defaultReleaseDelayMs: number;10491050 constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {1051 this.store = store;1052 this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);1053 }10541055 /** `releaseDelayMs` overrides the cache default for THIS lease only (see1056 * {@link QueryReleaseOptions}). Ignored for a local-only store, whose views are always torn down1057 * on release. */1058 retain<Q extends AnyQuery>(viewKey: string, query: Q, releaseDelayMs?: number): QueryLease {1059 let entry = this.entries.get(viewKey);1060 if (!entry) {1061 entry = this.store.canRetainRemoteQueries()1062 ? this.createSplitEntry(viewKey, query, releaseDelayMs)1063 : this.createMaterializedEntry(viewKey, query);1064 this.entries.set(viewKey, entry);1065 const lease = entry.leases[0];1066 return { id: lease.id, viewKey };1067 }1068 if (entry.mode === "split") {1069 // Take the new server lease FIRST, then hand the deferred ones back: `handle.retain` mints a1070 // distinct remote qid per call, so overlapping them keeps this query's coverage continuously1071 // live and the backend never re-subscribes.1072 const lease = this.createSplitLease(viewKey, entry.handle, query, releaseDelayMs);1073 entry.leases.push(lease);1074 this.flushPendingReleases(entry);1075 return { id: lease.id, viewKey };1076 }1077 const lease = this.createMaterializedLease(viewKey, query);1078 entry.leases.push(lease);1079 if (!entry.canonical.remote && lease.remote) this.setCanonical(entry, lease);1080 return { id: lease.id, viewKey };1081 }10821083 release(lease: QueryLease): void {1084 const entry = this.entries.get(lease.viewKey);1085 if (!entry) return;1086 const index = entry.leases.findIndex((l) => l.id === lease.id);1087 if (index < 0) return;1088 if (entry.mode === "split") {1089 const [released] = entry.leases.splice(index, 1);1090 // Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted1091 // must not discard the window this lease just asked for, or the result would depend on unmount1092 // order. Recording it can never cause a premature teardown — only the last-out branch below1093 // arms a timer.1094 entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);1095 // A non-last lease never needs its remote lease held: the entry (and its warm local view)1096 // outlives it, and the surviving leases keep the query subscribed.1097 if (entry.leases.length > 0) {1098 released.releaseRemote();1099 return;1100 }1101 // Last one out. Its remote lease is what keeps the warm view fed until the deadline, so it is1102 // deferred. `scheduleSplitRelease` collapses an already-expired deadline into a synchronous1103 // teardown, so both paths funnel through one place.1104 entry.pendingReleases.push(released);1105 this.scheduleSplitRelease(lease.viewKey, entry);1106 return;1107 }1108 const [released] = entry.leases.splice(index, 1);1109 if (entry.canonical.id === released.id) {1110 entry.canonicalUnsubscribe();1111 entry.canonicalUnsubscribe = () => {};1112 }1113 released.view.destroy();1114 if (entry.leases.length === 0) {1115 this.entries.delete(lease.viewKey);1116 return;1117 }1118 if (entry.canonical.id === released.id) this.setCanonical(entry, this.chooseCanonical(entry.leases));1119 }11201121 subscribe(viewKey: string, listener: () => void): () => void {1122 const entry = this.entries.get(viewKey);1123 if (!entry) return () => {};1124 entry.listeners.add(listener);1125 listener();1126 return () => {1127 entry.listeners.delete(listener);1128 };1129 }11301131 snapshot(viewKey: string, one: boolean): unknown {1132 const entry = this.entries.get(viewKey);1133 if (entry) return entry.mode === "split" ? entry.handle.view.data : entry.canonical.view.data;1134 return one ? null : EMPTY_ARRAY;1135 }11361137 /** A query's current {@link ResultType} (from its view), or `unknown` before it is retained. */1138 resultType(viewKey: string): ResultType {1139 const entry = this.entries.get(viewKey);1140 if (!entry) return "unknown";1141 return entry.mode === "split" ? entry.handle.view.resultType : entry.canonical.view.resultType;1142 }11431144 /** The SSR/hydration snapshot for `viewKey` — the store's preloaded/dehydrated seed, read1145 * WITHOUT retaining (the server never opens a subscription). Falls back to empty so a1146 * non-preloaded query renders like an unhydrated one. */1147 serverSnapshot(viewKey: string, one: boolean): unknown {1148 const seed = this.store.seedSnapshot(viewKey);1149 if (!seed) return one ? null : EMPTY_ARRAY;1150 return one ? (seed.rows[0] ?? null) : seed.rows;1151 }11521153 /** The SSR {@link ResultType}: `complete` when a seed exists (server-authoritative first paint),1154 * else `unknown`. */1155 serverResultType(viewKey: string): ResultType {1156 return this.store.seedSnapshot(viewKey) ? "complete" : "unknown";1157 }11581159 size(): number {1160 return this.entries.size;1161 }11621163 private createSplitEntry<Q extends AnyQuery>(1164 viewKey: string,1165 query: Q,1166 releaseDelayMs?: number,1167 ): SplitCacheEntry {1168 const handle = this.store.createCachedQueryView(query) as unknown as CachedQueryView<AnyQuery>;1169 const entry: SplitCacheEntry = {1170 mode: "split",1171 handle,1172 leases: [],1173 pendingReleases: [],1174 releaseTimer: undefined,1175 releaseDeadline: 0,1176 canonicalUnsubscribe: () => {},1177 listeners: new Set(),1178 };1179 entry.leases.push(this.createSplitLease(viewKey, handle, query, releaseDelayMs));1180 entry.canonicalUnsubscribe = handle.view.subscribe(() => {1181 for (const listener of entry.listeners) listener();1182 });1183 return entry;1184 }11851186 private createSplitLease<Q extends AnyQuery>(1187 viewKey: string,1188 handle: CachedQueryView<AnyQuery>,1189 query: Q,1190 releaseDelayMs?: number,1191 ): SplitLease {1192 return {1193 id: this.nextLeaseId++,1194 viewKey,1195 releaseRemote: handle.retain(query),1196 releaseDelayMs: this.resolveDelay(releaseDelayMs),1197 };1198 }11991200 private createMaterializedEntry<Q extends AnyQuery>(viewKey: string, query: Q): MaterializedCacheEntry {1201 const lease = this.createMaterializedLease(viewKey, query);1202 const entry: MaterializedCacheEntry = {1203 mode: "materialized",1204 leases: [lease],1205 canonical: lease,1206 canonicalUnsubscribe: () => {},1207 listeners: new Set(),1208 };1209 this.setCanonical(entry, lease);1210 return entry;1211 }12121213 private createMaterializedLease<Q extends AnyQuery>(viewKey: string, query: Q): MaterializedLease {1214 return {1215 id: this.nextLeaseId++,1216 viewKey,1217 view: this.store.materialize(query) as AnyView,1218 remote: typeof query.name === "string",1219 };1220 }12211222 private chooseCanonical(leases: MaterializedLease[]): MaterializedLease {1223 return leases.find((lease) => lease.remote) ?? leases[0];1224 }12251226 private setCanonical(entry: MaterializedCacheEntry, lease: MaterializedLease): void {1227 entry.canonicalUnsubscribe();1228 entry.canonical = lease;1229 entry.canonicalUnsubscribe = lease.view.subscribe(() => {1230 for (const listener of entry.listeners) listener();1231 });1232 }12331234 private resolveDelay(releaseDelayMs: number | undefined): number {1235 return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);1236 }12371238 /** Hand back every deferred remote lease and cancel the pending teardown, WITHOUT touching1239 * `entry.releaseDeadline`. Called when a retain revives the entry: the new lease covers the query,1240 * so the deferred ones are redundant, and the timer armed for an idle entry is stale. The deadline1241 * is not — it is an outstanding claim, and dropping it here would let a remount silently refresh a1242 * window that must only ever decay. */1243 private flushPendingReleases(entry: SplitCacheEntry): void {1244 if (entry.releaseTimer !== undefined) {1245 clearTimeout(entry.releaseTimer);1246 entry.releaseTimer = undefined;1247 }1248 for (const stale of entry.pendingReleases.splice(0)) stale.releaseRemote();1249 }12501251 /** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE1252 * instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of1253 * restarting the window. */1254 private scheduleSplitRelease(viewKey: string, entry: SplitCacheEntry): void {1255 if (entry.releaseTimer !== undefined) {1256 clearTimeout(entry.releaseTimer);1257 entry.releaseTimer = undefined;1258 }1259 const remaining = entry.releaseDeadline - nowMs();1260 if (remaining <= 0) {1261 this.finalizeSplitRelease(viewKey, entry);1262 return;1263 }1264 entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), remaining);1265 }12661267 private finalizeSplitRelease(viewKey: string, entry: SplitCacheEntry): void {1268 if (this.entries.get(viewKey) !== entry) return;1269 entry.releaseTimer = undefined;1270 const pending = entry.pendingReleases.splice(0);1271 for (const lease of pending) lease.releaseRemote();1272 // The guard that makes a live subscriber safe from a stale timer: a remount inside the window1273 // revives the entry, and the timer armed before it may still fire. Do not "simplify" this away.1274 if (entry.leases.length > 0) return;1275 entry.canonicalUnsubscribe();1276 entry.handle.destroy();1277 this.entries.delete(viewKey);1278 }1279}12801281export function queryCacheKey(query: AnyQuery): string {1282 return describeQuery(query).viewKey;1283}12841285function useRindleContext(): RindleContextValue {1286 const ctx = useContext(RindleContext);1287 if (!ctx) throw new Error("Rindle context is missing. Wrap this tree in <Rindle store={store}>.");1288 return ctx;1289}12901291function describeQuery(query: AnyQuery): QueryDescriptor {1292 const ast = query.ast();1293 const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : null;1294 return {1295 // `viewKey` MUST match the SSR seed key (`@rindle/client`'s `stableKey(ast)`) so1296 // `getServerSnapshot` finds the dehydrated entry — one canonical serializer, shared.1297 viewKey: stableKey(ast),1298 leaseKey: stableKey({ ast, remote }),1299 one: ast.one === true,1300 };1301}1302