From c77909c4d8953c608da98858f86ddf6bc3dcd140 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Jul 2026 11:21:41 +0300 Subject: [PATCH 1/4] feat: move async store out of react component --- README.md | 9 +- .../src/content/docs/database/reading-data.md | 5 +- .../docs/database/selectors-reactivity.md | 79 +- .../src/content/docs/integrations/react.md | 5 + .../src/content/docs/start/llm-cheat-sheet.md | 24 + packages/hyperdb-doc/summary.md | 17 +- .../selector/async-selector-store.test.ts | 413 ++++++++++ .../commands/selector/async-selector-store.ts | 609 +++++++++++++++ packages/hyperdb/src/hyperdb/index.ts | 1 + packages/hyperdb/src/react/hooks.test.ts | 63 +- packages/hyperdb/src/react/hooks.ts | 712 +++--------------- 11 files changed, 1280 insertions(+), 657 deletions(-) create mode 100644 packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts create mode 100644 packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts diff --git a/README.md b/README.md index 1f9be57..7e0e42c 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,11 @@ If your whole app state can be loaded into memory at startup, you may not need reads and writes synchronous, so you can use `useSyncSelector`, `useDispatch`, `selectSync`, and `syncDispatch` without promise or cache-miss tradeoffs. For one-off reads that should reuse the selector cache, use `selectCachedSync`, -`selectCachedAsync`, or `selectCachedMaybeAsync`. +`selectCachedAsync`, or `selectCachedMaybeAsync`. For subscriptions outside +React, use `createSelectorStoreSync` / `createCachedSelectorStoreSync` when +selector results are available synchronously. Use `createAsyncSelectorStore` / +`createCachedSelectorStoreAsync` when a `HybridDB`/async driver run may need to +resolve a promise and expose query state. `SubscribableDB` also exposes lifecycle hooks: mutation hooks such as `afterInsert`, `afterUpsert`, `afterDelete`, and `afterChange`, plus `afterScan` @@ -243,7 +247,8 @@ export function App({ db }: { db: SubscribableDB }) { `useAsyncSelector` attempts the selector once for its initial snapshot. If the run finishes synchronously, that value is visible immediately; if it returns a promise, the hook shows `defaultValue`, `initialData`, or `placeholderData` until -that same promise resolves. +that same promise resolves. The hook uses `createCachedSelectorStoreAsync` +internally, so the same async subscription behavior is available without React. ## Entry points diff --git a/packages/hyperdb-doc/src/content/docs/database/reading-data.md b/packages/hyperdb-doc/src/content/docs/database/reading-data.md index 5fde0be..5bb2dba 100644 --- a/packages/hyperdb-doc/src/content/docs/database/reading-data.md +++ b/packages/hyperdb-doc/src/content/docs/database/reading-data.md @@ -210,5 +210,6 @@ In that example, `projectDoneTasks` should use an index such as list in JavaScript. Composition keeps code ergonomic, but each selector should still choose the index that matches the data it needs. -To run selectors, cache selector results, or create subscribed selector stores, -see [Selectors & Reactivity](/database/selectors-reactivity/#running-selectors). +To run selectors, cache selector results, or create subscribed sync/async +selector stores, see +[Selectors & Reactivity](/database/selectors-reactivity/#running-selectors). diff --git a/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md b/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md index 4a3c434..e811501 100644 --- a/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md +++ b/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md @@ -51,16 +51,18 @@ const tasks = await Promise.resolve(tasksOrPromise); HyperDB exports these selector runners and store creators: -| Helper | Use | -| ------------------------------- | -------------------------------------------------------------------- | -| `selectSync` | Run a selector once with sync drivers | -| `selectAsync` | Run a selector once with async drivers and `HybridDB` | -| `selectMaybeAsync` | Return a value or a `Promise`, depending on whether execution yields | -| `selectCachedSync` | Run or reuse the root selector cache synchronously | -| `selectCachedAsync` | Async cache-aware selector read | -| `selectCachedMaybeAsync` | Cache-aware read that may return a value or a `Promise` | -| `createSelectorStoreSync` | Create a sync subscribed store with `subscribe()` / `getSnapshot()` | -| `createCachedSelectorStoreSync` | Create a sync subscribed store backed by the root selector cache | +| Helper | Use | +| -------------------------------- | -------------------------------------------------------------------- | +| `selectSync` | Run a selector once with sync drivers | +| `selectAsync` | Run a selector once with async drivers and `HybridDB` | +| `selectMaybeAsync` | Return a value or a `Promise`, depending on whether execution yields | +| `selectCachedSync` | Run or reuse the root selector cache synchronously | +| `selectCachedAsync` | Async cache-aware selector read | +| `selectCachedMaybeAsync` | Cache-aware read that may return a value or a `Promise` | +| `createSelectorStoreSync` | Create a sync subscribed store with `subscribe()` / `getSnapshot()` | +| `createCachedSelectorStoreSync` | Create a sync subscribed store backed by the root selector cache | +| `createAsyncSelectorStore` | Create an uncached async subscribed store with query state snapshots | +| `createCachedSelectorStoreAsync` | Create an async subscribed store backed by the root selector cache | Use `createSelectorStoreSync` when you want a small synchronous external store for one selector run: @@ -77,6 +79,63 @@ const unsubscribe = store.subscribe(() => { }); ``` +### Async selector stores + +Sync stores expose raw selector data because `getSnapshot()` can return the +selector result immediately. Async stores expose query state because the first +result may require a promise. + +Use `createAsyncSelectorStore` when you want an isolated async subscription that +does not read or populate the root selector cache. Use +`createCachedSelectorStoreAsync` when repeated reads for the same selector and +args should share the root selector cache, matching `useAsyncSelector`. + +```ts +import { + createAsyncSelectorStore, + createCachedSelectorStoreAsync, +} from "@will-be-done/hyperdb"; + +const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, + defaultValue: [], +}); + +const unsubscribe = store.subscribe(() => { + const snapshot = store.getSnapshot(); + if (snapshot.status === "success") { + console.log(snapshot.data); + } +}); + +const latest = await store.refetch({ throwOnError: true }); +unsubscribe(); +store.destroy(); +``` + +Snapshots include `status`, `fetchStatus`, `data`, timestamps, error and +failure counters, placeholder flags, a `promise` for the current run, and the +same loading/refetching booleans returned by `useAsyncSelector`. The first +`getSnapshot()` attempts the maybe-async selector run immediately: if it returns +synchronously, the snapshot is already `"success"`; otherwise it is pending +until the promise resolves. + +The store subscribes directly to `SubscribableDB`, records the scanned ranges +from each run, skips irrelevant DB changes, collapses overlapping reruns, and +ignores late async results after `destroy()` or unsubscribe. `useAsyncSelector` +uses this cached async store internally, so the same behavior is available +outside React. + +The four store helpers form a matrix: + +| Helper | Execution | Root selector cache | +| -------------------------------- | --------- | ------------------- | +| `createSelectorStoreSync` | sync | no | +| `createCachedSelectorStoreSync` | sync | yes | +| `createAsyncSelectorStore` | async | no | +| `createCachedSelectorStoreAsync` | async | yes | + ## Range tracking When a selector runs, the runtime records every index range it scans: table, diff --git a/packages/hyperdb-doc/src/content/docs/integrations/react.md b/packages/hyperdb-doc/src/content/docs/integrations/react.md index fdc0bb7..d0885fd 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -90,6 +90,11 @@ visible immediately; if it returns a promise, the hook shows `defaultValue`, `initialData`, or `placeholderData` until the same promise resolves. It does not read a cached selector snapshot during render. +`useAsyncSelector` is built on the framework-agnostic +`createCachedSelectorStoreAsync` from `@will-be-done/hyperdb`. Use that store +directly when you need the same async selector subscription behavior outside +React. + Options: | Option | Description | diff --git a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md index 3b4a265..6cb812e 100644 --- a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md +++ b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md @@ -56,7 +56,10 @@ import { SubscribableDB, asyncDispatch, createAction, + createAsyncSelectorStore, + createCachedSelectorStoreAsync, createSelector, + createCachedSelectorStoreSync, defineTable, deleteRows, execAsync, @@ -170,6 +173,27 @@ const cachedRows = await Promise.resolve( ); ``` +For subscriptions outside React, use a selector store. Pick sync vs async by +whether the selector may yield a promise, and cached vs uncached by whether the +root selector result should be shared: + +```ts +const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, + defaultValue: [], +}); + +const unsubscribe = store.subscribe(() => { + const snapshot = store.getSnapshot(); + if (snapshot.status === "success") console.log(snapshot.data); +}); +``` + +Store helpers: `createSelectorStoreSync` (sync uncached), +`createCachedSelectorStoreSync` (sync cached), `createAsyncSelectorStore` +(async uncached), and `createCachedSelectorStoreAsync` (async cached). + ## Actions And Writes Create one shared action builder and reuse it. Actions are generator functions diff --git a/packages/hyperdb-doc/summary.md b/packages/hyperdb-doc/summary.md index 6807568..03d5297 100644 --- a/packages/hyperdb-doc/summary.md +++ b/packages/hyperdb-doc/summary.md @@ -75,17 +75,19 @@ check the matching docs below and also check the root `README.md`. - `src/content/docs/database/reading-data.md`: Selector and query-builder guide. Covers selector object fields, `selectFrom`, immutable query builders, `where` comparisons, OR queries with `or(...)` or arrays, ordering, limits, - many-row results, `first()` and `firstOr()`, and composing selectors. + many-row results, `first()` and `firstOr()`, composing selectors, and links + to sync and async selector store APIs. - `src/content/docs/database/indexes.md`: Index behavior and valid query shapes. Covers declaring B-tree and hash indexes, built-in `byId`, equality, range, ordering, composite-key support, indexable value rules, equality-prefix and trailing-range rules, query-builder validation errors, and index ordering. - `src/content/docs/database/selectors-reactivity.md`: Reactive selector cache. Covers running selectors with `selectSync`/`selectAsync`, cached selector - reads, range tracking, cached selector stores, `createCachedSelectorStoreSync`, - garbage collection, selector memoization controls (`root` and `selfChild`), - subscriptions, revisions, and practical guidance for writing selectors that - invalidate precisely. + reads, range tracking, selector stores, `createSelectorStoreSync`, + `createCachedSelectorStoreSync`, `createAsyncSelectorStore`, + `createCachedSelectorStoreAsync`, garbage collection, selector memoization + controls (`root` and `selfChild`), subscriptions, revisions, and practical + guidance for writing selectors that invalidate precisely. - `src/content/docs/database/writing-data.md`: Actions and mutations. Covers defining actions, `insert`, `upsert`, `deleteRows`, dispatching with `syncDispatch`/`asyncDispatch`, why selectors cannot write, transaction @@ -108,8 +110,9 @@ check the matching docs below and also check the root `README.md`. - `src/content/docs/integrations/react.md`: React integration guide. Covers `DBProvider`, `useDB`, `useSyncSelector`, `useAsyncSelector`, `useDispatch`, `useAsyncDispatch`, `useSelectSync`, `useSelectAsync`, selector options, default - values, `enabled`, the React Query-style async selector result, and the full - hook reference table. + values, `enabled`, the React Query-style async selector result, its + `createCachedSelectorStoreAsync` foundation, and the full hook reference + table. - `src/content/docs/integrations/devtools.md`: Devtool and tracing guide. Covers adding `HyperDBDevtools`, devtool tabs and trace inspection, component props, embedded panel option, trace contents, cache-hit traces, `HybridDB` source diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts new file mode 100644 index 0000000..e88b378 --- /dev/null +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from "vitest"; +import { DB, execSync } from "../../db"; +import { unwrap } from "../async"; +import { BptreeInmemDriver } from "../../drivers/inmemory/bptree-inmem-driver"; +import { SubscribableDB } from "../../runtime/subscribable-db"; +import { defineTable } from "../../schema/table"; +import { v } from "../../schema/values"; +import { selectFrom } from "./builder"; +import { + createAsyncSelectorStore, + createCachedSelectorStoreAsync, +} from "./async-selector-store"; +import { createSelector } from "./selector"; + +const selector = createSelector(); + +const createTestDB = ( + ...tables: Parameters[0] +) => { + const db = new SubscribableDB(new DB(new BptreeInmemDriver())); + execSync(db.loadTables(tables)); + return db; +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, reject, resolve }; +}; + +const flushPromises = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +describe("createCachedSelectorStoreAsync", () => { + it("returns an immediate success snapshot on first getSnapshot when the selector resolves synchronously", () => { + const tasksTable = defineTable("asyncStoreSyncFirstTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const db = createTestDB(tasksTable); + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "First" }, + ]), + ); + const projectTasks = selector({ + name: "asyncStoreSyncFirstProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + return yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + }, + }); + let reachedPromiseTick = false; + void Promise.resolve().then(() => { + reachedPromiseTick = true; + }); + + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + defaultValue: [], + }); + const snapshot = store.getSnapshot(); + + expect(reachedPromiseTick).toBe(false); + expect(snapshot.status).toBe("success"); + expect(snapshot.fetchStatus).toBe("idle"); + expect(snapshot.data).toEqual([ + { id: "task-1", projectId: "project-1", title: "First" }, + ]); + + store.destroy(); + }); + + it("returns pending first and then success when the selector resolves asynchronously", async () => { + const gate = deferred(); + const db = createTestDB(); + const asyncValue = selector({ + name: "asyncStorePendingValue", + args: {}, + *handler() { + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + }); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: undefined, + fetchStatus: "fetching", + status: "pending", + }), + ); + + gate.resolve("ready"); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: "ready", + fetchStatus: "idle", + status: "success", + }), + ); + + store.destroy(); + }); + + it("reruns subscribed selectors only when DB changes overlap recorded ranges", () => { + const tasksTable = defineTable("asyncStoreRangeTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const db = createTestDB(tasksTable); + const projectTasks = selector({ + name: "asyncStoreRangeProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + return yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + defaultValue: [], + }); + + expect(store.getSnapshot().data).toEqual([]); + const subscriber = vi.fn(); + const unsubscribe = store.subscribe(subscriber); + + execSync( + db.insert(tasksTable, [ + { id: "task-2", projectId: "project-2", title: "Ignored" }, + ]), + ); + + expect(subscriber).not.toHaveBeenCalled(); + expect(store.getSnapshot().data).toEqual([]); + + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "Included" }, + ]), + ); + + expect(subscriber).toHaveBeenCalled(); + expect(store.getSnapshot().data).toEqual([ + { id: "task-1", projectId: "project-1", title: "Included" }, + ]); + + unsubscribe(); + store.destroy(); + }); + + it("collapses overlapping reruns and publishes the latest result", async () => { + const tasksTable = defineTable("asyncStoreOverlapTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const db = createTestDB(tasksTable); + const firstGate = deferred(); + const secondGate = deferred(); + let runCount = 0; + const projectTasks = selector({ + name: "asyncStoreOverlapProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + runCount++; + const rows = yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + + if (runCount === 1) { + yield* unwrap(firstGate.promise); + } else if (runCount === 2) { + yield* unwrap(secondGate.promise); + } + + return rows; + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + defaultValue: [], + }); + const subscriber = vi.fn(); + const unsubscribe = store.subscribe(subscriber); + + expect(store.getSnapshot().status).toBe("pending"); + + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "First" }, + ]), + ); + execSync( + db.insert(tasksTable, [ + { id: "task-2", projectId: "project-1", title: "Second" }, + ]), + ); + + firstGate.resolve(); + await flushPromises(); + + expect(runCount).toBe(2); + expect(store.getSnapshot().status).toBe("pending"); + + secondGate.resolve(); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [ + { id: "task-1", projectId: "project-1", title: "First" }, + { id: "task-2", projectId: "project-1", title: "Second" }, + ], + status: "success", + }), + ); + expect(subscriber).toHaveBeenCalled(); + + unsubscribe(); + store.destroy(); + }); + + it("ignores late async results after destroy", async () => { + const gate = deferred(); + const db = createTestDB(); + const asyncValue = selector({ + name: "asyncStoreLateValue", + args: {}, + *handler() { + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + }); + const subscriber = vi.fn(); + store.subscribe(subscriber); + + expect(store.getSnapshot().status).toBe("pending"); + store.destroy(); + gate.resolve("late"); + await flushPromises(); + + expect(subscriber).not.toHaveBeenCalledWith( + expect.objectContaining({ data: "late" }), + ); + }); + + it("refetch resolves with a fresh result state", async () => { + const tasksTable = defineTable("asyncStoreRefetchTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const db = createTestDB(tasksTable); + const projectTasks = selector({ + name: "asyncStoreRefetchProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + return yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + initialData: [], + subscribed: false, + }); + + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "Fresh" }, + ]), + ); + const result = await store.refetch(); + + expect(result).toEqual( + expect.objectContaining({ + data: [{ id: "task-1", projectId: "project-1", title: "Fresh" }], + status: "success", + }), + ); + + store.destroy(); + }); + + it("refetch rejects selector errors when throwOnError is true", async () => { + const error = new Error("selector failed"); + const db = createTestDB(); + const failingSelector = selector({ + name: "asyncStoreFailingSelector", + args: {}, + *handler() { + throw error; + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: failingSelector, + args: {}, + subscribed: false, + }); + + await expect(store.refetch({ throwOnError: true })).rejects.toBe(error); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + error, + status: "error", + }), + ); + + store.destroy(); + }); + + it("uses the cached maybe-async selector path for async stores", () => { + const db = { + getRevision: vi.fn(() => 0), + subscribe: vi.fn(() => vi.fn()), + } as unknown as SubscribableDB; + const cachedRunner = vi.fn((_db, input) => { + input.selectRangeCmds.push({ table: "cached-range" }); + return "cached"; + }); + const store = createCachedSelectorStoreAsync( + db, + { + selector: selector({ + name: "asyncStoreHybridSelector", + args: {}, + *handler() { + return "unused"; + }, + }), + args: {}, + }, + { + runCachedSelectorMaybeAsync: cachedRunner, + }, + ); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: "cached", + status: "success", + }), + ); + expect(cachedRunner).toHaveBeenCalledTimes(1); + + store.destroy(); + }); + + it("uses the uncached maybe-async selector path for uncached async stores", () => { + const db = { + getRevision: vi.fn(() => 0), + subscribe: vi.fn(() => vi.fn()), + } as unknown as SubscribableDB; + const uncachedRunner = vi.fn((_db, input) => { + input.selectRangeCmds.push({ table: "uncached-range" }); + return "uncached"; + }); + const store = createAsyncSelectorStore( + db, + { + selector: selector({ + name: "asyncStoreUncachedSelector", + args: {}, + *handler() { + return "unused"; + }, + }), + args: {}, + }, + { + selectMaybeAsync: uncachedRunner, + }, + ); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: "uncached", + status: "success", + }), + ); + expect(uncachedRunner).toHaveBeenCalledTimes(1); + + store.destroy(); + }); +}); diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts new file mode 100644 index 0000000..368a90d --- /dev/null +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts @@ -0,0 +1,609 @@ +import type { Op, SubscribableDB } from "../../runtime/subscribable-db"; +import type { SelectRangeCmd } from "./commands"; +import { + runCachedSelectorMaybeAsync, + selectMaybeAsync, + type AnyObjectSelector, + type SelectorArgs, + type SelectorReturn, +} from "./selector"; +import { isNeedToRerunRange } from "./selector-memo"; + +export type AsyncSelectorStatus = "pending" | "error" | "success"; +export type AsyncSelectorFetchStatus = "fetching" | "paused" | "idle"; + +export type AsyncSelectorRefetchOptions = { + throwOnError?: boolean; + cancelRefetch?: boolean; +}; + +export type AsyncSelectorStateLike = { + data: TData | undefined; + dataUpdatedAt: number; + error: TError | null; + errorUpdatedAt: number; + errorUpdateCount: number; + failureCount: number; + failureReason: TError | null; + fetchStatus: AsyncSelectorFetchStatus; + isError: boolean; + isFetched: boolean; + isFetchedAfterMount: boolean; + isFetching: boolean; + isInitialLoading: boolean; + isLoading: boolean; + isLoadingError: boolean; + isPaused: boolean; + isPending: boolean; + isPlaceholderData: boolean; + isRefetchError: boolean; + isRefetching: boolean; + isStale: boolean; + isSuccess: boolean; + isEnabled: boolean; + promise: Promise; + status: AsyncSelectorStatus; +}; + +export type AsyncSelectorStoreInput = { + selector: TSelector; + args: SelectorArgs; + enabled?: boolean; + subscribed?: boolean; + defaultValue?: SelectorReturn; + initialData?: SelectorReturn | (() => SelectorReturn); + initialDataUpdatedAt?: number | (() => number | undefined); + placeholderData?: + | SelectorReturn + | (( + previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => SelectorReturn); + staleTime?: number; + gcTime?: number; +}; + +export type AsyncSelectorStore = { + subscribe(callback: () => void): () => void; + getSnapshot(): AsyncSelectorStateLike; + refetch( + options?: AsyncSelectorRefetchOptions, + ): Promise>; + destroy(): void; +}; + +export type CachedSelectorStoreAsyncInput = + AsyncSelectorStoreInput; + +export type CachedSelectorStoreAsync< + TData, + TError = unknown, +> = AsyncSelectorStore; + +export type AsyncSelectorStoreDeps = { + runCachedSelectorMaybeAsync: typeof runCachedSelectorMaybeAsync; + isNeedToRerunRange: typeof isNeedToRerunRange; +}; + +export type CachedSelectorStoreAsyncDeps = AsyncSelectorStoreDeps; + +type AsyncSelectorStoreInternalDeps = AsyncSelectorStoreDeps & { + selectMaybeAsync: typeof selectMaybeAsync; +}; + +type AsyncSelectorStateCore = { + data: TData | undefined; + dataUpdatedAt: number; + error: TError | null; + errorUpdatedAt: number; + errorUpdateCount: number; + failureCount: number; + failureReason: TError | null; + fetchStatus: AsyncSelectorFetchStatus; + isFetched: boolean; + isFetchedAfterMount: boolean; + isPlaceholderData: boolean; + promise: Promise; + status: AsyncSelectorStatus; +}; + +const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function"; + +const hasOwn = ( + object: TObject, + key: TKey, +): object is TObject & Record => + Object.prototype.hasOwnProperty.call(object, key); + +const resolveValue = (value: TValue | (() => TValue)): TValue => + typeof value === "function" ? (value as () => TValue)() : value; + +const createPromiseController = () => { + let resolve!: (value: TValue) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + promise.catch(() => undefined); + + return { promise, reject, resolve }; +}; + +const readInitialDataUpdatedAt = ( + value: number | (() => number | undefined) | undefined, +) => { + if (value === undefined) return Date.now(); + + return (typeof value === "function" ? value() : value) ?? Date.now(); +}; + +const createInitialState = ( + input: { + defaultValue?: TData; + initialData?: TData | (() => TData); + initialDataUpdatedAt?: number | (() => number | undefined); + placeholderData?: + | TData + | ((previousValue: TData | undefined, previousQuery: undefined) => TData); + }, + options: { + canAutoFetch: boolean; + promise: Promise; + }, +): AsyncSelectorStateCore => { + if (hasOwn(input, "initialData") && input.initialData !== undefined) { + const data = resolveValue(input.initialData as TData | (() => TData)); + + return { + data, + dataUpdatedAt: readInitialDataUpdatedAt(input.initialDataUpdatedAt), + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + failureCount: 0, + failureReason: null, + fetchStatus: options.canAutoFetch ? "fetching" : "idle", + isFetched: true, + isFetchedAfterMount: false, + isPlaceholderData: false, + promise: options.promise, + status: "success", + }; + } + + let data: TData | undefined; + let isPlaceholderData = false; + + if (hasOwn(input, "placeholderData") && input.placeholderData !== undefined) { + const placeholderData = input.placeholderData as + | TData + | ((previousValue: TData | undefined, previousQuery: undefined) => TData); + data = + typeof placeholderData === "function" + ? ( + placeholderData as ( + previousValue: TData | undefined, + previousQuery: undefined, + ) => TData + )(undefined, undefined) + : placeholderData; + isPlaceholderData = true; + } else if ( + hasOwn(input, "defaultValue") && + input.defaultValue !== undefined + ) { + data = input.defaultValue as TData; + isPlaceholderData = true; + } + + return { + data, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + failureCount: 0, + failureReason: null, + fetchStatus: options.canAutoFetch ? "fetching" : "idle", + isFetched: false, + isFetchedAfterMount: false, + isPlaceholderData, + promise: options.promise, + status: "pending", + }; +}; + +const buildSnapshot = ( + queryState: AsyncSelectorStateCore, + options: { enabled: boolean; staleTime: number }, +): AsyncSelectorStateLike => { + const isPending = queryState.status === "pending"; + const isError = queryState.status === "error"; + const isFetching = queryState.fetchStatus === "fetching"; + const isPaused = queryState.fetchStatus === "paused"; + const isStale = + queryState.status !== "success" || + Date.now() > queryState.dataUpdatedAt + options.staleTime; + + return { + ...queryState, + isEnabled: options.enabled, + isError, + isFetching, + isInitialLoading: isFetching && isPending, + isLoading: isFetching && isPending, + isLoadingError: isError && queryState.dataUpdatedAt === 0, + isPaused, + isPending, + isRefetchError: isError && queryState.dataUpdatedAt > 0, + isRefetching: isFetching && !isPending, + isStale, + isSuccess: queryState.status === "success", + }; +}; + +const createAsyncSelectorStoreInternal = < + TSelector extends AnyObjectSelector, + TError = unknown, +>( + db: SubscribableDB, + input: AsyncSelectorStoreInput, + deps: Partial, + options: { cached: boolean }, +): AsyncSelectorStore, TError> => { + type TData = SelectorReturn; + + const enabled = input.enabled !== false; + const subscribed = input.subscribed !== false; + const canAutoFetch = enabled && subscribed; + const staleTime = input.staleTime ?? 0; + const storeDeps: AsyncSelectorStoreInternalDeps = { + runCachedSelectorMaybeAsync, + selectMaybeAsync, + isNeedToRerunRange, + ...deps, + }; + const listeners = new Set<() => void>(); + let destroyed = false; + let started = false; + let isRunning = false; + let rerunRequested = false; + let inFlightResult: Promise> | null = + null; + let dbUnsubscribe: (() => void) | undefined; + let runToken = 0; + let runRevision = db.getRevision(); + let selectRangeCmds: SelectRangeCmd[] = []; + let staleTimer: ReturnType | undefined; + const initialPromiseController = createPromiseController(); + let queryState = createInitialState(input, { + canAutoFetch, + promise: initialPromiseController.promise, + }); + let snapshot = buildSnapshot(queryState, { enabled, staleTime }); + + const clearStaleTimer = () => { + if (!staleTimer) return; + clearTimeout(staleTimer); + staleTimer = undefined; + }; + + const notify = () => { + for (const listener of listeners) { + listener(); + } + }; + + const scheduleStaleTimer = () => { + clearStaleTimer(); + + if ( + destroyed || + queryState.status !== "success" || + staleTime <= 0 || + snapshot.isStale + ) { + return; + } + + const staleAt = queryState.dataUpdatedAt + staleTime; + const delay = staleAt - Date.now() + 1; + staleTimer = setTimeout( + () => { + snapshot = buildSnapshot(queryState, { enabled, staleTime }); + notify(); + }, + Math.max(delay, 0), + ); + (staleTimer as { unref?: () => void }).unref?.(); + }; + + const setQueryState = ( + updater: ( + previous: AsyncSelectorStateCore, + ) => AsyncSelectorStateCore, + ) => { + if (destroyed) return; + + const next = updater(queryState); + if (next === queryState) return; + + queryState = next; + snapshot = buildSnapshot(queryState, { enabled, staleTime }); + scheduleStaleTimer(); + notify(); + }; + + const runSelector = (cmds: SelectRangeCmd[]) => { + if (!options.cached) { + return storeDeps.selectMaybeAsync(db, { + selector: input.selector, + args: input.args, + selectRangeCmds: cmds, + }); + } + + return storeDeps.runCachedSelectorMaybeAsync(db, { + selector: input.selector, + args: input.args, + selectRangeCmds: cmds, + gcTime: input.gcTime, + }); + }; + + const stopDBSubscription = () => { + dbUnsubscribe?.(); + dbUnsubscribe = undefined; + }; + + const run = ( + options?: AsyncSelectorRefetchOptions, + ): Promise> => { + started = true; + + if (isRunning) { + rerunRequested = true; + + if (options?.cancelRefetch === false && inFlightResult !== null) { + return inFlightResult; + } + + return inFlightResult ?? Promise.resolve(snapshot); + } + + const currentToken = ++runToken; + isRunning = true; + runRevision = db.getRevision(); + const promiseController = createPromiseController(); + + const resultPromise = new Promise>( + (resolve, reject) => { + const isCurrentRun = () => !destroyed && currentToken === runToken; + const resolveCurrentSnapshot = () => { + resolve(snapshot); + }; + const finishSuccess = (value: TData, cmds: SelectRangeCmd[]) => { + if (!isCurrentRun()) return; + + selectRangeCmds = cmds; + setQueryState((previous) => ({ + ...previous, + data: value, + dataUpdatedAt: Date.now(), + error: null, + failureCount: 0, + failureReason: null, + fetchStatus: "idle", + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + status: "success", + })); + promiseController.resolve(value); + isRunning = false; + inFlightResult = null; + resolveCurrentSnapshot(); + + if (rerunRequested && !destroyed) { + void run(); + } + }; + const finishError = (error: unknown) => { + if (!isCurrentRun()) return; + + const typedError = error as TError; + setQueryState((previous) => ({ + ...previous, + error: typedError, + errorUpdatedAt: Date.now(), + errorUpdateCount: previous.errorUpdateCount + 1, + failureCount: previous.failureCount + 1, + failureReason: typedError, + fetchStatus: "idle", + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + status: "error", + })); + promiseController.reject(error); + isRunning = false; + inFlightResult = null; + + if (options?.throwOnError === true) { + reject(error); + return; + } + + resolveCurrentSnapshot(); + }; + const runOnce = () => { + try { + do { + rerunRequested = false; + const cmds: SelectRangeCmd[] = []; + const value = runSelector(cmds); + + if (isPromiseLike(value)) { + void Promise.resolve(value).then( + (resolvedValue) => { + if (!isCurrentRun()) { + return; + } + + if (rerunRequested) { + runOnce(); + return; + } + + finishSuccess(resolvedValue, cmds); + }, + (error: unknown) => { + finishError(error); + }, + ); + return; + } + + if (!isCurrentRun()) { + isRunning = false; + return; + } + + if (rerunRequested) continue; + + finishSuccess(value, cmds); + return; + } while (rerunRequested); + } catch (error) { + finishError(error); + } + }; + + setQueryState((previous) => ({ + ...previous, + fetchStatus: "fetching", + promise: promiseController.promise, + status: + previous.status === "success" || previous.dataUpdatedAt > 0 + ? previous.status + : "pending", + })); + + runOnce(); + }, + ); + + if (isRunning) { + inFlightResult = resultPromise; + } + + return resultPromise; + }; + + const ensureStarted = () => { + if (started || !canAutoFetch || destroyed) return; + + void run(); + }; + + const ensureDBSubscription = () => { + if (dbUnsubscribe || !canAutoFetch || destroyed) return; + + dbUnsubscribe = db.subscribe((ops: Op[]) => { + if (isRunning) { + rerunRequested = true; + return; + } + + if ( + selectRangeCmds.length > 0 && + !storeDeps.isNeedToRerunRange(selectRangeCmds, ops) + ) { + return; + } + + void run(); + }); + }; + + return { + subscribe: (callback) => { + listeners.add(callback); + ensureDBSubscription(); + ensureStarted(); + + if (canAutoFetch && runRevision !== db.getRevision()) { + if (isRunning) { + rerunRequested = true; + } else { + void run(); + } + } + + return () => { + listeners.delete(callback); + + if (listeners.size > 0) return; + + stopDBSubscription(); + runToken++; + isRunning = false; + inFlightResult = null; + rerunRequested = false; + }; + }, + getSnapshot: () => { + ensureStarted(); + return snapshot; + }, + refetch: (options) => run(options), + destroy: () => { + if (destroyed) return; + + destroyed = true; + runToken++; + isRunning = false; + inFlightResult = null; + rerunRequested = false; + stopDBSubscription(); + clearStaleTimer(); + listeners.clear(); + }, + }; +}; + +export function createAsyncSelectorStore< + TSelector extends AnyObjectSelector, + TError = unknown, +>( + db: SubscribableDB, + input: AsyncSelectorStoreInput, + deps: Partial< + Pick< + AsyncSelectorStoreInternalDeps, + "selectMaybeAsync" | "isNeedToRerunRange" + > + > = {}, +): AsyncSelectorStore, TError> { + return createAsyncSelectorStoreInternal(db, input, deps, { + cached: false, + }); +} + +export function createCachedSelectorStoreAsync< + TSelector extends AnyObjectSelector, + TError = unknown, +>( + db: SubscribableDB, + input: CachedSelectorStoreAsyncInput, + deps: Partial = {}, +): CachedSelectorStoreAsync, TError> { + return createAsyncSelectorStoreInternal(db, input, deps, { + cached: true, + }); +} diff --git a/packages/hyperdb/src/hyperdb/index.ts b/packages/hyperdb/src/hyperdb/index.ts index a2180ec..408ece7 100644 --- a/packages/hyperdb/src/hyperdb/index.ts +++ b/packages/hyperdb/src/hyperdb/index.ts @@ -2,6 +2,7 @@ export * from "./db"; export * from "./commands/action/builders"; export * from "./commands/selector/builder"; export * from "./commands/selector/selector"; +export * from "./commands/selector/async-selector-store"; export { noop } from "./commands/async"; export type { HyperDB, diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 2025e94..56aae2c 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -26,10 +26,20 @@ const mocks = { stableSerializeSelectorArgs: vi.fn(), }; +function addCleanup(cleanup: (() => void) | undefined) { + if (!cleanup) return; + + const previous = mocks.cleanup; + mocks.cleanup = () => { + previous?.(); + cleanup(); + }; +} + const fakeReactHooks = { useCallback: vi.fn((cb) => cb), useEffect: vi.fn((effect) => { - mocks.cleanup = effect(); + addCleanup(effect()); }), useMemo: vi.fn((factory) => factory()), useRef: vi.fn((initial) => { @@ -41,7 +51,15 @@ const fakeReactHooks = { typeof initial === "function" ? initial() : initial, mocks.setState, ]), - useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()), + useSyncExternalStore: vi.fn((subscribe, getSnapshot) => { + addCleanup( + subscribe(() => { + mocks.setState(getSnapshot()); + }), + ); + + return getSnapshot(); + }), }; let restoreHookDeps: (() => void) | undefined; @@ -168,7 +186,7 @@ describe("useAsyncSelector", () => { const secondCmd = { table: "tasks", range: "second" }; let runCount = 0; - mocks.selectMaybeAsync.mockImplementation((_db, input) => { + mocks.selectCachedMaybeAsync.mockImplementation((_db, input) => { const cmds = input.selectRangeCmds; runCount++; if (runCount === 1) { @@ -187,7 +205,7 @@ describe("useAsyncSelector", () => { args: {}, }); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.db.subscribe).toHaveBeenCalledTimes(1); mocks.setState.mockClear(); @@ -195,13 +213,13 @@ describe("useAsyncSelector", () => { mocks.db.emit([{ id: "op-2" }]); mocks.db.emit([{ id: "op-3" }]); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); first.resolve("stale"); await flushPromises(); expect(mocks.setState).not.toHaveBeenCalled(); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(2); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(2); second.resolve("latest"); await flushPromises(); @@ -213,8 +231,6 @@ describe("useAsyncSelector", () => { status: "success", }), ); - expect(mocks.refs[2].current).toEqual([secondCmd]); - const ignoredOps = [{ id: "ignored" }]; mocks.isNeedToRerunRange.mockReturnValue(false); @@ -224,14 +240,14 @@ describe("useAsyncSelector", () => { [secondCmd], ignoredOps, ); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(2); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(2); }); it("ignores a pending async selector result after unmount cleanup", async () => { const pending = deferred(); const cmd = { table: "tasks", range: "pending" }; - mocks.selectMaybeAsync.mockImplementation((_db, input) => { + mocks.selectCachedMaybeAsync.mockImplementation((_db, input) => { input.selectRangeCmds.push(cmd); return pending.promise; }); @@ -243,7 +259,6 @@ describe("useAsyncSelector", () => { args: {}, }); - const selectRangeCmdsRef = mocks.refs[2]; expect(mocks.db.subscriberCount()).toBe(1); mocks.setState.mockClear(); @@ -255,7 +270,6 @@ describe("useAsyncSelector", () => { await flushPromises(); expect(mocks.setState).not.toHaveBeenCalled(); - expect(selectRangeCmdsRef.current).toEqual([]); }); it("returns default value for disabled async selectors without running or subscribing", () => { @@ -275,6 +289,7 @@ describe("useAsyncSelector", () => { expect(result.fetchStatus).toBe("idle"); expect(result.isEnabled).toBe(false); expect(mocks.stableSerializeSelectorArgs).not.toHaveBeenCalled(); + expect(mocks.selectCachedMaybeAsync).not.toHaveBeenCalled(); expect(mocks.selectMaybeAsync).not.toHaveBeenCalled(); expect(mocks.db.subscribe).not.toHaveBeenCalled(); }); @@ -319,7 +334,6 @@ describe("useAsyncSelector", () => { await flushPromises(); - expect(mocks.refs[2].current).toEqual([cmd]); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ data: ["fresh"], @@ -377,7 +391,7 @@ describe("useAsyncSelector", () => { const freshCmd = { table: "tasks", range: "fresh" }; let runCount = 0; - mocks.selectMaybeAsync.mockImplementation((_db, input) => { + mocks.selectCachedMaybeAsync.mockImplementation((_db, input) => { const cmds = input.selectRangeCmds; runCount++; @@ -399,11 +413,12 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(2); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); stale.resolve(["stale"]); await flushPromises(); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(2); expect(mocks.setState).not.toHaveBeenLastCalledWith( expect.objectContaining({ data: ["stale"], @@ -419,7 +434,6 @@ describe("useAsyncSelector", () => { status: "success", }), ); - expect(mocks.refs[2].current).toEqual([freshCmd]); }); it("ignores a late HybridDB preload after args change cleanup", async () => { @@ -542,7 +556,7 @@ describe("useAsyncSelector", () => { const selector = vi.fn(function* selector() { return ["unused"]; }); - mocks.selectMaybeAsync.mockReturnValue(pending.promise); + mocks.selectCachedMaybeAsync.mockReturnValue(pending.promise); const result = useAsyncSelector({ selector, @@ -560,7 +574,7 @@ describe("useAsyncSelector", () => { expect(refetchResult.status).toBe("success"); expect(refetchResult.isSuccess).toBe(true); expect(refetchResult.isFetching).toBe(false); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.db.subscribe).not.toHaveBeenCalled(); }); @@ -592,7 +606,7 @@ describe("useAsyncSelector", () => { return ["unused"]; }); - mocks.selectMaybeAsync.mockImplementation((_db, input) => { + mocks.selectCachedMaybeAsync.mockImplementation((_db, input) => { input.selectRangeCmds.push(cmd); input.selector(input.args); return ["task-1"]; @@ -608,7 +622,7 @@ describe("useAsyncSelector", () => { expect(result.status).toBe("success"); expect(result.isLoading).toBe(false); expect(selector).toHaveBeenCalledWith({ projectId: "project-1" }); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.selectAsync).not.toHaveBeenCalled(); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -616,7 +630,6 @@ describe("useAsyncSelector", () => { status: "success", }), ); - expect(mocks.refs[2].current).toEqual([cmd]); }); it("runs object-form async selectors with args", async () => { @@ -624,7 +637,7 @@ describe("useAsyncSelector", () => { return ["unused"]; }); - mocks.selectMaybeAsync.mockImplementation((_db, input) => { + mocks.selectCachedMaybeAsync.mockImplementation((_db, input) => { input.selector(input.args); return Promise.resolve(["task-1"]); }); @@ -638,7 +651,7 @@ describe("useAsyncSelector", () => { await flushPromises(); expect(selector).toHaveBeenCalledWith({ projectId: "project-1" }); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.db.subscribe).toHaveBeenCalledTimes(1); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -652,7 +665,7 @@ describe("useAsyncSelector", () => { const selector = vi.fn(function* selector(_args: { projectId: string }) { return ["unused"]; }); - mocks.selectMaybeAsync.mockReturnValue(new Promise(() => {})); + mocks.selectCachedMaybeAsync.mockReturnValue(new Promise(() => {})); mocks.stableSerializeSelectorArgs .mockReturnValueOnce("project-1") .mockReturnValueOnce("project-2"); @@ -675,6 +688,6 @@ describe("useAsyncSelector", () => { expect(mocks.setState).toHaveBeenCalledWith( expect.objectContaining({ data: ["loading-2"] }), ); - expect(mocks.selectMaybeAsync).toHaveBeenCalledTimes(2); + expect(mocks.selectCachedMaybeAsync).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index 929420c..0e7bef5 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -3,14 +3,13 @@ import { useEffect, useMemo, useRef, - useState, useSyncExternalStore, } from "react"; import { createCachedSelectorStoreSync, - getSubscribableHybridCacheDB, - selectAsync, + runCachedSelectorMaybeAsync, selectCachedMaybeAsync, + selectAsync, selectMaybeAsync, selectSync, type AnyObjectSelector, @@ -18,6 +17,12 @@ import { type SelectorInput, type SelectorReturn, } from "../hyperdb/commands/selector/selector"; +import { + createCachedSelectorStoreAsync, + type AsyncSelectorFetchStatus, + type AsyncSelectorRefetchOptions, + type AsyncSelectorStatus, +} from "../hyperdb/commands/selector/async-selector-store"; import { asyncDispatch, syncDispatch, @@ -27,7 +32,6 @@ import { isNeedToRerunRange, stableSerializeSelectorArgs, } from "../hyperdb/commands/selector/selector-memo"; -import type { SelectRangeCmd } from "../hyperdb/commands/selector/commands"; type SyncSelectorEnabledOptions = { selector: TSelector; @@ -43,13 +47,9 @@ type SyncSelectorMaybeDisabledOptions = { defaultValue: SelectorReturn; }; -export type AsyncSelectorStatus = "pending" | "error" | "success"; -export type AsyncSelectorFetchStatus = "fetching" | "paused" | "idle"; +export type { AsyncSelectorFetchStatus, AsyncSelectorStatus }; -export type UseAsyncSelectorRefetchOptions = { - throwOnError?: boolean; - cancelRefetch?: boolean; -}; +export type UseAsyncSelectorRefetchOptions = AsyncSelectorRefetchOptions; export type UseAsyncSelectorResult = { data: TData | undefined; @@ -135,266 +135,21 @@ type AsyncSelectorDefinedOptions< } ); -type AsyncSelectorState = { - data: TData | undefined; - dataUpdatedAt: number; - error: TError | null; - errorUpdatedAt: number; - errorUpdateCount: number; - failureCount: number; - failureReason: TError | null; - fetchStatus: AsyncSelectorFetchStatus; - isFetched: boolean; - isFetchedAfterMount: boolean; - isPlaceholderData: boolean; - promise: Promise; - status: AsyncSelectorStatus; -}; - -type AsyncSelectorValueSnapshot = - | { hasValue: false } - | { hasValue: true; value: TData }; - -type AsyncSelectorInitialRun = - | { status: "sync"; value: TData; cmds: SelectRangeCmd[]; revision: number } - | { - status: "async"; - promise: Promise; - cmds: SelectRangeCmd[]; - revision: number; - } - | { status: "error"; error: unknown; revision: number }; - -type AsyncSelectorValueSnapshotStore = { - getSnapshot: () => AsyncSelectorValueSnapshot; - publish: (value: TData) => void; - subscribe: (callback: () => void) => () => void; - takeInitialRun: () => AsyncSelectorInitialRun | undefined; -}; - const createDisabledStore = (defaultValue: TReturn) => ({ subscribe: () => () => {}, getSnapshot: () => defaultValue, }); -const createAsyncSelectorValueSnapshotStore = (options: { - runInitial?: () => AsyncSelectorInitialRun | undefined; -}): AsyncSelectorValueSnapshotStore => { - const subscribers = new Set<() => void>(); - let initialRun: AsyncSelectorInitialRun | undefined; - let initialRunComputed = false; - let snapshot: AsyncSelectorValueSnapshot = { hasValue: false }; - const notify = () => { - for (const subscriber of subscribers) { - subscriber(); - } - }; - const ensureInitialRun = () => { - if (initialRunComputed) { - return; - } - - initialRunComputed = true; - initialRun = options.runInitial?.(); - if (initialRun?.status === "sync") { - snapshot = { hasValue: true, value: initialRun.value }; - } - }; - - return { - getSnapshot: () => { - ensureInitialRun(); - return snapshot; - }, - publish: (value) => { - snapshot = { hasValue: true, value }; - notify(); - }, - subscribe: (callback) => { - subscribers.add(callback); - - return () => { - subscribers.delete(callback); - }; - }, - takeInitialRun: () => { - ensureInitialRun(); - const run = initialRun; - initialRun = undefined; - return run; - }, - }; -}; - -const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => - value !== null && - (typeof value === "object" || typeof value === "function") && - typeof (value as { then?: unknown }).then === "function"; - -const hasOwn = ( - object: TObject, - key: TKey, -): object is TObject & Record => - Object.prototype.hasOwnProperty.call(object, key); - -const resolveValue = (value: TValue | (() => TValue)): TValue => - typeof value === "function" ? (value as () => TValue)() : value; - -const createPromiseController = () => { - let resolve!: (value: TValue) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - - promise.catch(() => undefined); - - return { promise, reject, resolve }; -}; - -const readInitialDataUpdatedAt = ( - value: number | (() => number | undefined) | undefined, -) => { - if (value === undefined) return Date.now(); - - return (typeof value === "function" ? value() : value) ?? Date.now(); -}; - -const createAsyncSelectorState = ( - input: { - defaultValue?: TData; - initialData?: TData | (() => TData); - initialDataUpdatedAt?: number | (() => number | undefined); - placeholderData?: - | TData - | ((previousValue: TData | undefined, previousQuery: undefined) => TData); - }, - options: { - canFetch: boolean; - previousData?: TData; - promise: Promise; - }, -): AsyncSelectorState => { - if (hasOwn(input, "initialData")) { - const data = resolveValue(input.initialData as TData | (() => TData)); - - return { - data, - dataUpdatedAt: readInitialDataUpdatedAt(input.initialDataUpdatedAt), - error: null, - errorUpdatedAt: 0, - errorUpdateCount: 0, - failureCount: 0, - failureReason: null, - fetchStatus: options.canFetch ? "fetching" : "idle", - isFetched: true, - isFetchedAfterMount: false, - isPlaceholderData: false, - promise: options.promise, - status: "success", - }; - } - - let data: TData | undefined; - let isPlaceholderData = false; - - if (hasOwn(input, "placeholderData")) { - const placeholderData = input.placeholderData as - | TData - | ((previousValue: TData | undefined, previousQuery: undefined) => TData); - data = - typeof placeholderData === "function" - ? ( - placeholderData as ( - previousValue: TData | undefined, - previousQuery: undefined, - ) => TData - )(options.previousData, undefined) - : placeholderData; - isPlaceholderData = true; - } else if (hasOwn(input, "defaultValue")) { - data = input.defaultValue as TData; - isPlaceholderData = true; - } - - return { - data, - dataUpdatedAt: 0, - error: null, - errorUpdatedAt: 0, - errorUpdateCount: 0, - failureCount: 0, - failureReason: null, - fetchStatus: options.canFetch ? "fetching" : "idle", - isFetched: false, - isFetchedAfterMount: false, - isPlaceholderData, - promise: options.promise, - status: "pending", - }; -}; - -const createUseAsyncSelectorResult = ( - queryState: AsyncSelectorState, - options: { - enabled: boolean; - refetch: UseAsyncSelectorResult["refetch"]; - }, -): UseAsyncSelectorResult => { - const isStale = (() => { - if (queryState.status !== "success") return true; - - return Date.now() > queryState.dataUpdatedAt; - })(); - const isPending = queryState.status === "pending"; - const isError = queryState.status === "error"; - const isFetching = queryState.fetchStatus === "fetching"; - const isPaused = queryState.fetchStatus === "paused"; - - return { - ...queryState, - isEnabled: options.enabled, - isError, - isFetching, - isInitialLoading: isFetching && isPending, - isLoading: isFetching && isPending, - isLoadingError: isError && queryState.dataUpdatedAt === 0, - isPaused, - isPending, - isRefetchError: isError && queryState.dataUpdatedAt > 0, - isRefetching: isFetching && !isPending, - isStale, - isSuccess: queryState.status === "success", - refetch: options.refetch, - }; -}; - -const createVisibleAsyncSelectorState = ( - queryState: AsyncSelectorState, - snapshot: AsyncSelectorValueSnapshot, -): AsyncSelectorState => { - if (!snapshot.hasValue) { - return queryState; - } - - return { - ...queryState, - data: snapshot.value, - isPlaceholderData: false, - status: queryState.status === "pending" ? "success" : queryState.status, - }; -}; - const defaultHookDeps = { useCallback, useEffect, useMemo, useRef, - useState, useSyncExternalStore, useDB, createCachedSelectorStoreSync, + createCachedSelectorStoreAsync, + runCachedSelectorMaybeAsync, selectAsync, selectCachedMaybeAsync, selectMaybeAsync, @@ -411,6 +166,12 @@ export function setHyperDBHookDepsForTest( deps: Partial, ): () => void { hookDeps = { ...defaultHookDeps, ...deps }; + if ( + deps.selectCachedMaybeAsync && + deps.runCachedSelectorMaybeAsync === undefined + ) { + hookDeps.runCachedSelectorMaybeAsync = deps.selectCachedMaybeAsync; + } return () => { hookDeps = defaultHookDeps; @@ -469,381 +230,110 @@ export function useAsyncSelector< const db = hookDeps.useDB(); const enabled = input.enabled !== false; const subscribed = input.subscribed !== false; - const canFetch = enabled && subscribed; const argsKey = enabled ? hookDeps.stableSerializeSelectorArgs(input.args) : undefined; - const hybridCacheDB = hookDeps.useMemo( + const previousDataRef = hookDeps.useRef< + SelectorReturn | undefined + >(undefined); + const storeInput = hookDeps.useMemo(() => { + const placeholderData = + typeof input.placeholderData === "function" + ? (_previousValue, previousQuery) => + ( + input.placeholderData as ( + previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => SelectorReturn + )(previousDataRef.current, previousQuery) + : input.placeholderData; + + const nextInput = { + selector: input.selector, + args: input.args, + enabled, + subscribed, + }; + + if (input.defaultValue !== undefined) { + Object.assign(nextInput, { defaultValue: input.defaultValue }); + } + if (input.initialData !== undefined) { + Object.assign(nextInput, { initialData: input.initialData }); + } + if (input.initialDataUpdatedAt !== undefined) { + Object.assign(nextInput, { + initialDataUpdatedAt: input.initialDataUpdatedAt, + }); + } + if (placeholderData !== undefined) { + Object.assign(nextInput, { placeholderData }); + } + + return nextInput; + }, [ + input.selector, + argsKey, + enabled, + subscribed, + input.defaultValue, + input.initialData, + input.initialDataUpdatedAt, + input.placeholderData, + ]); + const store = hookDeps.useMemo( () => - enabled && subscribed ? getSubscribableHybridCacheDB(db) : undefined, - [db, enabled, subscribed], - ); - const valueSnapshotStore = hookDeps.useMemo(() => { - return createAsyncSelectorValueSnapshotStore>({ - runInitial: () => { - if (!canFetch) { - return undefined; - } - - const cmds: SelectRangeCmd[] = []; - const revision = db.getRevision(); - - try { - const value = hybridCacheDB - ? hookDeps.selectCachedMaybeAsync(db, { - selector: input.selector, - args: input.args, - selectRangeCmds: cmds, - }) - : hookDeps.selectMaybeAsync(db, { - selector: input.selector, - args: input.args, - selectRangeCmds: cmds, - }); - - if (isPromiseLike(value)) { - const promise = Promise.resolve(value); - promise.catch(() => undefined); - - return { status: "async", promise, cmds, revision }; - } - - return { status: "sync", value, cmds, revision }; - } catch (error) { - return { status: "error", error, revision }; - } - }, - }); - }, [db, input.selector, argsKey, canFetch, hybridCacheDB]); - const valueSnapshot = hookDeps.useSyncExternalStore( - valueSnapshotStore.subscribe, - valueSnapshotStore.getSnapshot, - valueSnapshotStore.getSnapshot, - ); - const promiseControllerRef = hookDeps.useRef( - createPromiseController>(), - ); - const [queryState, setQueryStateRaw] = hookDeps.useState< - AsyncSelectorState, TError> - >(() => - createAsyncSelectorState, TError>(input, { - canFetch, - promise: promiseControllerRef.current.promise, - }), + hookDeps.createCachedSelectorStoreAsync( + db, + storeInput, + { + runCachedSelectorMaybeAsync: hookDeps.runCachedSelectorMaybeAsync, + isNeedToRerunRange: hookDeps.isNeedToRerunRange, + }, + ), + [db, storeInput], ); - const queryStateRef = hookDeps.useRef(queryState); - const selectRangeCmdsRef = hookDeps.useRef([]); - const valueSnapshotStoreRef = hookDeps.useRef(valueSnapshotStore); - const isRunningRef = hookDeps.useRef(false); - const rerunRequestedRef = hookDeps.useRef(false); - const cancelledRef = hookDeps.useRef(false); - const inFlightResultRef = hookDeps.useRef, TError> - > | null>(null); - const resultRef = hookDeps.useRef< - UseAsyncSelectorResult, TError> - >( - undefined as unknown as UseAsyncSelectorResult< - SelectorReturn, - TError - >, - ); - const runRef = hookDeps.useRef< - ( - options?: UseAsyncSelectorRefetchOptions, - ) => Promise, TError>> - >(() => Promise.resolve(resultRef.current)); - valueSnapshotStoreRef.current = valueSnapshotStore; - - const setQueryState = hookDeps.useCallback( - ( - updater: ( - previous: AsyncSelectorState, TError>, - ) => AsyncSelectorState, TError>, - ) => { - const next = updater(queryStateRef.current); - queryStateRef.current = next; - setQueryStateRaw(next); - return next; - }, - [], + const snapshot = hookDeps.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, ); const refetch = hookDeps.useCallback( - (options?: UseAsyncSelectorRefetchOptions) => runRef.current(options), - [], + async function refetch(options?: UseAsyncSelectorRefetchOptions) { + return { + ...(await store.refetch(options)), + refetch, + }; + }, + [store], ); - - const visibleQueryState = createVisibleAsyncSelectorState( - queryState, - valueSnapshot, + const result = hookDeps.useMemo( + (): UseAsyncSelectorResult, TError> => ({ + ...snapshot, + refetch, + }), + [snapshot, refetch], ); - const result = createUseAsyncSelectorResult(visibleQueryState, { - enabled, - refetch, - }); - resultRef.current = result; hookDeps.useEffect(() => { - const promiseController = - createPromiseController>(); - promiseControllerRef.current = promiseController; - selectRangeCmdsRef.current = []; - setQueryState((previous) => - createAsyncSelectorState, TError>(input, { - canFetch, - previousData: previous.data, - promise: promiseController.promise, - }), - ); - }, [input.selector, argsKey]); - - hookDeps.useEffect(() => { - cancelledRef.current = false; - - const run = (options?: UseAsyncSelectorRefetchOptions) => { - if (isRunningRef.current) { - rerunRequestedRef.current = true; - - if ( - options?.cancelRefetch === false && - inFlightResultRef.current !== null - ) { - return inFlightResultRef.current; - } - - return inFlightResultRef.current ?? Promise.resolve(resultRef.current); - } - - isRunningRef.current = true; - const promiseController = - createPromiseController>(); - promiseControllerRef.current = promiseController; - let initialRun = valueSnapshotStoreRef.current.takeInitialRun(); - const resultPromise = new Promise< - UseAsyncSelectorResult, TError> - >((resolve, reject) => { - const resolveCurrentResult = () => { - resolve(resultRef.current); - }; - const finishSuccess = ( - value: SelectorReturn, - cmds: SelectRangeCmd[], - ) => { - if (cancelledRef.current) return; - - selectRangeCmdsRef.current = cmds; - valueSnapshotStoreRef.current.publish(value); - const nextState = setQueryState((previous) => ({ - ...previous, - data: value, - dataUpdatedAt: Date.now(), - error: null, - failureCount: 0, - failureReason: null, - fetchStatus: "idle", - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - status: "success", - })); - resultRef.current = createUseAsyncSelectorResult( - createVisibleAsyncSelectorState(nextState, { - hasValue: true, - value, - }), - { - enabled, - refetch, - }, - ); - promiseController.resolve(value); - isRunningRef.current = false; - inFlightResultRef.current = null; - resolveCurrentResult(); - - if (rerunRequestedRef.current && !cancelledRef.current) { - void run(); - } - }; - const finishError = (error: unknown) => { - if (cancelledRef.current) return; - - const typedError = error as TError; - const nextState = setQueryState((previous) => ({ - ...previous, - error: typedError, - errorUpdatedAt: Date.now(), - errorUpdateCount: previous.errorUpdateCount + 1, - failureCount: previous.failureCount + 1, - failureReason: typedError, - fetchStatus: "idle", - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - status: "error", - })); - resultRef.current = createUseAsyncSelectorResult( - createVisibleAsyncSelectorState( - nextState, - valueSnapshotStoreRef.current.getSnapshot(), - ), - { - enabled, - refetch, - }, - ); - promiseController.reject(error); - isRunningRef.current = false; - inFlightResultRef.current = null; - - if (options?.throwOnError === true) { - reject(error); - return; - } - - resolveCurrentResult(); - }; - const runOnce = () => { - try { - do { - rerunRequestedRef.current = false; - const cmds: SelectRangeCmd[] = []; - let currentInitialRun = initialRun; - initialRun = undefined; - let value: - | SelectorReturn - | PromiseLike>; - - if ( - currentInitialRun && - currentInitialRun.revision !== db.getRevision() - ) { - currentInitialRun = undefined; - } - - if (currentInitialRun) { - if (currentInitialRun.status === "error") { - finishError(currentInitialRun.error); - return; - } - - if (currentInitialRun.status === "sync") { - value = currentInitialRun.value; - cmds.push(...currentInitialRun.cmds); - } else { - value = currentInitialRun.promise; - cmds.push(...currentInitialRun.cmds); - } - } else { - value = hybridCacheDB - ? hookDeps.selectCachedMaybeAsync(db, { - selector: input.selector, - args: input.args, - selectRangeCmds: cmds, - }) - : hookDeps.selectMaybeAsync(db, { - selector: input.selector, - args: input.args, - selectRangeCmds: cmds, - }); - } - - if (isPromiseLike(value)) { - void Promise.resolve(value).then( - (resolvedValue) => { - if (cancelledRef.current) { - return; - } - - if (rerunRequestedRef.current) { - runOnce(); - return; - } - - finishSuccess(resolvedValue, cmds); - }, - (error: unknown) => { - finishError(error); - }, - ); - return; - } - - if (cancelledRef.current) { - isRunningRef.current = false; - return; - } - - if (rerunRequestedRef.current) continue; - - finishSuccess(value, cmds); - return; - } while (rerunRequestedRef.current); - } catch (error) { - finishError(error); - } - }; - - setQueryState((previous) => ({ - ...previous, - fetchStatus: "fetching", - promise: promiseController.promise, - status: - previous.status === "success" || previous.dataUpdatedAt > 0 - ? previous.status - : "pending", - })); - - runOnce(); - }); - - if (isRunningRef.current) { - inFlightResultRef.current = resultPromise; - } - - return resultPromise; - }; - runRef.current = run; - - if (!canFetch) { - return; - } - - const unsubscribe = db.subscribe((ops) => { - if (isRunningRef.current) { - rerunRequestedRef.current = true; - return; - } - - // Only skip if we already have cmds AND they don't need rerun - if ( - selectRangeCmdsRef.current.length > 0 && - !hookDeps.isNeedToRerunRange(selectRangeCmdsRef.current, ops) - ) { - return; - } - - void run(); - }); - - void run(); - return () => { - cancelledRef.current = true; - isRunningRef.current = false; - unsubscribe(); + store.destroy(); }; - }, [db, input.selector, argsKey, canFetch, enabled, hybridCacheDB, refetch]); + }, [store]); + + if (!result.isPlaceholderData) { + previousDataRef.current = result.data; + } if (result.isError && input.throwOnError) { const shouldThrow = typeof input.throwOnError === "function" - ? input.throwOnError(queryState.error as TError, result) + ? input.throwOnError(result.error as TError, result) : input.throwOnError; if (shouldThrow) { - throw queryState.error; + throw result.error; } } From 2284e878ea7b027dfc33687a2fd2a6b813d059dc Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Jul 2026 11:59:29 +0300 Subject: [PATCH 2/4] fix: improve hooks --- README.md | 1 + .../src/content/docs/integrations/react.md | 2 +- .../src/content/docs/start/llm-cheat-sheet.md | 3 +- .../selector/async-selector-store.test.ts | 178 ++++++++++ .../commands/selector/async-selector-store.ts | 304 ++++++++++-------- packages/hyperdb/src/react/hooks.test.ts | 271 ++++++++++++++++ packages/hyperdb/src/react/hooks.ts | 93 ++++-- 7 files changed, 695 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 7e0e42c..5b30101 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ run finishes synchronously, that value is visible immediately; if it returns a promise, the hook shows `defaultValue`, `initialData`, or `placeholderData` until that same promise resolves. The hook uses `createCachedSelectorStoreAsync` internally, so the same async subscription behavior is available without React. +`defaultValue` may be a value or a zero-argument function that returns the value. ## Entry points diff --git a/packages/hyperdb-doc/src/content/docs/integrations/react.md b/packages/hyperdb-doc/src/content/docs/integrations/react.md index d0885fd..2cf78a9 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -102,7 +102,7 @@ Options: | `selector` | The selector to run | | `args` | Its arguments (also the reactive identity) | | `enabled` | Set `false` to skip automatic runs; call `refetch()` to run manually | -| `defaultValue` | Compatibility alias for placeholder data before the first resolved run | +| `defaultValue` | Value or thunk used as placeholder data before the first resolved run | | `initialData` | Initial successful data for the result | | `initialDataUpdatedAt` | Timestamp for `initialData` | | `placeholderData` | Temporary data while the selector is still pending | diff --git a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md index 6cb812e..c9b1e26 100644 --- a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md +++ b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md @@ -359,7 +359,8 @@ Use `useAsyncSelector` and `useAsyncDispatch` with HybridDB. `useAsyncSelector` returns a React Query-style result with `data`, `status`, `error`, fetching flags, and `refetch()`. Its initial snapshot attempts the selector once: sync results are visible immediately, while promise results show `defaultValue`, -`initialData`, or `placeholderData` until that promise resolves. Use +`initialData`, or `placeholderData` until that promise resolves. `defaultValue` +may be a value or a zero-argument function that returns the value. Use `useSyncSelector` / `useDispatch` only for purely synchronous drivers. ## Rules Of Thumb diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts index e88b378..f4c5cd7 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts @@ -310,6 +310,151 @@ describe("createCachedSelectorStoreAsync", () => { store.destroy(); }); + it("settles in-flight refetch when the last subscriber unsubscribes", async () => { + const gate = deferred(); + const db = createTestDB(); + const asyncValue = selector({ + name: "asyncStoreUnsubscribeRefetchValue", + args: {}, + *handler() { + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + subscribed: false, + }); + const unsubscribe = store.subscribe(vi.fn()); + + const refetchPromise = store.refetch(); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + fetchStatus: "fetching", + status: "pending", + }), + ); + + unsubscribe(); + + await expect(refetchPromise).resolves.toEqual( + expect.objectContaining({ + fetchStatus: "idle", + isFetching: false, + status: "pending", + }), + ); + + gate.resolve("late"); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: undefined, + fetchStatus: "idle", + status: "pending", + }), + ); + + store.destroy(); + }); + + it("settles in-flight refetch when the store is destroyed", async () => { + const gate = deferred(); + const db = createTestDB(); + const asyncValue = selector({ + name: "asyncStoreDestroyRefetchValue", + args: {}, + *handler() { + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + subscribed: false, + }); + + const refetchPromise = store.refetch(); + store.destroy(); + + await expect(refetchPromise).resolves.toEqual( + expect.objectContaining({ + fetchStatus: "idle", + isFetching: false, + status: "pending", + }), + ); + + gate.resolve("late"); + await flushPromises(); + }); + + it("restarts after unsubscribe and resubscribe while a selector run is in-flight", async () => { + const first = deferred(); + const second = deferred(); + const db = createTestDB(); + let runCount = 0; + const asyncValue = selector({ + name: "asyncStoreResubscribeInFlightValue", + args: {}, + *handler() { + runCount++; + + if (runCount === 1) { + return yield* unwrap(first.promise); + } + + return yield* unwrap(second.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + }); + const firstUnsubscribe = store.subscribe(vi.fn()); + + expect(runCount).toBe(1); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + fetchStatus: "fetching", + status: "pending", + }), + ); + + firstUnsubscribe(); + const subscriber = vi.fn(); + const secondUnsubscribe = store.subscribe(subscriber); + + expect(runCount).toBe(2); + + first.resolve("stale"); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: undefined, + fetchStatus: "fetching", + status: "pending", + }), + ); + + second.resolve("fresh"); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: "fresh", + fetchStatus: "idle", + status: "success", + }), + ); + expect(subscriber).toHaveBeenCalled(); + + secondUnsubscribe(); + store.destroy(); + }); + it("refetch rejects selector errors when throwOnError is true", async () => { const error = new Error("selector failed"); const db = createTestDB(); @@ -337,6 +482,39 @@ describe("createCachedSelectorStoreAsync", () => { store.destroy(); }); + it("applies throwOnError for a refetch that joins an existing in-flight run", async () => { + const gate = deferred(); + const error = new Error("selector failed later"); + const db = createTestDB(); + const failingSelector = selector({ + name: "asyncStoreOverlappingFailingSelector", + args: {}, + *handler() { + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: failingSelector, + args: {}, + subscribed: false, + }); + + const nonThrowingRefetch = store.refetch(); + const throwingRefetch = store.refetch({ throwOnError: true }); + + gate.reject(error); + + await expect(nonThrowingRefetch).resolves.toEqual( + expect.objectContaining({ + error, + status: "error", + }), + ); + await expect(throwingRefetch).rejects.toBe(error); + + store.destroy(); + }); + it("uses the cached maybe-async selector path for async stores", () => { const db = { getRevision: vi.fn(() => 0), diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts index 368a90d..67daa90 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts @@ -50,7 +50,7 @@ export type AsyncSelectorStoreInput = { args: SelectorArgs; enabled?: boolean; subscribed?: boolean; - defaultValue?: SelectorReturn; + defaultValue?: SelectorReturn | (() => SelectorReturn); initialData?: SelectorReturn | (() => SelectorReturn); initialDataUpdatedAt?: number | (() => number | undefined); placeholderData?: @@ -134,6 +134,10 @@ const createPromiseController = () => { return { promise, reject, resolve }; }; +type PromiseController = ReturnType< + typeof createPromiseController +>; + const readInitialDataUpdatedAt = ( value: number | (() => number | undefined) | undefined, ) => { @@ -144,7 +148,7 @@ const readInitialDataUpdatedAt = ( const createInitialState = ( input: { - defaultValue?: TData; + defaultValue?: TData | (() => TData); initialData?: TData | (() => TData); initialDataUpdatedAt?: number | (() => number | undefined); placeholderData?: @@ -197,7 +201,7 @@ const createInitialState = ( hasOwn(input, "defaultValue") && input.defaultValue !== undefined ) { - data = input.defaultValue as TData; + data = resolveValue(input.defaultValue as TData | (() => TData)); isPlaceholderData = true; } @@ -275,6 +279,11 @@ const createAsyncSelectorStoreInternal = < let rerunRequested = false; let inFlightResult: Promise> | null = null; + let activeRun: { + token: number; + dataController: PromiseController; + resultController: PromiseController>; + } | null = null; let dbUnsubscribe: (() => void) | undefined; let runToken = 0; let runRevision = db.getRevision(); @@ -361,148 +370,195 @@ const createAsyncSelectorStoreInternal = < dbUnsubscribe = undefined; }; + const cancelInFlightRun = () => { + runToken++; + + if (activeRun) { + const cancelledQueryState = { + ...queryState, + fetchStatus: "idle" as const, + }; + const cancelledSnapshot = buildSnapshot(cancelledQueryState, { + enabled, + staleTime, + }); + + queryState = cancelledQueryState; + snapshot = cancelledSnapshot; + activeRun.dataController.reject( + new Error("Async selector run was cancelled"), + ); + activeRun.resultController.resolve(cancelledSnapshot); + activeRun = null; + } + + isRunning = false; + inFlightResult = null; + rerunRequested = false; + }; + const run = ( options?: AsyncSelectorRefetchOptions, ): Promise> => { + const applyRefetchOptions = ( + resultPromise: Promise>, + ) => { + if (options?.throwOnError !== true) return resultPromise; + + return resultPromise.then((result) => { + if (result.isError) { + throw result.error; + } + + return result; + }); + }; + started = true; if (isRunning) { rerunRequested = true; + const currentResult = inFlightResult ?? Promise.resolve(snapshot); if (options?.cancelRefetch === false && inFlightResult !== null) { - return inFlightResult; + return applyRefetchOptions(inFlightResult); } - return inFlightResult ?? Promise.resolve(snapshot); + return applyRefetchOptions(currentResult); } const currentToken = ++runToken; isRunning = true; runRevision = db.getRevision(); const promiseController = createPromiseController(); + const resultController = + createPromiseController>(); + activeRun = { + token: currentToken, + dataController: promiseController, + resultController, + }; - const resultPromise = new Promise>( - (resolve, reject) => { - const isCurrentRun = () => !destroyed && currentToken === runToken; - const resolveCurrentSnapshot = () => { - resolve(snapshot); - }; - const finishSuccess = (value: TData, cmds: SelectRangeCmd[]) => { - if (!isCurrentRun()) return; - - selectRangeCmds = cmds; - setQueryState((previous) => ({ - ...previous, - data: value, - dataUpdatedAt: Date.now(), - error: null, - failureCount: 0, - failureReason: null, - fetchStatus: "idle", - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - status: "success", - })); - promiseController.resolve(value); - isRunning = false; - inFlightResult = null; - resolveCurrentSnapshot(); - - if (rerunRequested && !destroyed) { - void run(); - } - }; - const finishError = (error: unknown) => { - if (!isCurrentRun()) return; - - const typedError = error as TError; - setQueryState((previous) => ({ - ...previous, - error: typedError, - errorUpdatedAt: Date.now(), - errorUpdateCount: previous.errorUpdateCount + 1, - failureCount: previous.failureCount + 1, - failureReason: typedError, - fetchStatus: "idle", - isFetched: true, - isFetchedAfterMount: true, - isPlaceholderData: false, - status: "error", - })); - promiseController.reject(error); - isRunning = false; - inFlightResult = null; - - if (options?.throwOnError === true) { - reject(error); + const resultPromise = resultController.promise; + const isCurrentRun = () => + !destroyed && + currentToken === runToken && + activeRun?.token === currentToken; + const resolveCurrentSnapshot = () => { + resultController.resolve(snapshot); + }; + const finishSuccess = (value: TData, cmds: SelectRangeCmd[]) => { + if (!isCurrentRun()) return; + + selectRangeCmds = cmds; + setQueryState((previous) => ({ + ...previous, + data: value, + dataUpdatedAt: Date.now(), + error: null, + failureCount: 0, + failureReason: null, + fetchStatus: "idle", + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + status: "success", + })); + promiseController.resolve(value); + isRunning = false; + inFlightResult = null; + activeRun = null; + resolveCurrentSnapshot(); + + if (rerunRequested && !destroyed) { + void run(); + } + }; + const finishError = (error: unknown) => { + if (!isCurrentRun()) return; + + const typedError = error as TError; + setQueryState((previous) => ({ + ...previous, + error: typedError, + errorUpdatedAt: Date.now(), + errorUpdateCount: previous.errorUpdateCount + 1, + failureCount: previous.failureCount + 1, + failureReason: typedError, + fetchStatus: "idle", + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + status: "error", + })); + promiseController.reject(error); + isRunning = false; + inFlightResult = null; + activeRun = null; + + resolveCurrentSnapshot(); + }; + const runOnce = () => { + try { + do { + rerunRequested = false; + const cmds: SelectRangeCmd[] = []; + const value = runSelector(cmds); + + if (isPromiseLike(value)) { + void Promise.resolve(value).then( + (resolvedValue) => { + if (!isCurrentRun()) { + return; + } + + if (rerunRequested) { + runOnce(); + return; + } + + finishSuccess(resolvedValue, cmds); + }, + (error: unknown) => { + finishError(error); + }, + ); return; } - resolveCurrentSnapshot(); - }; - const runOnce = () => { - try { - do { - rerunRequested = false; - const cmds: SelectRangeCmd[] = []; - const value = runSelector(cmds); - - if (isPromiseLike(value)) { - void Promise.resolve(value).then( - (resolvedValue) => { - if (!isCurrentRun()) { - return; - } - - if (rerunRequested) { - runOnce(); - return; - } - - finishSuccess(resolvedValue, cmds); - }, - (error: unknown) => { - finishError(error); - }, - ); - return; - } - - if (!isCurrentRun()) { - isRunning = false; - return; - } - - if (rerunRequested) continue; - - finishSuccess(value, cmds); - return; - } while (rerunRequested); - } catch (error) { - finishError(error); + if (!isCurrentRun()) { + isRunning = false; + inFlightResult = null; + return; } - }; - - setQueryState((previous) => ({ - ...previous, - fetchStatus: "fetching", - promise: promiseController.promise, - status: - previous.status === "success" || previous.dataUpdatedAt > 0 - ? previous.status - : "pending", - })); - - runOnce(); - }, - ); + + if (rerunRequested) continue; + + finishSuccess(value, cmds); + return; + } while (rerunRequested); + } catch (error) { + finishError(error); + } + }; + + setQueryState((previous) => ({ + ...previous, + fetchStatus: "fetching", + promise: promiseController.promise, + status: + previous.status === "success" || previous.dataUpdatedAt > 0 + ? previous.status + : "pending", + })); + + runOnce(); if (isRunning) { inFlightResult = resultPromise; } - return resultPromise; + return applyRefetchOptions(resultPromise); }; const ensureStarted = () => { @@ -551,10 +607,9 @@ const createAsyncSelectorStoreInternal = < if (listeners.size > 0) return; stopDBSubscription(); - runToken++; - isRunning = false; - inFlightResult = null; - rerunRequested = false; + cancelInFlightRun(); + started = false; + clearStaleTimer(); }; }, getSnapshot: () => { @@ -566,10 +621,7 @@ const createAsyncSelectorStoreInternal = < if (destroyed) return; destroyed = true; - runToken++; - isRunning = false; - inFlightResult = null; - rerunRequested = false; + cancelInFlightRun(); stopDBSubscription(); clearStaleTimer(); listeners.clear(); diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 56aae2c..5a910bd 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -106,6 +106,84 @@ function createMockDB() { }; } +function createStatefulFakeReactHooks() { + type MemoState = { + cleanup?: () => void; + deps?: unknown[]; + value?: unknown; + }; + const state: MemoState[] = []; + let index = 0; + const areDepsEqual = ( + previous: unknown[] | undefined, + next: unknown[] | undefined, + ) => + previous !== undefined && + next !== undefined && + previous.length === next.length && + previous.every((value, depIndex) => Object.is(value, next[depIndex])); + + return { + resetRender() { + index = 0; + }, + cleanup() { + for (const entry of state) { + entry.cleanup?.(); + entry.cleanup = undefined; + } + }, + useCallback: vi.fn((cb, deps) => { + const slot = index++; + const previous = state[slot]; + if (previous && areDepsEqual(previous.deps, deps)) { + return previous.value; + } + + state[slot] = { deps, value: cb }; + return cb; + }), + useEffect: vi.fn((effect, deps) => { + const slot = index++; + const previous = state[slot]; + if (previous && areDepsEqual(previous.deps, deps)) return; + + previous?.cleanup?.(); + state[slot] = { cleanup: effect(), deps }; + }), + useMemo: vi.fn((factory, deps) => { + const slot = index++; + const previous = state[slot]; + if (previous && areDepsEqual(previous.deps, deps)) { + return previous.value; + } + + const value = factory(); + state[slot] = { deps, value }; + return value; + }), + useRef: vi.fn((initial) => { + const slot = index++; + const previous = state[slot]; + if (previous) return previous.value; + + const value = { current: initial }; + state[slot] = { value }; + return value; + }), + useSyncExternalStore: vi.fn((subscribe, getSnapshot) => { + const slot = index++; + const previous = state[slot]; + if (!previous || previous.value !== subscribe) { + previous?.cleanup?.(); + state[slot] = { cleanup: subscribe(() => undefined), value: subscribe }; + } + + return getSnapshot(); + }), + }; +} + describe("useAsyncSelector", () => { beforeEach(() => { mocks.cleanup = undefined; @@ -179,6 +257,69 @@ describe("useAsyncSelector", () => { expect(mocks.createCachedSelectorStoreSync).not.toHaveBeenCalled(); }); + it("keeps the sync selector store stable when defaultValue references change", () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + const store = { + subscribe: vi.fn(() => vi.fn()), + getSnapshot: vi.fn(() => ["task-1"]), + }; + const createCachedSelectorStoreSync = vi.fn(() => store); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useDB: () => mocks.db, + createCachedSelectorStoreSync, + stableSerializeSelectorArgs: () => "args-key", + }); + const render = () => { + reactHooks.resetRender(); + return useSyncSelector({ + selector, + args: { projectId: "project-1" }, + defaultValue: [], + }); + }; + + expect(render()).toEqual(["task-1"]); + expect(render()).toEqual(["task-1"]); + + expect(createCachedSelectorStoreSync).toHaveBeenCalledTimes(1); + reactHooks.cleanup(); + }); + + it("returns the latest disabled sync defaultValue through the stable store", () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useDB: () => mocks.db, + createCachedSelectorStoreSync: (...args) => + mocks.createCachedSelectorStoreSync(...args), + stableSerializeSelectorArgs: () => "args-key", + }); + const render = (defaultValue: string[]) => { + reactHooks.resetRender(); + return useSyncSelector({ + selector, + args: { projectId: "project-1" }, + enabled: false, + defaultValue, + }); + }; + + expect(render(["first"])).toEqual(["first"]); + expect(render(["second"])).toEqual(["second"]); + + expect(mocks.createCachedSelectorStoreSync).not.toHaveBeenCalled(); + reactHooks.cleanup(); + }); + it("collapses overlapping subscription reruns and applies only the latest async result", async () => { const first = deferred(); const second = deferred(); @@ -342,6 +483,136 @@ describe("useAsyncSelector", () => { ); }); + it("keeps the async selector store stable when option value references change", () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + const store = { + subscribe: vi.fn(() => vi.fn()), + getSnapshot: vi.fn(() => ({ + data: [], + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + failureCount: 0, + failureReason: null, + fetchStatus: "idle", + isEnabled: true, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: true, + isPlaceholderData: true, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + promise: Promise.resolve([]), + status: "pending", + })), + refetch: vi.fn(), + destroy: vi.fn(), + }; + const createCachedSelectorStoreAsync = vi.fn(() => store); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useDB: () => mocks.db, + createCachedSelectorStoreAsync, + stableSerializeSelectorArgs: () => "args-key", + }); + const render = () => { + reactHooks.resetRender(); + useAsyncSelector({ + selector, + args: { projectId: "project-1" }, + defaultValue: [], + initialData: [], + placeholderData: [], + }); + }; + + render(); + render(); + + expect(createCachedSelectorStoreAsync).toHaveBeenCalledTimes(1); + reactHooks.cleanup(); + }); + + it("passes the latest async selector option values when the store is recreated", () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + const store = { + subscribe: vi.fn(() => vi.fn()), + getSnapshot: vi.fn(() => ({ + data: [], + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + failureCount: 0, + failureReason: null, + fetchStatus: "idle", + isEnabled: true, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: true, + isPlaceholderData: true, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + promise: Promise.resolve([]), + status: "pending", + })), + refetch: vi.fn(), + destroy: vi.fn(), + }; + const createCachedSelectorStoreAsync = vi.fn(() => store); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useDB: () => mocks.db, + createCachedSelectorStoreAsync, + stableSerializeSelectorArgs: (args: { projectId: string }) => + args.projectId, + }); + const render = (projectId: string, initialData: string[]) => { + reactHooks.resetRender(); + useAsyncSelector({ + selector, + args: { projectId }, + initialData, + }); + }; + + render("project-1", ["stale"]); + render("project-2", ["fresh"]); + + const secondInput = createCachedSelectorStoreAsync.mock.calls[1]?.[1]; + + expect(createCachedSelectorStoreAsync).toHaveBeenCalledTimes(2); + expect((secondInput?.initialData as () => string[] | undefined)()).toEqual([ + "fresh", + ]); + reactHooks.cleanup(); + }); + it("waits for a pending HybridDB cache run before applying the result", async () => { const cacheDB = { beginTx: vi.fn(), diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index 0e7bef5..c8d42f3 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -96,7 +96,7 @@ type AsyncSelectorBaseOptions< selector: TSelector; args: SelectorArgs; enabled?: boolean; - defaultValue?: SelectorReturn; + defaultValue?: SelectorReturn | (() => SelectorReturn); initialData?: SelectorReturn | (() => SelectorReturn); initialDataUpdatedAt?: number | (() => number | undefined); placeholderData?: @@ -119,7 +119,11 @@ type AsyncSelectorDefinedOptions< TError = unknown, > = AsyncSelectorBaseOptions & ( - | { defaultValue: SelectorReturn } + | { + defaultValue: + | SelectorReturn + | (() => SelectorReturn); + } | { initialData: | SelectorReturn @@ -135,9 +139,9 @@ type AsyncSelectorDefinedOptions< } ); -const createDisabledStore = (defaultValue: TReturn) => ({ +const createDisabledStore = (getDefaultValue: () => TReturn) => ({ subscribe: () => () => {}, - getSnapshot: () => defaultValue, + getSnapshot: getDefaultValue, }); const defaultHookDeps = { @@ -194,11 +198,14 @@ export function useSyncSelector( const argsKey = enabled ? hookDeps.stableSerializeSelectorArgs(input.args) : undefined; + const defaultValueRef = hookDeps.useRef(input.defaultValue); + + defaultValueRef.current = input.defaultValue; const selector = hookDeps.useMemo(() => { if (!enabled) { return createDisabledStore( - input.defaultValue as SelectorReturn, + () => defaultValueRef.current as SelectorReturn, ); } @@ -206,7 +213,7 @@ export function useSyncSelector( selector: input.selector, args: input.args, }); - }, [db, input.selector, argsKey, enabled, input.defaultValue]); + }, [db, input.selector, argsKey, enabled]); return hookDeps.useSyncExternalStore( selector.subscribe, @@ -236,18 +243,17 @@ export function useAsyncSelector< const previousDataRef = hookDeps.useRef< SelectorReturn | undefined >(undefined); - const storeInput = hookDeps.useMemo(() => { - const placeholderData = - typeof input.placeholderData === "function" - ? (_previousValue, previousQuery) => - ( - input.placeholderData as ( - previousValue: SelectorReturn | undefined, - previousQuery: undefined, - ) => SelectorReturn - )(previousDataRef.current, previousQuery) - : input.placeholderData; + const defaultValueRef = hookDeps.useRef(input.defaultValue); + const initialDataRef = hookDeps.useRef(input.initialData); + const initialDataUpdatedAtRef = hookDeps.useRef(input.initialDataUpdatedAt); + const placeholderDataRef = hookDeps.useRef(input.placeholderData); + + defaultValueRef.current = input.defaultValue; + initialDataRef.current = input.initialData; + initialDataUpdatedAtRef.current = input.initialDataUpdatedAt; + placeholderDataRef.current = input.placeholderData; + const storeInput = hookDeps.useMemo(() => { const nextInput = { selector: input.selector, args: input.args, @@ -255,19 +261,48 @@ export function useAsyncSelector< subscribed, }; - if (input.defaultValue !== undefined) { - Object.assign(nextInput, { defaultValue: input.defaultValue }); + if (defaultValueRef.current !== undefined) { + Object.assign(nextInput, { + defaultValue: () => + typeof defaultValueRef.current === "function" + ? (defaultValueRef.current as () => SelectorReturn)() + : defaultValueRef.current, + }); } - if (input.initialData !== undefined) { - Object.assign(nextInput, { initialData: input.initialData }); + if (initialDataRef.current !== undefined) { + Object.assign(nextInput, { + initialData: () => + typeof initialDataRef.current === "function" + ? (initialDataRef.current as () => SelectorReturn)() + : initialDataRef.current, + }); } - if (input.initialDataUpdatedAt !== undefined) { + if (initialDataUpdatedAtRef.current !== undefined) { Object.assign(nextInput, { - initialDataUpdatedAt: input.initialDataUpdatedAt, + initialDataUpdatedAt: () => + typeof initialDataUpdatedAtRef.current === "function" + ? (initialDataUpdatedAtRef.current as () => number | undefined)() + : initialDataUpdatedAtRef.current, }); } - if (placeholderData !== undefined) { - Object.assign(nextInput, { placeholderData }); + if (placeholderDataRef.current !== undefined) { + Object.assign(nextInput, { + placeholderData: ( + _previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => { + const placeholderData = placeholderDataRef.current; + + return typeof placeholderData === "function" + ? ( + placeholderData as ( + previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => SelectorReturn + )(previousDataRef.current, previousQuery) + : placeholderData; + }, + }); } return nextInput; @@ -276,10 +311,10 @@ export function useAsyncSelector< argsKey, enabled, subscribed, - input.defaultValue, - input.initialData, - input.initialDataUpdatedAt, - input.placeholderData, + input.defaultValue !== undefined, + input.initialData !== undefined, + input.initialDataUpdatedAt !== undefined, + input.placeholderData !== undefined, ]); const store = hookDeps.useMemo( () => From 7a6c69104a20c69db69a4c1f6938fbcae56271dd Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Jul 2026 12:19:37 +0300 Subject: [PATCH 3/4] fix: improve doc --- .changeset/sync-dispatch-hook.md | 5 + README.md | 2 +- TODO.md | 2 +- packages/hyperdb-doc/astro.config.mjs | 11 +- .../database/{writing-data.md => actions.md} | 8 +- .../src/content/docs/database/reading-data.md | 447 ++++++++++++------ .../docs/database/selectors-reactivity.md | 355 -------------- .../src/content/docs/database/selectors.md | 214 +++++++++ .../src/content/docs/integrations/react.md | 8 +- .../src/content/docs/runtime/db.md | 4 +- .../src/content/docs/runtime/drivers.md | 2 +- .../src/content/docs/start/how-it-works.md | 16 +- .../src/content/docs/start/llm-cheat-sheet.md | 7 +- .../src/content/docs/start/quickstart.md | 6 +- .../hyperdb-doc/src/content/docs/start/why.md | 2 +- packages/hyperdb-doc/summary.md | 31 +- packages/hyperdb/src/react/hooks.test.ts | 16 + packages/hyperdb/src/react/hooks.ts | 2 +- 18 files changed, 585 insertions(+), 553 deletions(-) create mode 100644 .changeset/sync-dispatch-hook.md rename packages/hyperdb-doc/src/content/docs/database/{writing-data.md => actions.md} (97%) delete mode 100644 packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md create mode 100644 packages/hyperdb-doc/src/content/docs/database/selectors.md diff --git a/.changeset/sync-dispatch-hook.md b/.changeset/sync-dispatch-hook.md new file mode 100644 index 0000000..53d5506 --- /dev/null +++ b/.changeset/sync-dispatch-hook.md @@ -0,0 +1,5 @@ +--- +"@will-be-done/hyperdb": minor +--- + +Rename the React synchronous dispatch hook from `useDispatch` to `useSyncDispatch`. diff --git a/README.md b/README.md index 5b30101..a7fe9c7 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ export async function createAppDB() { If your whole app state can be loaded into memory at startup, you may not need `HybridDB`. A plain `new SubscribableDB(new DB(new BptreeInmemDriver()))` keeps -reads and writes synchronous, so you can use `useSyncSelector`, `useDispatch`, +reads and writes synchronous, so you can use `useSyncSelector`, `useSyncDispatch`, `selectSync`, and `syncDispatch` without promise or cache-miss tradeoffs. For one-off reads that should reuse the selector cache, use `selectCachedSync`, `selectCachedAsync`, or `selectCachedMaybeAsync`. For subscriptions outside diff --git a/TODO.md b/TODO.md index 8483266..cc2f891 100644 --- a/TODO.md +++ b/TODO.md @@ -56,6 +56,6 @@ DB: 7. In-mem tx for fast action 8. k-way indexxedb OR merging 9. Multibe column support in db index -10. Rename useSelect => useSyncSelect, useDispatch => useSyncDispatch, select -> selectSync, runSelector -> runSelectorSync, initCachedSelector -> initCachedSelectorSync, initSelector -> initSelectorSync +10. Rename useSelect => useSyncSelect, select -> selectSync, runSelector -> runSelectorSync, initCachedSelector -> initCachedSelectorSync, initSelector -> initSelectorSync 11. Maybe rename upsert -> put 12. Move async storage to selector.ts from react hook diff --git a/packages/hyperdb-doc/astro.config.mjs b/packages/hyperdb-doc/astro.config.mjs index f45d524..dd179d5 100644 --- a/packages/hyperdb-doc/astro.config.mjs +++ b/packages/hyperdb-doc/astro.config.mjs @@ -5,6 +5,10 @@ import starlight from "@astrojs/starlight"; // https://astro.build/config export default defineConfig({ site: "https://hyperdb.will-be-done.app", + redirects: { + "/database/selectors-reactivity": "/database/reading-data", + "/database/writing-data": "/database/actions", + }, integrations: [ starlight({ title: "HyperDB", @@ -39,12 +43,9 @@ export default defineConfig({ { label: "Schemas", slug: "database/schemas" }, { label: "Indexes", slug: "database/indexes" }, { label: "Data Types", slug: "database/data-types" }, + { label: "Selectors", slug: "database/selectors" }, { label: "Reading Data", slug: "database/reading-data" }, - { - label: "Selectors & Reactivity", - slug: "database/selectors-reactivity", - }, - { label: "Writing Data", slug: "database/writing-data" }, + { label: "Actions", slug: "database/actions" }, ], }, { diff --git a/packages/hyperdb-doc/src/content/docs/database/writing-data.md b/packages/hyperdb-doc/src/content/docs/database/actions.md similarity index 97% rename from packages/hyperdb-doc/src/content/docs/database/writing-data.md rename to packages/hyperdb-doc/src/content/docs/database/actions.md index 1501f27..c219353 100644 --- a/packages/hyperdb-doc/src/content/docs/database/writing-data.md +++ b/packages/hyperdb-doc/src/content/docs/database/actions.md @@ -1,8 +1,8 @@ --- -title: Writing Data +title: Actions description: Change data with actions and the insert, upsert, and deleteRows mutations, dispatched in transactions. sidebar: - order: 5 + order: 6 --- You change data through actions, generator functions that may both read and @@ -143,8 +143,8 @@ await asyncDispatch( ); ``` -Inside React use [`useDispatch` / `useAsyncDispatch`](/integrations/react/), which -bind the dispatcher to the database from context. +Inside React use [`useSyncDispatch` / `useAsyncDispatch`](/integrations/react/), +which bind the dispatcher to the database from context. With `HybridDB`, use the async dispatch path. Writes update the in-memory cache first so subscribers and React can respond immediately, then flush to the diff --git a/packages/hyperdb-doc/src/content/docs/database/reading-data.md b/packages/hyperdb-doc/src/content/docs/database/reading-data.md index 5bb2dba..43bf0fe 100644 --- a/packages/hyperdb-doc/src/content/docs/database/reading-data.md +++ b/packages/hyperdb-doc/src/content/docs/database/reading-data.md @@ -1,215 +1,366 @@ --- title: Reading Data -description: Query data with selectors and the selectFrom builder, covering filters, ordering, limits, and first results. +description: Run selectors, subscribe to reads, cache results, and understand how HyperDB re-runs only affected data. sidebar: - order: 3 + order: 5 --- -You read data through selectors, generator functions that describe what to -read. Inside a selector you build queries with `selectFrom` and `yield*` them. -Selectors can call other selectors but can never write; the runtime rejects any -mutation emitted from a read. +Selectors aren't just query definitions; they're how you read data at runtime. +HyperDB can run selectors once, cache their results, subscribe to them, and +re-run them precisely: only when a mutation touches a range the selector +actually read. This page explains the read helpers, selector stores, and cache +controls you can use. -Every read starts from a named index. That makes the access path explicit in -your application code: `selectFrom(tasksTable, "byProjectOrder")` means "scan -this table through this index", then `.where(...)`, `.order(...)`, and -`.limit(...)` describe the bounds and result shape. +Selectors are ordinary generator code, so they can call other selectors, branch, +loop, and use shared helpers. Reactivity still comes from the indexed reads they +perform: every `selectFrom(...)` scan contributes ranges to the selector run. +To define selectors and their queries, see [Selectors](/database/selectors/). -## A first selector +## Running selectors + +Inside React, use [`useSyncSelector` / `useAsyncSelector`](/integrations/react/). +Outside React, run selectors directly with the same `{ selector, args }` input +shape: ```ts -import { selectFrom, v } from "@will-be-done/hyperdb"; -import { selector } from "./builders"; // createSelector() -import { tasksTable } from "./schema"; +import { selectAsync, selectSync } from "@will-be-done/hyperdb"; -export const projectTasks = selector({ - name: "projectTasks", - args: { projectId: v.string() }, - handler: function* ({ projectId }) { - return yield* selectFrom(tasksTable, "byProjectOrder") - .where((q) => q.eq("projectId", projectId)) - .order("asc"); - }, +// synchronous drivers (in-memory, sync SQLite) +const tasks = selectSync(db, { + selector: projectTasks, + args: { projectId: "p1" }, +}); + +// asynchronous drivers (IndexedDB, async SQLite, HybridDB) +const tasksAsync = await selectAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, +}); +``` + +Use cached selector reads when repeated calls for the same selector and args +should reuse the root selector cache: + +```ts +import { selectCachedMaybeAsync } from "@will-be-done/hyperdb"; + +const tasksOrPromise = selectCachedMaybeAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, }); +const tasks = await Promise.resolve(tasksOrPromise); ``` -Object-form selectors accept: +HyperDB exports these selector read helpers: -| Field | Description | -| ------------- | ------------------------------------------------------------------------------------------------------ | -| `name` | Required display/debug name (shown in traces) | -| `args` | Validator map for the single args object | -| `handler` | Generator function that does the reading | -| `skipTrace` | `true`, or `{ rootTrace, childTrace }` to skip tracing | -| `memoization` | `{ root?, selfChild? }` cache controls (see [Selectors & Reactivity](/database/selectors-reactivity/)) | +| Helper | Use | +| ------------------------ | -------------------------------------------------------------------- | +| `selectSync` | Run a selector once with sync drivers | +| `selectAsync` | Run a selector once with async drivers and `HybridDB` | +| `selectMaybeAsync` | Return a value or a `Promise`, depending on whether execution yields | +| `selectCachedSync` | Run or reuse the root selector cache synchronously | +| `selectCachedAsync` | Async cache-aware selector read | +| `selectCachedMaybeAsync` | Cache-aware read that may return a value or a `Promise` | +| `preloadSelectorAsync` | Warm the async root selector cache before a later read | -You can also wrap a bare generator function instead of using the object form. +## Selector stores -## The query builder +Selector stores are the low-level subscription primitive behind the React +selector hooks. Use them outside React when you want an external store with +`subscribe()` and `getSnapshot()`. -`selectFrom(table, indexName)` returns a builder. You always query through an -index. The builder is immutable: each method returns a new builder. With -`HybridDB`, the same indexed read checks the in-memory cache first; missing -ranges fall through to the primary store and are cached for later reads. +The four store helpers differ by execution mode, cache sharing, and snapshot +shape: -```ts -selectFrom(tasksTable, "byProjectOrder") - .where((q) => q.eq("projectId", "p1")) - .order("desc") - .limit(20); -``` +| Helper | Execution | Root selector cache | Snapshot shape | +| -------------------------------- | --------- | ------------------- | -------------- | +| `createSelectorStoreSync` | sync | no | selector data | +| `createCachedSelectorStoreSync` | sync | yes | selector data | +| `createAsyncSelectorStore` | async | no | query state | +| `createCachedSelectorStoreAsync` | async | yes | query state | -### `where` +### Sync stores -`where` takes a callback that receives a query object. Chain comparison methods -on it; each column you constrain must belong to the index you selected. +Use `createSelectorStoreSync` when you want an isolated synchronous external +store for one selector run: ```ts -.where((q) => q.eq("projectId", "p1")) -.where((q) => q.eq("projectId", "p1").gte("orderToken", "m")) -``` +import { createSelectorStoreSync } from "@will-be-done/hyperdb"; -Available comparisons: +const store = createSelectorStoreSync(db, { + selector: projectTasks, + args: { projectId: "p1" }, +}); +const unsubscribe = store.subscribe(() => { + console.log(store.getSnapshot()); +}); +``` -| Method | Meaning | -| ----------------- | --------------------- | -| `q.eq(col, val)` | equal to | -| `q.gt(col, val)` | greater than | -| `q.gte(col, val)` | greater than or equal | -| `q.lt(col, val)` | less than | -| `q.lte(col, val)` | less than or equal | +Sync stores expose raw selector data because `getSnapshot()` can return the +selector result immediately. -Comparisons apply to index columns, and which combinations are legal depends -on the column order of the index. The rules (equality prefix + one trailing -range) are explained in detail in [Indexes](/database/indexes/). +### Async stores -### OR queries +Async stores expose query state because the first result may require a promise. +Use `createAsyncSelectorStore` when you want an isolated async subscription that +does not read or populate the root selector cache. -To express an OR, return an array of query branches from `where`, or use the -`or(...)` helper. Each branch is scanned and the results are combined. -With `.order(...)`, the combined rows are ordered by the index globally, not by -the order of the OR branches. +Use `createCachedSelectorStoreAsync` when repeated reads for the same selector +and args should share the root selector cache, matching `useAsyncSelector`. ```ts -import { selectFrom, or } from "@will-be-done/hyperdb"; +import { + createAsyncSelectorStore, + createCachedSelectorStoreAsync, +} from "@will-be-done/hyperdb"; + +const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, + defaultValue: [], +}); -selectFrom(tasksTable, "byProjectState").where((q) => - or(q.eq("projectId", "p1").eq("state", "todo"), q.eq("projectId", "p2")), -); +const unsubscribe = store.subscribe(() => { + const snapshot = store.getSnapshot(); + if (snapshot.status === "success") { + console.log(snapshot.data); + } +}); -// equivalent, returning an array directly: -selectFrom(tasksTable, "byProjectState").where((q) => [ - q.eq("projectId", "p1").eq("state", "todo"), - q.eq("projectId", "p2"), -]); +const latest = await store.refetch({ throwOnError: true }); +unsubscribe(); +store.destroy(); ``` -This is the idiom for batched lookups, for example fetching many rows by id in -one query: +Snapshots include `status`, `fetchStatus`, `data`, timestamps, error and +failure counters, placeholder flags, a `promise` for the current run, and the +same loading/refetching booleans returned by `useAsyncSelector`. The first +`getSnapshot()` attempts the maybe-async selector run immediately: if it returns +synchronously, the snapshot is already `"success"`; otherwise it is pending +until the promise resolves. -```ts -selectFrom(tasksTable, "byId").where((q) => ids.map((id) => q.eq("id", id))); -``` +The store subscribes directly to `SubscribableDB`, records the scanned ranges +from each run, skips irrelevant DB changes, collapses overlapping reruns, and +ignores late async results after `destroy()` or unsubscribe. `useAsyncSelector` +uses this cached async store internally, so the same behavior is available +outside React. -When reads go through a [`SubscribableDB`](/runtime/db/), `afterScan` lifecycle -hooks can observe each successful scan with the table, index, where clauses, -select options, and returned rows. +### Cached stores -### `order` and `limit` +The cached store helpers use the root selector cache, so calls with the same +database, selector identity, and serialized args share one subscribed cache +entry. Use `createCachedSelectorStoreSync` for synchronous drivers and +`createCachedSelectorStoreAsync` for async drivers, `HybridDB`, or code that +needs query state snapshots. ```ts -.order("asc") // or "desc"; follows the index's key order -.limit(50) // cap the number of returned rows +import { createCachedSelectorStoreSync } from "@will-be-done/hyperdb"; + +const store = createCachedSelectorStoreSync(db, { + selector: projectTasks, + args: { projectId: "p1" }, +}); + +store.getSnapshot(); // current value +const unsub = store.subscribe(() => { + console.log("changed:", store.getSnapshot()); +}); +// ... later +unsub(); ``` -Ordering follows the B-tree index's natural key order; `"desc"` walks it in -reverse. Hash indexes are for equality lookups and do not provide ordering. +The cache key uses stable argument serialization. Argument key order doesn't +matter: `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` resolve to the same cache entry. + +For a one-off cached read without a subscription, use `selectCachedSync`, +`selectCachedAsync`, or `selectCachedMaybeAsync` with the same `{ selector, args +}` input shape. + +## Range tracking + +When a selector runs, the runtime records every index range it scans: table, +index, and bounds. If the selector calls child selectors, their scanned ranges +are part of the same dependency tree. The result is cached together with those +ranges. + +When an action commits, the [`SubscribableDB`](/runtime/db/) notifies subscribers +with the rows that changed. A cached selector re-runs only if a changed row falls +inside one of the ranges it previously scanned; otherwise the cached value is +reused untouched. + +This means a selector that reads `projectId = "p1"` is unaffected by a write to +`projectId = "p2"`, automatically. You never write invalidation logic. +For inserts, HyperDB checks the new row. For deletes, it checks the old row. For +upserts, it checks both old and new rows, so moving a row from one indexed range +to another invalidates both affected reads. + +With `HybridDB`, a scan may be served from the in-memory cache or may fall +through to the primary store on a missing range. Reactivity tracks the logical +index range either way, independent of which storage tier returned the rows. + +## Caching layers + +Cached selector reads always use the selector cache. If the runtime is +`HybridDB`, there is also an in-memory cache for rows and index ranges. -## Retrieving results +- The selector cache stores the result of running selector logic for one selector + identity and one args object. This matters when the selector does more than a + single scan: it may compose other selectors, branch, map rows, or build a + derived result. +- The HybridDB in-memory cache stores rows and covered index ranges. This matters + when selector args change: another selector run may not reuse the exact + selector result, but it can still read already-loaded ranges from memory + instead of going back to IndexedDB or SQLite. -### Many rows +## Preloading a selector -`yield*`-ing a query returns an array of rows: +`preloadSelectorAsync` runs a selector through the root selector cache without +creating a React subscription. Use it in route loaders or hover prefetches when +you know the selector args a screen is about to render. ```ts -const tasks = - yield * - selectFrom(tasksTable, "byProjectOrder").where((q) => - q.eq("projectId", projectId), - ); +import { preloadSelectorAsync } from "@will-be-done/hyperdb"; + +await preloadSelectorAsync(db, { + selector: projectTasks, + args: { projectId: "p1" }, +}); ``` -### The first row +Preloading is execution-based, not static analysis. HyperDB runs the selector +with the current args and current data, so it warms the ranges that this run +actually touches. If the selector branches on loaded data, a different branch may +read different ranges later. + +After preloading, the selector result can be reused by a later read with the same +selector and args. If a later mutation touches a range the selector read, +HyperDB will refresh it on the next preload or cached read. -`first()` returns the first matching row or `undefined`. `firstOr(fallback)` -returns a fallback instead of `undefined`. +With a `SubscribableDB(new HybridDB(primary, cache))`, the preload warms two +layers. First, HybridDB loads any missing index ranges from the primary store +into its in-memory cache. Then the selector result and its read ranges are +stored in the selector cache. A later `useAsyncSelector` with the same selector +identity and args can reuse that warmed entry when its async run starts, without +doing a synchronous cache read during render. ```ts -const task = - yield * - selectFrom(tasksTable, "byId") - .where((q) => q.eq("id", taskId)) - .first(); - -const stateOrDefault = - yield * - selectFrom(tasksTable, "byId") - .where((q) => q.eq("id", taskId)) - .firstOr({ id: taskId, state: "todo" } as Task); +const categories = await preloadSelectorAsync(db, { + selector: projectCategoriesByProjectId, + args: { projectId }, +}); + +await Promise.all( + categories.map((category) => + preloadSelectorAsync(db, { + selector: projectCategoryCardsForDisplayChildren, + args: { projectCategoryId: category.id }, + }), + ), +); ``` -Both apply a `limit(1)` internally, so they stop after the first match. +Match the render args exactly. The selector identity plus serialized args are +the cache key, so `{ limited: true }` and an omitted `limited` property are +different entries. -## Composing selectors +## Garbage collection -Selectors call other selectors with `yield*`, which lets you build larger reads -from smaller ones. The runtime tracks the index ranges scanned across the whole -tree, so a composed selector stays just as precisely reactive as its parts. +When the last subscriber of a cache entry unsubscribes, the entry is retained for +`gcTime` milliseconds (default `30_000`) before being dropped. This lets a value +survive brief gaps, such as a component unmounting and remounting, without +recomputing. While retained, the entry remains subscribed to DB changes so +non-overlapping mutations can advance its revision without a re-run, and +overlapping mutations can mark it stale for the next read. ```ts -import { selectFrom, v } from "@will-be-done/hyperdb"; -import { selector } from "./builders"; // createSelector() -import { tasksTable } from "./schema"; - -export const projectTasks = selector({ - name: "projectTasks", - args: { projectId: v.string() }, - handler: function* ({ projectId }) { - return yield* selectFrom(tasksTable, "byProjectOrder") - .where((q) => q.eq("projectId", projectId)) - .order("asc"); - }, +// keep an unused entry around for 30s +createCachedSelectorStoreSync(db, { + selector: projectTasks, + args: { projectId: "p1" }, + gcTime: 30_000, }); -const projectDoneTasks = selector({ - name: "projectDoneTasks", - args: { projectId: v.string() }, - handler: function* ({ projectId }) { - return yield* selectFrom(tasksTable, "byProjectState").where((q) => - q.eq("projectId", projectId).eq("state", "done"), - ); - }, +// drop immediately when unsubscribed +createCachedSelectorStoreSync(db, { + selector: projectTasks, + args: { projectId: "p1" }, + gcTime: 0, }); +``` + +## Memoization controls -const projectSummary = selector({ - name: "projectSummary", +Selectors take a `memoization` option: + +```ts +const projectTasks = selector({ + name: "projectTasks", args: { projectId: v.string() }, + memoization: { root: true, selfChild: false }, // defaults handler: function* ({ projectId }) { - const tasks = yield* projectTasks({ projectId }); - const doneTasks = yield* projectDoneTasks({ projectId }); - return { - total: tasks.length, - done: doneTasks.length, - }; + /* ... */ }, }); ``` -In that example, `projectDoneTasks` should use an index such as -`byProjectState = ["projectId", "state"]` instead of filtering the full project -list in JavaScript. Composition keeps code ergonomic, but each selector should -still choose the index that matches the data it needs. +### `root` (default `true`) + +Root memoization is the top-level cache described above. With `root: true`, calls +to `createCachedSelectorStoreSync` (and the React hooks) share a cached, subscribed entry +per args. Set `root: false` to opt out, so each use gets an uncached store that +still tracks ranges and stays reactive, but isn't shared or retained between uses. + +```ts +memoization: { + root: false; +} +``` + +### `selfChild` (default `false`) + +When a selector is used as a child of another selector, `selfChild: true` +memoizes that nested selector's own subtree across the parent's reruns. If a +mutation forces the parent to re-run but doesn't affect this child's ranges, the +child's previous result and ranges are reused instead of recomputed. Turn it on +for expensive nested selectors that change less often than their parents. + +```ts +memoization: { + selfChild: true; +} +``` + +This is separate from root caching. `root` controls whether top-level reads +share a subscribed cache entry by selector args. `selfChild` controls whether a +nested selector can reuse its own previous subtree while a parent selector +re-runs. + +## Subscriptions and revisions + +Under the hood, a `SubscribableDB` keeps a monotonically increasing revision +number and a list of subscribers. Each committed transaction increments the +revision and calls subscribers with `(ops, traits, revision)`, where `ops` are +the `insert` / `upsert` / `delete` operations (including old and new row values). +The selector cache uses this to decide what to re-run. You can subscribe directly +for lower-level needs: + +```ts +const unsub = db.subscribe((ops, traits, revision) => { + // ops: InsertOp[] | UpsertOp[] | DeleteOp[] for this commit +}); +``` -To run selectors, cache selector results, or create subscribed sync/async -selector stores, see -[Selectors & Reactivity](/database/selectors-reactivity/#running-selectors). +## Practical guidance + +- Default to the defaults. Root memoization on, `selfChild` off, `gcTime` 30_000. + This is right for most selectors. +- Keep args minimal and serializable. They form the cache key. Avoid passing + large or unstable objects. +- Prefer indexed reads over filtering large arrays in selector code. Reactivity + is precise when the runtime can see the range you read. +- Use `preloadSelectorAsync` when you know the exact selector args a screen will + render. Use `preloadTables` on `HybridDB` when an entire table should be warm. +- Reach for `selfChild` only when profiling (or the [devtool](/integrations/devtools/)) + shows an expensive nested selector recomputing needlessly. +- Reach for `root: false` for one-off selectors you don't want sharing a + cache entry. diff --git a/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md b/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md deleted file mode 100644 index e811501..0000000 --- a/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -title: Selectors & Reactivity -description: How HyperDB caches selectors, tracks scanned ranges, and re-runs only what a mutation actually affects. -sidebar: - order: 6 ---- - -Selectors aren't just queries; they're the unit of reactivity. HyperDB caches -selector results and re-runs them precisely: only when a mutation touches a range -the selector actually read. This page explains the model and the knobs you can -turn. - -Selectors are ordinary generator code, so they can call other selectors, branch, -loop, and use shared helpers. Reactivity still comes from the indexed reads they -perform: every `selectFrom(...)` scan contributes ranges to the selector run. - -## Running selectors - -Inside React, use [`useSyncSelector` / `useAsyncSelector`](/integrations/react/). -Outside React, run selectors directly with the same `{ selector, args }` input -shape: - -```ts -import { selectAsync, selectSync } from "@will-be-done/hyperdb"; - -// synchronous drivers (in-memory, sync SQLite) -const tasks = selectSync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); - -// asynchronous drivers (IndexedDB, async SQLite, HybridDB) -const tasksAsync = await selectAsync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); -``` - -Use cached selector reads when repeated calls for the same selector and args -should reuse the root selector cache: - -```ts -import { selectCachedMaybeAsync } from "@will-be-done/hyperdb"; - -const tasksOrPromise = selectCachedMaybeAsync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); -const tasks = await Promise.resolve(tasksOrPromise); -``` - -HyperDB exports these selector runners and store creators: - -| Helper | Use | -| -------------------------------- | -------------------------------------------------------------------- | -| `selectSync` | Run a selector once with sync drivers | -| `selectAsync` | Run a selector once with async drivers and `HybridDB` | -| `selectMaybeAsync` | Return a value or a `Promise`, depending on whether execution yields | -| `selectCachedSync` | Run or reuse the root selector cache synchronously | -| `selectCachedAsync` | Async cache-aware selector read | -| `selectCachedMaybeAsync` | Cache-aware read that may return a value or a `Promise` | -| `createSelectorStoreSync` | Create a sync subscribed store with `subscribe()` / `getSnapshot()` | -| `createCachedSelectorStoreSync` | Create a sync subscribed store backed by the root selector cache | -| `createAsyncSelectorStore` | Create an uncached async subscribed store with query state snapshots | -| `createCachedSelectorStoreAsync` | Create an async subscribed store backed by the root selector cache | - -Use `createSelectorStoreSync` when you want a small synchronous external store -for one selector run: - -```ts -import { createSelectorStoreSync } from "@will-be-done/hyperdb"; - -const store = createSelectorStoreSync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); -const unsubscribe = store.subscribe(() => { - console.log(store.getSnapshot()); -}); -``` - -### Async selector stores - -Sync stores expose raw selector data because `getSnapshot()` can return the -selector result immediately. Async stores expose query state because the first -result may require a promise. - -Use `createAsyncSelectorStore` when you want an isolated async subscription that -does not read or populate the root selector cache. Use -`createCachedSelectorStoreAsync` when repeated reads for the same selector and -args should share the root selector cache, matching `useAsyncSelector`. - -```ts -import { - createAsyncSelectorStore, - createCachedSelectorStoreAsync, -} from "@will-be-done/hyperdb"; - -const store = createCachedSelectorStoreAsync(db, { - selector: projectTasks, - args: { projectId: "p1" }, - defaultValue: [], -}); - -const unsubscribe = store.subscribe(() => { - const snapshot = store.getSnapshot(); - if (snapshot.status === "success") { - console.log(snapshot.data); - } -}); - -const latest = await store.refetch({ throwOnError: true }); -unsubscribe(); -store.destroy(); -``` - -Snapshots include `status`, `fetchStatus`, `data`, timestamps, error and -failure counters, placeholder flags, a `promise` for the current run, and the -same loading/refetching booleans returned by `useAsyncSelector`. The first -`getSnapshot()` attempts the maybe-async selector run immediately: if it returns -synchronously, the snapshot is already `"success"`; otherwise it is pending -until the promise resolves. - -The store subscribes directly to `SubscribableDB`, records the scanned ranges -from each run, skips irrelevant DB changes, collapses overlapping reruns, and -ignores late async results after `destroy()` or unsubscribe. `useAsyncSelector` -uses this cached async store internally, so the same behavior is available -outside React. - -The four store helpers form a matrix: - -| Helper | Execution | Root selector cache | -| -------------------------------- | --------- | ------------------- | -| `createSelectorStoreSync` | sync | no | -| `createCachedSelectorStoreSync` | sync | yes | -| `createAsyncSelectorStore` | async | no | -| `createCachedSelectorStoreAsync` | async | yes | - -## Range tracking - -When a selector runs, the runtime records every index range it scans: table, -index, and bounds. If the selector calls child selectors, their scanned ranges -are part of the same dependency tree. The result is cached together with those -ranges. - -When an action commits, the [`SubscribableDB`](/runtime/db/) notifies subscribers -with the rows that changed. A cached selector re-runs only if a changed row falls -inside one of the ranges it previously scanned; otherwise the cached value is -reused untouched. - -This means a selector that reads `projectId = "p1"` is unaffected by a write to -`projectId = "p2"`, automatically. You never write invalidation logic. -For inserts, HyperDB checks the new row. For deletes, it checks the old row. For -upserts, it checks both old and new rows, so moving a row from one indexed range -to another invalidates both affected reads. - -With `HybridDB`, a scan may be served from the in-memory cache or may fall -through to the primary store on a missing range. Reactivity tracks the logical -index range either way, independent of which storage tier returned the rows. - -## Caching a selector - -`createCachedSelectorStoreSync` gives you a subscribable store for a selector + args, -outside of React. (The React hooks are built on it.) - -```ts -import { createCachedSelectorStoreSync } from "@will-be-done/hyperdb"; - -const store = createCachedSelectorStoreSync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); - -store.getSnapshot(); // current value -const unsub = store.subscribe(() => { - console.log("changed:", store.getSnapshot()); -}); -// ... later -unsub(); -``` - -The cache is keyed by the database, the selector identity, and a stable -serialization of the args. Argument key order doesn't matter: -`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` resolve to the same cache entry. - -For a one-off cached read without a subscription, use `selectCachedSync`, -`selectCachedAsync`, or `selectCachedMaybeAsync` with the same `{ selector, args -}` input shape. - -## Caching layers - -Cached selector reads always use the selector cache. If the runtime is -`HybridDB`, there is also an in-memory cache for rows and index ranges. - -- The selector cache stores the result of running selector logic for one selector - identity and one args object. This matters when the selector does more than a - single scan: it may compose other selectors, branch, map rows, or build a - derived result. -- The HybridDB in-memory cache stores rows and covered index ranges. This matters - when selector args change: another selector run may not reuse the exact - selector result, but it can still read already-loaded ranges from memory - instead of going back to IndexedDB or SQLite. - -## Preloading a selector - -`preloadSelectorAsync` runs a selector through the root selector cache without -creating a React subscription. Use it in route loaders or hover prefetches when -you know the selector args a screen is about to render. - -```ts -import { preloadSelectorAsync } from "@will-be-done/hyperdb"; - -await preloadSelectorAsync(db, { - selector: projectTasks, - args: { projectId: "p1" }, -}); -``` - -Preloading is execution-based, not static analysis. HyperDB runs the selector -with the current args and current data, so it warms the ranges that this run -actually touches. If the selector branches on loaded data, a different branch may -read different ranges later. - -After preloading, the selector result can be reused by a later read with the same -selector and args. If a later mutation touches a range the selector read, -HyperDB will refresh it on the next preload or cached read. - -With a `SubscribableDB(new HybridDB(primary, cache))`, the preload warms two -layers. First, HybridDB loads any missing index ranges from the primary store -into its in-memory cache. Then the selector result and its read ranges are -stored in the selector cache. A later `useAsyncSelector` with the same selector -identity and args can reuse that warmed entry when its async run starts, without -doing a synchronous cache read during render. - -```ts -const categories = await preloadSelectorAsync(db, { - selector: projectCategoriesByProjectId, - args: { projectId }, -}); - -await Promise.all( - categories.map((category) => - preloadSelectorAsync(db, { - selector: projectCategoryCardsForDisplayChildren, - args: { projectCategoryId: category.id }, - }), - ), -); -``` - -Match the render args exactly. The selector identity plus serialized args are -the cache key, so `{ limited: true }` and an omitted `limited` property are -different entries. - -## Garbage collection - -When the last subscriber of a cache entry unsubscribes, the entry is retained for -`gcTime` milliseconds (default `30_000`) before being dropped. This lets a value -survive brief gaps, such as a component unmounting and remounting, without -recomputing. While retained, the entry remains subscribed to DB changes so -non-overlapping mutations can advance its revision without a re-run, and -overlapping mutations can mark it stale for the next read. - -```ts -// keep an unused entry around for 30s -createCachedSelectorStoreSync(db, { - selector: projectTasks, - args: { projectId: "p1" }, - gcTime: 30_000, -}); - -// drop immediately when unsubscribed -createCachedSelectorStoreSync(db, { - selector: projectTasks, - args: { projectId: "p1" }, - gcTime: 0, -}); -``` - -## Memoization controls - -Selectors take a `memoization` option: - -```ts -const projectTasks = selector({ - name: "projectTasks", - args: { projectId: v.string() }, - memoization: { root: true, selfChild: false }, // defaults - handler: function* ({ projectId }) { - /* ... */ - }, -}); -``` - -### `root` (default `true`) - -Root memoization is the top-level cache described above. With `root: true`, calls -to `createCachedSelectorStoreSync` (and the React hooks) share a cached, subscribed entry -per args. Set `root: false` to opt out, so each use gets an uncached store that -still tracks ranges and stays reactive, but isn't shared or retained between uses. - -```ts -memoization: { - root: false; -} -``` - -### `selfChild` (default `false`) - -When a selector is used as a child of another selector, `selfChild: true` -memoizes that nested selector's own subtree across the parent's reruns. If a -mutation forces the parent to re-run but doesn't affect this child's ranges, the -child's previous result and ranges are reused instead of recomputed. Turn it on -for expensive nested selectors that change less often than their parents. - -```ts -memoization: { - selfChild: true; -} -``` - -This is separate from root caching. `root` controls whether top-level reads -share a subscribed cache entry by selector args. `selfChild` controls whether a -nested selector can reuse its own previous subtree while a parent selector -re-runs. - -## Subscriptions and revisions - -Under the hood, a `SubscribableDB` keeps a monotonically increasing revision -number and a list of subscribers. Each committed transaction increments the -revision and calls subscribers with `(ops, traits, revision)`, where `ops` are -the `insert` / `upsert` / `delete` operations (including old and new row values). -The selector cache uses this to decide what to re-run. You can subscribe directly -for lower-level needs: - -```ts -const unsub = db.subscribe((ops, traits, revision) => { - // ops: InsertOp[] | UpsertOp[] | DeleteOp[] for this commit -}); -``` - -## Practical guidance - -- Default to the defaults. Root memoization on, `selfChild` off, `gcTime` 30_000. - This is right for most selectors. -- Keep args minimal and serializable. They form the cache key. Avoid passing - large or unstable objects. -- Prefer indexed reads over filtering large arrays in selector code. Reactivity - is precise when the runtime can see the range you read. -- Use `preloadSelectorAsync` when you know the exact selector args a screen will - render. Use `preloadTables` on `HybridDB` when an entire table should be warm. -- Reach for `selfChild` only when profiling (or the [devtool](/integrations/devtools/)) - shows an expensive nested selector recomputing needlessly. -- Reach for `root: false` for one-off selectors you don't want sharing a - cache entry. diff --git a/packages/hyperdb-doc/src/content/docs/database/selectors.md b/packages/hyperdb-doc/src/content/docs/database/selectors.md new file mode 100644 index 0000000..9c68669 --- /dev/null +++ b/packages/hyperdb-doc/src/content/docs/database/selectors.md @@ -0,0 +1,214 @@ +--- +title: Selectors +description: Define selector reads with the selectFrom builder, filters, ordering, limits, and first-result helpers. +sidebar: + order: 4 +--- + +You read data by defining selectors: generator functions that describe what to +read. Inside a selector you build queries with `selectFrom` and `yield*` them. +Selectors can call other selectors but can never write; the runtime rejects any +mutation emitted from a read. + +Every read starts from a named index. That makes the access path explicit in +your application code: `selectFrom(tasksTable, "byProjectOrder")` means "scan +this table through this index", then `.where(...)`, `.order(...)`, and +`.limit(...)` describe the bounds and result shape. + +## A first selector + +```ts +import { selectFrom, v } from "@will-be-done/hyperdb"; +import { selector } from "./builders"; // createSelector() +import { tasksTable } from "./schema"; + +export const projectTasks = selector({ + name: "projectTasks", + args: { projectId: v.string() }, + handler: function* ({ projectId }) { + return yield* selectFrom(tasksTable, "byProjectOrder") + .where((q) => q.eq("projectId", projectId)) + .order("asc"); + }, +}); +``` + +Object-form selectors accept: + +| Field | Description | +| ------------- | ------------------------------------------------------------------------------------ | +| `name` | Required display/debug name (shown in traces) | +| `args` | Validator map for the single args object | +| `handler` | Generator function that does the reading | +| `skipTrace` | `true`, or `{ rootTrace, childTrace }` to skip tracing | +| `memoization` | `{ root?, selfChild? }` cache controls (see [Reading Data](/database/reading-data/)) | + +You can also wrap a bare generator function instead of using the object form. + +## The query builder + +`selectFrom(table, indexName)` returns a builder. You always query through an +index. The builder is immutable: each method returns a new builder. With +`HybridDB`, the same indexed read checks the in-memory cache first; missing +ranges fall through to the primary store and are cached for later reads. + +```ts +selectFrom(tasksTable, "byProjectOrder") + .where((q) => q.eq("projectId", "p1")) + .order("desc") + .limit(20); +``` + +### `where` + +`where` takes a callback that receives a query object. Chain comparison methods +on it; each column you constrain must belong to the index you selected. + +```ts +.where((q) => q.eq("projectId", "p1")) +.where((q) => q.eq("projectId", "p1").gte("orderToken", "m")) +``` + +Available comparisons: + +| Method | Meaning | +| ----------------- | --------------------- | +| `q.eq(col, val)` | equal to | +| `q.gt(col, val)` | greater than | +| `q.gte(col, val)` | greater than or equal | +| `q.lt(col, val)` | less than | +| `q.lte(col, val)` | less than or equal | + +Comparisons apply to index columns, and which combinations are legal depends +on the column order of the index. The rules (equality prefix + one trailing +range) are explained in detail in [Indexes](/database/indexes/). + +### OR queries + +To express an OR, return an array of query branches from `where`, or use the +`or(...)` helper. Each branch is scanned and the results are combined. +With `.order(...)`, the combined rows are ordered by the index globally, not by +the order of the OR branches. + +```ts +import { selectFrom, or } from "@will-be-done/hyperdb"; + +selectFrom(tasksTable, "byProjectState").where((q) => + or(q.eq("projectId", "p1").eq("state", "todo"), q.eq("projectId", "p2")), +); + +// equivalent, returning an array directly: +selectFrom(tasksTable, "byProjectState").where((q) => [ + q.eq("projectId", "p1").eq("state", "todo"), + q.eq("projectId", "p2"), +]); +``` + +This is the idiom for batched lookups, for example fetching many rows by id in +one query: + +```ts +selectFrom(tasksTable, "byId").where((q) => ids.map((id) => q.eq("id", id))); +``` + +When reads go through a [`SubscribableDB`](/runtime/db/), `afterScan` lifecycle +hooks can observe each successful scan with the table, index, where clauses, +select options, and returned rows. + +### `order` and `limit` + +```ts +.order("asc") // or "desc"; follows the index's key order +.limit(50) // cap the number of returned rows +``` + +Ordering follows the B-tree index's natural key order; `"desc"` walks it in +reverse. Hash indexes are for equality lookups and do not provide ordering. + +## Retrieving results + +### Many rows + +`yield*`-ing a query returns an array of rows: + +```ts +const tasks = + yield * + selectFrom(tasksTable, "byProjectOrder").where((q) => + q.eq("projectId", projectId), + ); +``` + +### The first row + +`first()` returns the first matching row or `undefined`. `firstOr(fallback)` +returns a fallback instead of `undefined`. + +```ts +const task = + yield * + selectFrom(tasksTable, "byId") + .where((q) => q.eq("id", taskId)) + .first(); + +const stateOrDefault = + yield * + selectFrom(tasksTable, "byId") + .where((q) => q.eq("id", taskId)) + .firstOr({ id: taskId, state: "todo" } as Task); +``` + +Both apply a `limit(1)` internally, so they stop after the first match. + +## Composing selectors + +Selectors call other selectors with `yield*`, which lets you build larger reads +from smaller ones. The runtime tracks the index ranges scanned across the whole +tree, so a composed selector stays just as precisely reactive as its parts. + +```ts +import { selectFrom, v } from "@will-be-done/hyperdb"; +import { selector } from "./builders"; // createSelector() +import { tasksTable } from "./schema"; + +export const projectTasks = selector({ + name: "projectTasks", + args: { projectId: v.string() }, + handler: function* ({ projectId }) { + return yield* selectFrom(tasksTable, "byProjectOrder") + .where((q) => q.eq("projectId", projectId)) + .order("asc"); + }, +}); + +const projectDoneTasks = selector({ + name: "projectDoneTasks", + args: { projectId: v.string() }, + handler: function* ({ projectId }) { + return yield* selectFrom(tasksTable, "byProjectState").where((q) => + q.eq("projectId", projectId).eq("state", "done"), + ); + }, +}); + +const projectSummary = selector({ + name: "projectSummary", + args: { projectId: v.string() }, + handler: function* ({ projectId }) { + const tasks = yield* projectTasks({ projectId }); + const doneTasks = yield* projectDoneTasks({ projectId }); + return { + total: tasks.length, + done: doneTasks.length, + }; + }, +}); +``` + +In that example, `projectDoneTasks` should use an index such as +`byProjectState = ["projectId", "state"]` instead of filtering the full project +list in JavaScript. Composition keeps code ergonomic, but each selector should +still choose the index that matches the data it needs. + +To run selectors, cache selector results, or create subscribed sync/async +selector stores, see [Reading Data](/database/reading-data/#running-selectors). diff --git a/packages/hyperdb-doc/src/content/docs/integrations/react.md b/packages/hyperdb-doc/src/content/docs/integrations/react.md index 2cf78a9..64445e7 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -12,7 +12,7 @@ reads. React 18 is a peer dependency. HyperDB hands your components plain, immutable rows: frozen data, never proxies. There is no `observer()` wrapper to remember and nothing leaking into your view layer. The selector hooks use HyperDB's -[range-tracked reads](/database/selectors-reactivity/), so a component re-runs +[range-tracked reads](/database/reading-data/), so a component re-runs only when a mutation touches a range its selector actually scanned, giving fine-grained updates that compose with React's rendering model directly. @@ -187,7 +187,7 @@ Options: ## Writing -### `useDispatch` / `useAsyncDispatch` +### `useSyncDispatch` / `useAsyncDispatch` Return a function that dispatches an action against the context database. @@ -212,7 +212,7 @@ function AddButton({ projectId }: { projectId: string }) { ``` Use `useAsyncDispatch` with `HybridDB`, IndexedDB, and async SQLite. Use -`useDispatch` only with synchronous drivers. With HybridDB, writes update the +`useSyncDispatch` only with synchronous drivers. With HybridDB, writes update the in-memory cache first so subscribed UI can respond immediately, then flush to the primary store in order. @@ -243,6 +243,6 @@ Use `useSelectSync` for synchronous drivers. | `useAsyncSelector(opts)` | query-style result object | `HybridDB`, IndexedDB, async SQLite | | `useSyncSelector(opts)` | the selector result | in-memory and sync SQLite | | `useAsyncDispatch()` | `(action) => Promise` | `HybridDB`, IndexedDB, async SQLite | -| `useDispatch()` | `(action) => TReturn` | in-memory and sync SQLite | +| `useSyncDispatch()` | `(action) => TReturn` | in-memory and sync SQLite | | `useSelectAsync()` | `({ selector, args }) => Promise` | one-off read, async runtimes | | `useSelectSync()` | `({ selector, args }) => TReturn` | one-off read, sync runtimes | diff --git a/packages/hyperdb-doc/src/content/docs/runtime/db.md b/packages/hyperdb-doc/src/content/docs/runtime/db.md index 8414e85..57c7b05 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/db.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/db.md @@ -13,7 +13,7 @@ commands. | Runtime shape | Use when | Tradeoff | | ------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DB` | You only need `selectSync`, `insert`, `upsert`, `deleteRows`, and transactions. | Lowest overhead. No subscriptions, reactive selector cache, revisions, or lifecycle hooks. | -| `SubscribableDB` + sync driver | Your reactive app can load its working state into memory. | Best interactive path: selectors and actions can stay synchronous with `useSyncSelector`, `useDispatch`, `selectSync`, and `syncDispatch`. | +| `SubscribableDB` + sync driver | Your reactive app can load its working state into memory. | Best interactive path: selectors and actions can stay synchronous with `useSyncSelector`, `useSyncDispatch`, `selectSync`, and `syncDispatch`. | | `SubscribableDB` + async driver | Your reactive app should keep memory low and read directly from IndexedDB or async SQLite. | Uses async selectors/actions. Simpler than `HybridDB`, but every read follows the async driver path. | | `SubscribableDB` + `HybridDB` | Local-first browser apps that want persistent storage plus fast reads for hot data. | Uses async APIs, but reads check the in-memory cache first. Missing index ranges fall through to the primary store, then get cached for next time. Writes update the cache first for immediate UI response, then flush to the primary store. | @@ -79,7 +79,7 @@ const unsub = db.subscribe((ops, traits, revision) => { }); ``` -This is the mechanism the [selector cache](/database/selectors-reactivity/) uses. +This is the mechanism the [selector cache](/database/reading-data/) uses. ### Lifecycle hooks diff --git a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md index da5760c..913f3f9 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md @@ -343,7 +343,7 @@ and failure logs easier to correlate in browser consoles. Use a synchronous driver when the whole read path can stay in memory or in a sync SQLite binding. That gives you `selectSync`, `syncDispatch`, `useSyncSelector`, -and `useDispatch` with no promises. +and `useSyncDispatch` with no promises. Use an asynchronous driver when storage itself is asynchronous, such as IndexedDB, async SQLite, or `HybridDB`. `HybridDB` can still serve warm reads diff --git a/packages/hyperdb-doc/src/content/docs/start/how-it-works.md b/packages/hyperdb-doc/src/content/docs/start/how-it-works.md index 34fb6ee..697630e 100644 --- a/packages/hyperdb-doc/src/content/docs/start/how-it-works.md +++ b/packages/hyperdb-doc/src/content/docs/start/how-it-works.md @@ -85,7 +85,7 @@ Because commands are _descriptions_, the same selector or action can be executed synchronously against an in-memory driver, asynchronously against IndexedDB or async SQLite, or through a `HybridDB` that reads from memory first and falls through to a primary store on cache misses. The code does not change. See -[Reading Data](/database/reading-data/) and [Writing Data](/database/writing-data/). +[Selectors](/database/selectors/) and [Actions](/database/actions/). Commands also compose with normal `yield*` calls. A selector can call another selector, an action can read through selectors, and a larger action can call @@ -174,7 +174,7 @@ The same mechanism gives the devtool useful traces: it can show which selector ran, which indexes it scanned, and whether HybridDB reads came from `in-mem` or `persist`. -More details in [Selectors & Reactivity](/database/selectors-reactivity/). +More details in [Reading Data](/database/reading-data/). ## Sync vs. async execution @@ -182,12 +182,12 @@ Every storage path is either synchronous (in-memory, sync SQLite) or asynchronous (IndexedDB, async SQLite, or `HybridDB` when a read misses memory). The runtime exposes a matching pair of entry points for almost everything: -| Sync | Async | -| ------------------------------------ | --------------------------------------- | -| `syncDispatch(db, action(args))` | `asyncDispatch(db, action(args))` | -| `selectSync(db, { selector, args })` | `selectAsync(db, { selector, args })` | -| `execSync(generator)` | `execAsync(generator)` | -| `useSyncSelector` / `useDispatch` | `useAsyncSelector` / `useAsyncDispatch` | +| Sync | Async | +| ------------------------------------- | --------------------------------------- | +| `syncDispatch(db, action(args))` | `asyncDispatch(db, action(args))` | +| `selectSync(db, { selector, args })` | `selectAsync(db, { selector, args })` | +| `execSync(generator)` | `execAsync(generator)` | +| `useSyncSelector` / `useSyncDispatch` | `useAsyncSelector` / `useAsyncDispatch` | Use the sync variants with the in-memory and synchronous SQLite drivers; use the async variants with IndexedDB, async SQLite, and `HybridDB` (because a selector diff --git a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md index c9b1e26..63e7d79 100644 --- a/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md +++ b/packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md @@ -12,8 +12,9 @@ Good deep links: - Quickstart: https://hyperdb.will-be-done.app/start/quickstart/ - Schemas: https://hyperdb.will-be-done.app/database/schemas/ +- Selectors: https://hyperdb.will-be-done.app/database/selectors/ - Reading data: https://hyperdb.will-be-done.app/database/reading-data/ -- Writing data: https://hyperdb.will-be-done.app/database/writing-data/ +- Actions: https://hyperdb.will-be-done.app/database/actions/ - React: https://hyperdb.will-be-done.app/integrations/react/ - Drivers: https://hyperdb.will-be-done.app/runtime/drivers/ @@ -35,7 +36,7 @@ and views that should re-run only when the exact index ranges they read change. actions, runtimes, command executors, `HybridDB`, `SubscribableDB`, tracing setup, and core types. - `@will-be-done/hyperdb/react`: `DBProvider`, `useDB`, `useOptionalDB`, - `useSyncSelector`, `useAsyncSelector`, `useDispatch`, `useAsyncDispatch`, + `useSyncSelector`, `useAsyncSelector`, `useSyncDispatch`, `useAsyncDispatch`, `useSelectSync`, and `useSelectAsync`. - `@will-be-done/hyperdb/tracing`: tracing store, tracer configuration, and trace metadata helpers. @@ -361,7 +362,7 @@ flags, and `refetch()`. Its initial snapshot attempts the selector once: sync results are visible immediately, while promise results show `defaultValue`, `initialData`, or `placeholderData` until that promise resolves. `defaultValue` may be a value or a zero-argument function that returns the value. Use -`useSyncSelector` / `useDispatch` only for purely synchronous drivers. +`useSyncSelector` / `useSyncDispatch` only for purely synchronous drivers. ## Rules Of Thumb diff --git a/packages/hyperdb-doc/src/content/docs/start/quickstart.md b/packages/hyperdb-doc/src/content/docs/start/quickstart.md index c63347a..197249b 100644 --- a/packages/hyperdb-doc/src/content/docs/start/quickstart.md +++ b/packages/hyperdb-doc/src/content/docs/start/quickstart.md @@ -125,7 +125,7 @@ export async function createAppDB() { If your whole app state can be loaded into memory at startup, you may not need `HybridDB`. A plain `new SubscribableDB(new DB(new BptreeInmemDriver()))` keeps reads and writes synchronous, so you can use `useSyncSelector`, -`useDispatch`, `selectSync`, and `syncDispatch` without promise or cache-miss +`useSyncDispatch`, `selectSync`, and `syncDispatch` without promise or cache-miss tradeoffs. ## 6. Read and write outside React @@ -217,7 +217,7 @@ from the primary store. ## Where to next - [Schemas](/database/schemas/): tables, validators, tagged unions. -- [Reading Data](/database/reading-data/) and [Indexes](/database/indexes/): +- [Selectors](/database/selectors/) and [Indexes](/database/indexes/): the full query builder. -- [Writing Data](/database/writing-data/): actions, mutations, transactions. +- [Actions](/database/actions/): actions, mutations, transactions. - [Storage Drivers](/runtime/drivers/): persist to SQLite or IndexedDB. diff --git a/packages/hyperdb-doc/src/content/docs/start/why.md b/packages/hyperdb-doc/src/content/docs/start/why.md index 62e3f35..5ffc4b5 100644 --- a/packages/hyperdb-doc/src/content/docs/start/why.md +++ b/packages/hyperdb-doc/src/content/docs/start/why.md @@ -63,7 +63,7 @@ HyperDB tracks dependencies at the level of indexed ranges. Because every selector reads through indexes, the runtime records exactly which index ranges it scanned. When a mutation commits, only the selectors whose ranges actually contain the changed rows re-run. See -[Selectors & Reactivity](/database/selectors-reactivity/). A write to +[Reading Data](/database/reading-data/). A write to `projectId = "p2"` does not wake a selector that read `projectId = "p1"`. You get fine-grained invalidation without mutable observable objects. HyperDB diff --git a/packages/hyperdb-doc/summary.md b/packages/hyperdb-doc/summary.md index 03d5297..23d78b7 100644 --- a/packages/hyperdb-doc/summary.md +++ b/packages/hyperdb-doc/summary.md @@ -72,7 +72,7 @@ check the matching docs below and also check the root `README.md`. reference. Lists supported validators and TypeScript types, composite validator helpers, binary data behavior, indexable values, `v.any()` rules, `undefined` handling, and date/time modeling guidance. -- `src/content/docs/database/reading-data.md`: Selector and query-builder +- `src/content/docs/database/selectors.md`: Selector and query-builder guide. Covers selector object fields, `selectFrom`, immutable query builders, `where` comparisons, OR queries with `or(...)` or arrays, ordering, limits, many-row results, `first()` and `firstOr()`, composing selectors, and links @@ -81,14 +81,12 @@ check the matching docs below and also check the root `README.md`. shapes. Covers declaring B-tree and hash indexes, built-in `byId`, equality, range, ordering, composite-key support, indexable value rules, equality-prefix and trailing-range rules, query-builder validation errors, and index ordering. -- `src/content/docs/database/selectors-reactivity.md`: Reactive selector cache. - Covers running selectors with `selectSync`/`selectAsync`, cached selector - reads, range tracking, selector stores, `createSelectorStoreSync`, - `createCachedSelectorStoreSync`, `createAsyncSelectorStore`, - `createCachedSelectorStoreAsync`, garbage collection, selector memoization - controls (`root` and `selfChild`), subscriptions, revisions, and practical - guidance for writing selectors that invalidate precisely. -- `src/content/docs/database/writing-data.md`: Actions and mutations. Covers +- `src/content/docs/database/reading-data.md`: Runtime reads and reactive + selector cache. Covers one-off selector reads, dedicated selector stores with + sync, async, and cached variants, range tracking, caching layers, preloading, + garbage collection, selector memoization controls (`root` and `selfChild`), + subscriptions, revisions, and practical guidance for reading data reactively. +- `src/content/docs/database/actions.md`: Actions and mutations. Covers defining actions, `insert`, `upsert`, `deleteRows`, dispatching with `syncDispatch`/`asyncDispatch`, why selectors cannot write, transaction behavior and rollback, and bulk write guidance. @@ -108,9 +106,10 @@ check the matching docs below and also check the root `README.md`. ## Integrations - `src/content/docs/integrations/react.md`: React integration guide. Covers - `DBProvider`, `useDB`, `useSyncSelector`, `useAsyncSelector`, `useDispatch`, - `useAsyncDispatch`, `useSelectSync`, `useSelectAsync`, selector options, default - values, `enabled`, the React Query-style async selector result, its + `DBProvider`, `useDB`, `useSyncSelector`, `useAsyncSelector`, + `useSyncDispatch`, `useAsyncDispatch`, `useSelectSync`, `useSelectAsync`, + selector options, default values, `enabled`, the React Query-style async + selector result, its `createCachedSelectorStoreAsync` foundation, and the full hook reference table. - `src/content/docs/integrations/devtools.md`: Devtool and tracing guide. Covers @@ -131,11 +130,11 @@ check the matching docs below and also check the root `README.md`. - Schema, validator, type helper, table, or index changes: check `database/schemas.md`, `database/data-types.md`, `database/indexes.md`, `start/quickstart.md`, and the root `README.md`. -- Query builder or selector API changes: check `database/reading-data.md`, - `database/selectors-reactivity.md`, `start/how-it-works.md`, React docs if - hooks are affected, and the root `README.md`. +- Query builder or selector API changes: check `database/selectors.md`, + `database/reading-data.md`, `start/how-it-works.md`, React docs if hooks are + affected, and the root `README.md`. - Action, mutation, transaction, hook, trait, or dispatch changes: check - `database/writing-data.md`, `runtime/db.md`, guides that use hooks/persistence, + `database/actions.md`, `runtime/db.md`, guides that use hooks/persistence, and the root `README.md`. - Driver, storage codec, sync/async, `HybridDB`, IndexedDB, or SQLite changes: check `runtime/drivers.md`, `runtime/db.md`, `start/introduction.md`, diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 5a910bd..5255d93 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setHyperDBHookDepsForTest, + useSyncDispatch, useAsyncSelector, useSyncSelector, } from "./hooks"; @@ -22,6 +23,7 @@ const mocks = { selectCachedMaybeAsync: vi.fn(), selectAsync: vi.fn(), selectMaybeAsync: vi.fn(), + syncDispatch: vi.fn(), isNeedToRerunRange: vi.fn(), stableSerializeSelectorArgs: vi.fn(), }; @@ -194,6 +196,7 @@ describe("useAsyncSelector", () => { mocks.selectCachedMaybeAsync.mockReset(); mocks.selectAsync.mockReset(); mocks.selectMaybeAsync.mockReset(); + mocks.syncDispatch.mockReset(); mocks.isNeedToRerunRange.mockReset(); mocks.stableSerializeSelectorArgs.mockReset(); mocks.stableSerializeSelectorArgs.mockReturnValue("args-key"); @@ -206,6 +209,7 @@ describe("useAsyncSelector", () => { mocks.selectCachedMaybeAsync(...args), selectAsync: (...args) => mocks.selectAsync(...args), selectMaybeAsync: (...args) => mocks.selectMaybeAsync(...args), + syncDispatch: (...args) => mocks.syncDispatch(...args), isNeedToRerunRange: (...args) => mocks.isNeedToRerunRange(...args), stableSerializeSelectorArgs: (...args) => mocks.stableSerializeSelectorArgs(...args), @@ -320,6 +324,18 @@ describe("useAsyncSelector", () => { reactHooks.cleanup(); }); + it("dispatches sync actions against the context database", () => { + const action = (function* action() { + return "unused"; + })(); + mocks.syncDispatch.mockReturnValue("created"); + + const dispatch = useSyncDispatch(); + + expect(dispatch(action)).toBe("created"); + expect(mocks.syncDispatch).toHaveBeenCalledWith(mocks.db, action); + }); + it("collapses overlapping subscription reruns and applies only the latest async result", async () => { const first = deferred(); const second = deferred(); diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index c8d42f3..a6032fb 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -375,7 +375,7 @@ export function useAsyncSelector< return result; } -export function useDispatch() { +export function useSyncDispatch() { const db = hookDeps.useDB(); return hookDeps.useCallback( From ef8ff80cc41f1a54fa8663b36cb3e3f74bdf34d8 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Jul 2026 12:20:22 +0300 Subject: [PATCH 4/4] feat: bump --- .changeset/metal-cows-search.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/metal-cows-search.md diff --git a/.changeset/metal-cows-search.md b/.changeset/metal-cows-search.md new file mode 100644 index 0000000..2c0459e --- /dev/null +++ b/.changeset/metal-cows-search.md @@ -0,0 +1,6 @@ +--- +"@will-be-done/hyperdb": minor +"@will-be-done/hyperdb-devtool": minor +--- + +improve hook