Rindle

API index and search · Build metadata

Source snapshot

packages/react/src/index.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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