diff --git a/README.md b/README.md index c02022d..cfbe186 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,16 @@ state libraries start to strain: to persistent storage on a miss, then cache the covered index range for next time. Cache fills, write-through mutations, and transactions are serialized per HybridDB instance so async selectors and actions do not overlap against - the in-memory cache tier. + the in-memory cache tier. Drivers explicitly report whether selector readonly + transactions are supported; enabled drivers use `beginTx("readonly")` for + scoped reuse. With an IndexedDB primary, that readonly transaction starts + only when the persistent tier is actually read. - **Synchronous on the frontend.** Against the in-memory driver, selectors and actions execute **synchronously** (no `await`, no microtask hop), so a click updates the store and the UI in the same tick. `useAsyncSelector` keeps this fast path when a run completes from memory, then promotes to async only if a - command yields a promise. + command yields a promise. Its async React API returns a React Query-style + object with `data`, `status`, `error`, fetching flags, and `refetch()`. - **JavaScript selectors and actions.** Selectors and actions are ordinary JS: loops, conditionals, function calls. You get fast indexed lookups underneath, not a query language to learn. diff --git a/packages/hyperdb-demo/src/BenchmarkApp.tsx b/packages/hyperdb-demo/src/BenchmarkApp.tsx index 41a9f4b..26c7b0a 100644 --- a/packages/hyperdb-demo/src/BenchmarkApp.tsx +++ b/packages/hyperdb-demo/src/BenchmarkApp.tsx @@ -60,15 +60,16 @@ export function BenchmarkApp() { isWorking, setIsWorking, } = benchmarkState; - const dashboard = - useAsyncSelector({ - selector: getDashboardSnapshot, - args: { - taskLimit, - projectLimit, - selectedProjectId: benchmarkState.selectedProjectId, - }, - }) ?? EMPTY_DASHBOARD_SNAPSHOT; + const { data: dashboard } = useAsyncSelector({ + selector: getDashboardSnapshot, + args: { + taskLimit, + projectLimit, + selectedProjectId: benchmarkState.selectedProjectId, + }, + placeholderData: (previousDashboard) => + previousDashboard ?? EMPTY_DASHBOARD_SNAPSHOT, + }); const storeMode = getStoredMode(); const persistence = usePersistence(); 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 c907d9b..db411fa 100644 --- a/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md +++ b/packages/hyperdb-doc/src/content/docs/database/selectors-reactivity.md @@ -58,8 +58,6 @@ initCachedSelector(db, projectTasks, { projectId: "p1" }, { gcTime: 30_000 }); initCachedSelector(db, projectTasks, { projectId: "p1" }, { gcTime: 0 }); ``` -In React, pass `gcTime` to [`useSyncSelector`](/integrations/react/). - ## Memoization controls Selectors take a `memoization` option: diff --git a/packages/hyperdb-doc/src/content/docs/integrations/react.md b/packages/hyperdb-doc/src/content/docs/integrations/react.md index d7ecb15..9dc4d54 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -66,32 +66,71 @@ function Tasks({ projectId }: { projectId: string }) { Options: -| Option | Description | -| -------------- | ------------------------------------------------------------------------------------------------ | -| `selector` | The selector to run | -| `args` | Its arguments (also the cache key) | -| `defaultValue` | Value returned before the first result / when disabled | -| `enabled` | Set `false` to skip running; returns `defaultValue` | -| `gcTime` | Override the cache [garbage-collection time](/database/selectors-reactivity/#garbage-collection) | +| Option | Description | +| -------------- | ------------------------------------------------------ | +| `selector` | The selector to run | +| `args` | Its arguments (also the cache key) | +| `defaultValue` | Value returned before the first result / when disabled | +| `enabled` | Set `false` to skip running; returns `defaultValue` | ### `useAsyncSelector` -For asynchronous drivers (IndexedDB, async SQLite). Same shape, but the result -arrives asynchronously, so it returns `defaultValue` (or `undefined`) until the -first run resolves, and re-runs on relevant changes. +For asynchronous drivers (IndexedDB, async SQLite). It accepts the same +`selector` and `args` identity as `useSyncSelector`, but returns a +React Query-style result object so loading, error, and manual refetch states are +explicit. Each run starts synchronously. If the selector completes from memory or cache, the result is applied in the same tick; if a command yields a promise, that run continues asynchronously. ```tsx -const tasks = useAsyncSelector({ +const { + data: tasks = [], + error, + isFetching, + isLoading, + isError, + refetch, + status, +} = useAsyncSelector({ selector: projectTasks, args: { projectId }, defaultValue: [], }); ``` +Options: + +| Option | Description | +| ---------------------- | -------------------------------------------------------------------------- | +| `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 | +| `initialData` | Initial successful data for the result | +| `initialDataUpdatedAt` | Timestamp for `initialData` | +| `placeholderData` | Temporary data while the selector is still pending | +| `subscribed` | Set `false` to avoid automatic runs and DB subscriptions for this instance | +| `throwOnError` | Throw render-phase errors to an error boundary when `true` or a predicate | + +Returns: + +| Field | Description | +| ----------------------------------------------------- | ------------------------------------------------------------------------------- | +| `data` | Last successful selector result, placeholder data, initial data, or `undefined` | +| `status` | `"pending"`, `"success"`, or `"error"` | +| `fetchStatus` | `"fetching"` or `"idle"` (`"paused"` is reserved for query compatibility) | +| `error` | Last selector error, or `null` | +| `dataUpdatedAt` / `errorUpdatedAt` | Timestamps for the last success or error | +| `isPending` / `isSuccess` / `isError` | Status booleans | +| `isFetching` / `isLoading` / `isRefetching` | Fetching booleans, matching React Query naming | +| `isLoadingError` / `isRefetchError` | Distinguish first-load failures from refresh failures | +| `isPlaceholderData` / `isStale` / `isEnabled` | Extra query-state booleans | +| `failureCount` / `failureReason` / `errorUpdateCount` | Failure counters and reason | +| `promise` | Promise for the current run's data | +| `refetch(options)` | Manually rerun the selector; pass `{ throwOnError: true }` to reject on error | + ## Writing ### `useDispatch` / `useAsyncDispatch` @@ -141,7 +180,7 @@ const handleClick = () => { | ------------------------ | ------------------------------ | ---------------------------- | | `useDB()` | the `SubscribableDB` | accessing the DB directly | | `useSyncSelector(opts)` | the selector result | reactive read, sync drivers | -| `useAsyncSelector(opts)` | the result or default | reactive read, async drivers | +| `useAsyncSelector(opts)` | React Query-style result | reactive read, async drivers | | `useDispatch()` | `(action) => TReturn` | write, sync drivers | | `useAsyncDispatch()` | `(action) => Promise` | write, async drivers | | `useSelect()` | `(gen) => TReturn` | one-off read, sync drivers | diff --git a/packages/hyperdb-doc/src/content/docs/runtime/db.md b/packages/hyperdb-doc/src/content/docs/runtime/db.md index ca798fe..7fdde0e 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/db.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/db.md @@ -114,7 +114,9 @@ later reads. Empty misses are cached too. Limited B-tree reads cache the covered prefix or suffix when the runtime can prove the returned rows are enough to answer the same limited query from memory. With an IndexedDB primary, this means no readonly IndexedDB transaction is opened until the selector actually falls -through to the persisted tier. +through to the persisted tier. If the persistent tier is read, readonly +transaction reuse stays scoped to that selector run, so concurrent selector runs +do not share one IndexedDB transaction. ```ts import { DB, HybridDB, SubscribableDB, execAsync } from "@will-be-done/hyperdb"; @@ -138,6 +140,10 @@ Writes go to both tiers in the same operation. That means cached rows stay current immediately, while uncached ranges still load lazily on first access. Transactions open transactions against both tiers; scan coverage discovered inside a transaction is published to the outer cache only after commit. +Drivers explicitly report whether selector-scoped readonly transactions are +supported. When they are, HyperDB uses `beginTx("readonly")`; HybridDB keeps +that context lazy until a selector misses the cache and reads the persistent +tier. HybridDB serializes cache fills, write-through mutations, coverage updates, and transaction lifetimes per instance. This keeps async selector misses and actions diff --git a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md index a98568a..1f904e7 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md @@ -311,9 +311,12 @@ await asyncDispatch( The IndexedDB driver uses the same storage encoding and sort-key ordering as the SQLite driver, so data and index semantics are consistent across the two -persistent backends. Selector reads use readonly IndexedDB transactions; when -multiple scans happen while the browser keeps a readonly transaction active, the -driver reuses it instead of opening one transaction per scan. +persistent backends. IndexedDB reports selector readonly transaction support, +so selector reads use `beginTx("readonly")`; when multiple scans happen inside +one selector run while the browser keeps a readonly transaction active, the +driver reuses it instead of opening one transaction per scan. Concurrent +selector runs get separate readonly transactions, and an inactive readonly +transaction is reopened once for the current scan. ## Sync vs. async, in practice diff --git a/packages/hyperdb-doc/src/content/docs/start/quickstart.md b/packages/hyperdb-doc/src/content/docs/start/quickstart.md index ef60c3f..6becc2c 100644 --- a/packages/hyperdb-doc/src/content/docs/start/quickstart.md +++ b/packages/hyperdb-doc/src/content/docs/start/quickstart.md @@ -183,6 +183,10 @@ export function App() { The list re-renders automatically whenever a `createTask` (or any mutation touching the queried range) commits. +For async drivers, use `useAsyncSelector` instead. It keeps the same +`selector`/`args` input and returns a React Query-style object with `data`, +`status`, `error`, fetching flags, and `refetch()`. + ## Where to next - [Schemas](/database/schemas/): tables, validators, tagged unions. diff --git a/packages/hyperdb-doc/summary.md b/packages/hyperdb-doc/summary.md index 0bc0a8f..bdac24d 100644 --- a/packages/hyperdb-doc/summary.md +++ b/packages/hyperdb-doc/summary.md @@ -103,7 +103,8 @@ 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`, `useSelect`, `useAsyncSelect`, selector options, default - values, `enabled`, `gcTime`, and the full hook reference table. + values, `enabled`, the React Query-style async selector result, 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/README.md b/packages/hyperdb/README.md index 7d52633..3f9d619 100644 --- a/packages/hyperdb/README.md +++ b/packages/hyperdb/README.md @@ -37,7 +37,8 @@ state libraries start to strain: actions execute **synchronously** (no `await`, no microtask hop), so a click updates the store and the UI in the same tick. `useAsyncSelector` keeps this fast path when a run completes from memory, then promotes to async only if a - command yields a promise. + command yields a promise. Its async React API returns a React Query-style + object with `data`, `status`, `error`, fetching flags, and `refetch()`. - **JavaScript selectors and actions.** Selectors and actions are ordinary JS: loops, conditionals, function calls. You get fast indexed lookups underneath, not a query language to learn. diff --git a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts index 5e1a5fa..22e18ea 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { SubscribableDB, Op } from "../../runtime/subscribable-db"; import { execAsync, execMaybeAsync, execSync } from "../../core/executor"; +import type { DBCmd } from "../async"; import type { HyperDB } from "../../core/contracts"; import { deepFreeze } from "../../deep-freeze"; import type { Row } from "../../core/primitives"; @@ -348,6 +349,25 @@ type RunSelectorOptions = Pick; const makeVisited = (options: RunSelectorOptions): ChildVisited | undefined => options.childMemo ? new Map() : undefined; +function* runCommandGeneratorWithReadonlyTransaction( + db: HyperDB, + gen: Generator, + options: CommandRunnerOptions, +): Generator { + const tx = db.canUseReadonlyTransactionsForSelectors() + ? yield* db.beginTx("readonly") + : undefined; + const runnerDB = tx ?? db; + + try { + return yield* runCommandGenerator(runnerDB, gen, options); + } finally { + if (tx) { + yield* tx.rollback(); + } + } +} + export function runSelector( db: HyperDB, gen: () => Generator, @@ -358,7 +378,11 @@ export function runSelector( const visited = makeVisited(options); const result = execSync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), + runCommandGeneratorWithReadonlyTransaction(db, gen(), { + ...options, + selectRangeCmds, + visited, + }), ); if (options.childMemo && visited) { pruneChildMemo(options.childMemo, visited); @@ -376,7 +400,11 @@ export async function runSelectorAsync( const visited = makeVisited(options); const result = await execAsync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), + runCommandGeneratorWithReadonlyTransaction(db, gen(), { + ...options, + selectRangeCmds, + visited, + }), ); if (options.childMemo && visited) { pruneChildMemo(options.childMemo, visited); @@ -394,7 +422,11 @@ export function runSelectorMaybeAsync( const visited = makeVisited(options); const result = execMaybeAsync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), + runCommandGeneratorWithReadonlyTransaction(db, gen(), { + ...options, + selectRangeCmds, + visited, + }), ); if (result instanceof Promise) { diff --git a/packages/hyperdb/src/hyperdb/core/contracts.ts b/packages/hyperdb/src/hyperdb/core/contracts.ts index 5910946..af884bb 100644 --- a/packages/hyperdb/src/hyperdb/core/contracts.ts +++ b/packages/hyperdb/src/hyperdb/core/contracts.ts @@ -1,4 +1,5 @@ import type { DBCmd } from "../commands/async"; +import type { DBTransactionMode } from "./driver"; import type { CodecOptions } from "../storage/codec"; import type { ExtractIndexes, @@ -36,8 +37,9 @@ export interface HyperDB { getDBName?(): string | undefined; getTracer?(): HyperDBTracerOption | undefined; getOptions?(): CodecOptions; + canUseReadonlyTransactionsForSelectors(): boolean; - beginTx(): Generator; + beginTx(mode?: DBTransactionMode): Generator; // eslint-disable-next-line @typescript-eslint/no-explicit-any loadTables(tables: TableDefinition[]): Generator; } diff --git a/packages/hyperdb/src/hyperdb/core/driver.ts b/packages/hyperdb/src/hyperdb/core/driver.ts index 297a802..e3e362f 100644 --- a/packages/hyperdb/src/hyperdb/core/driver.ts +++ b/packages/hyperdb/src/hyperdb/core/driver.ts @@ -3,6 +3,8 @@ import type { DBCmd } from "../commands/async"; import type { TableDefinition } from "../schema/table"; import type { Row, SelectOptions, WhereClause } from "./primitives"; +export type DBTransactionMode = "readonly" | "readwrite"; + export type BaseDBDriverOperations = { intervalScan( table: string, @@ -17,7 +19,8 @@ export type BaseDBDriverOperations = { export interface DBDriver extends BaseDBDriverOperations { loadTables(table: TableDefinition[]): Generator; - beginTx(): Generator; + beginTx(mode?: DBTransactionMode): Generator; + canUseReadonlyTransactionsForSelectors(): boolean; } export interface DBDriverTX extends BaseDBDriverOperations { diff --git a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.browser.test.ts b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.browser.test.ts index c2ae369..8179009 100644 --- a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.browser.test.ts +++ b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.browser.test.ts @@ -266,7 +266,58 @@ describe("IdbDriver", () => { } }); - it("reuses one active readonly transaction across selector scans", async () => { + it("does not share readonly transactions across concurrent selector runs", async () => { + const db = await createDB(); + await execAsync(db.loadTables([tasksTable])); + await execAsync( + db.insert(tasksTable, [ + { + id: "task-1", + title: "First", + projectId: "project-1", + rank: 1, + }, + ]), + ); + + const originalTransaction = IDBDatabase.prototype.transaction; + const readonlyTransactions: IDBTransaction[] = []; + const txSpy = vi + .spyOn(IDBDatabase.prototype, "transaction") + .mockImplementation(function ( + this: IDBDatabase, + storeNames: string | string[], + mode?: IDBTransactionMode, + options?: IDBTransactionOptions, + ) { + const tx = originalTransaction.call(this, storeNames, mode, options); + if (mode === "readonly") { + readonlyTransactions.push(tx); + } + return tx; + }); + + const readProjectTasks = () => + (function* () { + return yield* selectFrom(tasksTable, "byProjectRank").where((q) => + q.eq("projectId", "project-1"), + ); + })(); + + try { + await Promise.all([ + selectAsync(db, readProjectTasks()), + selectAsync(db, readProjectTasks()), + ]); + + expect(readonlyTransactions).toHaveLength(2); + expect(readonlyTransactions[0]).not.toBe(readonlyTransactions[1]); + } finally { + txSpy.mockRestore(); + } + }); + + it("reuses one scoped readonly transaction across scans in one selector", async () => { const db = await createDB(); await execAsync(db.loadTables([tasksTable])); await execAsync( @@ -286,7 +337,22 @@ describe("IdbDriver", () => { ]), ); - const txSpy = vi.spyOn(IDBDatabase.prototype, "transaction"); + const originalTransaction = IDBDatabase.prototype.transaction; + const readonlyTransactions: IDBTransaction[] = []; + const txSpy = vi + .spyOn(IDBDatabase.prototype, "transaction") + .mockImplementation(function ( + this: IDBDatabase, + storeNames: string | string[], + mode?: IDBTransactionMode, + options?: IDBTransactionOptions, + ) { + const tx = originalTransaction.call(this, storeNames, mode, options); + if (mode === "readonly") { + readonlyTransactions.push(tx); + } + return tx; + }); try { const result = await selectAsync( @@ -314,11 +380,84 @@ describe("IdbDriver", () => { ); expect(readonlyCalls).toHaveLength(1); expect(readonlyCalls[0]?.[0]).toEqual(["hyperdb:idbTasks"]); + expect(readonlyTransactions).toHaveLength(1); } finally { txSpy.mockRestore(); } }); + it("retries and logs when a scoped readonly transaction is no longer active", async () => { + const db = await createDB(); + await execAsync(db.loadTables([tasksTable])); + await execAsync( + db.insert(tasksTable, [ + { + id: "task-1", + title: "First", + projectId: "project-1", + rank: 1, + }, + ]), + ); + + const getAllRecordsSpy = spyOnGetAllRecords(IDBIndex.prototype); + const getAllSpy = getAllRecordsSpy + ? undefined + : vi.spyOn(IDBIndex.prototype, "getAll"); + const txSpy = vi.spyOn(IDBDatabase.prototype, "transaction"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const inactiveError = new DOMException( + "The transaction is not active.", + "TransactionInactiveError", + ); + + try { + if (getAllRecordsSpy) { + getAllRecordsSpy.mockImplementationOnce(() => { + throw inactiveError; + }); + } else { + getAllSpy?.mockImplementationOnce(() => { + throw inactiveError; + }); + } + + await expect( + selectAsync( + db, + (function* () { + return yield* selectFrom(tasksTable, "byProjectRank").where((q) => + q.eq("projectId", "project-1"), + ); + })(), + ), + ).resolves.toEqual([ + { + id: "task-1", + title: "First", + projectId: "project-1", + rank: 1, + }, + ]); + + expect( + txSpy.mock.calls.filter(([, mode]) => mode === "readonly").length, + ).toBeGreaterThanOrEqual(2); + expect( + logSpy.mock.calls + .map(([message]) => String(message)) + .some((message) => + /IDB transaction reopen .* mode readonly/.test(message), + ), + ).toBe(true); + } finally { + getAllRecordsSpy?.mockRestore(); + getAllSpy?.mockRestore(); + txSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + it("does not open an IDB readonly transaction for cached HybridDB reads", async () => { dbCounter += 1; const dbName = `hyperdb-idb-driver-${Date.now().toString(36)}-${dbCounter}`; diff --git a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts index 4a5eea7..33e177a 100644 --- a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts @@ -2,7 +2,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { unwrapCb, type DBCmd } from "../../commands/async"; -import type { DBDriver, DBDriverTX } from "../../core/driver"; +import type { + DBDriver, + DBDriverTX, + DBTransactionMode, +} from "../../core/driver"; import { MAX, MIN, @@ -37,7 +41,10 @@ type LockRelease = () => void; type ActiveReadonlyTransaction = { tx: IDBTransaction; release: LockRelease; + done: Promise; + startedAt: number; finished: boolean; + released: boolean; }; export type OpenIndexedDBDriverOptions = { @@ -154,7 +161,7 @@ function isInactiveTransactionError(error: unknown): boolean { return ( name === "TransactionInactiveError" || - /TransactionInactiveError|transaction.*inactive|inactive.*transaction/i.test( + /TransactionInactiveError|transaction.*inactive|inactive.*transaction|transaction.*not active|not active.*transaction/i.test( message, ) ); @@ -770,15 +777,17 @@ async function performScan( }); return result; } catch (error) { - logIdbOperation( - "scan", - startedAt, - { - tableName, - indexName, - }, - error, - ); + if (!isInactiveTransactionError(error)) { + logIdbOperation( + "scan", + startedAt, + { + tableName, + indexName, + }, + error, + ); + } throw new Error(`Scan failed for index ${indexName}: ${error}`); } } @@ -916,6 +925,190 @@ class IdbDriverTx implements DBDriverTX { } } +class IdbDriverReadonlyTx implements DBDriverTX { + private active: ActiveReadonlyTransaction | undefined; + private tableDefinitions: Map; + private acquireRead: () => Promise; + private createTransaction: () => IDBTransaction; + private throwIfClosed: () => void; + private onDispose: (tx: IdbDriverReadonlyTx) => void; + private closed = false; + + constructor( + active: ActiveReadonlyTransaction, + tableDefinitions: Map, + acquireRead: () => Promise, + createTransaction: () => IDBTransaction, + throwIfClosed: () => void, + onDispose: (tx: IdbDriverReadonlyTx) => void, + ) { + this.active = active; + this.tableDefinitions = tableDefinitions; + this.acquireRead = acquireRead; + this.createTransaction = createTransaction; + this.throwIfClosed = throwIfClosed; + this.onDispose = onDispose; + this.watchActive(active); + } + + *commit(): Generator { + yield* this.rollback(); + } + + *rollback(): Generator { + this.dispose(); + } + + *insert(): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *upsert(): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *delete(): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *intervalScan( + table: string, + indexName: string, + clauses: WhereClause[], + selectOptions: SelectOptions, + ): Generator { + return yield* unwrapCb(async () => { + let canRetryInactiveTransaction = true; + + while (true) { + const active = await this.getActive(); + try { + return await performScan( + active.tx, + this.tableDefinitions, + table, + indexName, + clauses, + selectOptions, + ); + } catch (error) { + if ( + canRetryInactiveTransaction && + isInactiveTransactionError(error) + ) { + canRetryInactiveTransaction = false; + logIdbOperation("transaction reopen", nowMs(), { + mode: active.tx.mode, + }); + this.finishActive(active, true); + continue; + } + + this.finishActive(active, true); + throw error; + } + } + }); + } + + private async getActive(): Promise { + if (this.closed) { + throw new Error("Transaction already finished"); + } + if (this.active && !this.active.finished) { + return this.active; + } + + const release = await this.acquireRead(); + const startedAt = nowMs(); + try { + this.throwIfClosed(); + const tx = this.createTransaction(); + const active: ActiveReadonlyTransaction = { + tx, + release, + done: txDone(tx), + startedAt, + finished: false, + released: false, + }; + this.active = active; + logIdbOperation("transaction start", startedAt, { + mode: tx.mode, + }); + this.watchActive(active); + return active; + } catch (error) { + release(); + logIdbOperation( + "transaction start", + startedAt, + { + mode: "readonly", + }, + error, + ); + throw error; + } + } + + private watchActive(active: ActiveReadonlyTransaction): void { + void active.done + .then( + () => { + if (!active.finished) { + logIdbOperation("transaction commit", active.startedAt, { + mode: active.tx.mode, + }); + } + }, + (error) => { + if (!active.finished) { + logIdbOperation( + "transaction rollback", + active.startedAt, + { + mode: active.tx.mode, + }, + error, + ); + } + }, + ) + .finally(() => { + this.finishActive(active, false); + }); + } + + dispose(): void { + if (this.closed) return; + this.closed = true; + if (this.active) { + this.finishActive(this.active, true); + } + this.onDispose(this); + } + + private finishActive( + active: ActiveReadonlyTransaction, + abort: boolean, + ): void { + if (active.finished) return; + + active.finished = true; + if (this.active === active) { + this.active = undefined; + } + if (abort) { + abortQuietly(active.tx); + } + if (!active.released) { + active.released = true; + active.release(); + } + } +} + export class IdbDriver implements DBDriver { private db: IDBDatabase; private readonly dbName: string; @@ -923,7 +1116,7 @@ export class IdbDriver implements DBDriver { private readonly options: OpenIndexedDBDriverOptions; private tableDefinitions = new Map(); private lock = new AsyncReadWriteLock(); - private activeReadonlyTransaction: ActiveReadonlyTransaction | undefined; + private readonlyTransactions = new Set(); private closedReason: Error | null = null; constructor( @@ -943,16 +1136,23 @@ export class IdbDriver implements DBDriver { if (!this.closedReason) { this.closedReason = reason; } - if (this.activeReadonlyTransaction) { - const active = this.activeReadonlyTransaction; - active.finished = true; - this.clearReadonlyTransaction(active); - abortQuietly(active.tx); + for (const tx of [...this.readonlyTransactions]) { + tx.dispose(); } this.db.close(); } - *beginTx(): Generator { + canUseReadonlyTransactionsForSelectors(): boolean { + return true; + } + + *beginTx( + mode: DBTransactionMode = "readwrite", + ): Generator { + if (mode === "readonly") { + return yield* this.beginReadonlyTx(); + } + const release = yield* unwrapCb(async () => this.lock.acquireWrite()); let tx: IDBTransaction; @@ -971,6 +1171,49 @@ export class IdbDriver implements DBDriver { } } + private *beginReadonlyTx(): Generator { + const release = yield* unwrapCb(async () => this.lock.acquireRead()); + const startedAt = nowMs(); + + try { + this.throwIfClosed(); + const storeNames = this.loadedStoreNames(); + const tx = this.createTransaction(storeNames, "readonly"); + const active: ActiveReadonlyTransaction = { + tx, + release, + done: txDone(tx), + startedAt, + finished: false, + released: false, + }; + logIdbOperation("transaction start", startedAt, { + mode: tx.mode, + }); + const readonlyTx = new IdbDriverReadonlyTx( + active, + this.tableDefinitions, + () => this.lock.acquireRead(), + () => this.createTransaction(storeNames, "readonly"), + () => this.throwIfClosed(), + (finishedTx) => this.readonlyTransactions.delete(finishedTx), + ); + this.readonlyTransactions.add(readonlyTx); + return readonlyTx; + } catch (error) { + release(); + logIdbOperation( + "transaction start", + startedAt, + { + mode: "readonly", + }, + error, + ); + throw error; + } + } + *loadTables( tableDefinitions: TableDefinition[], ): Generator { @@ -1036,19 +1279,12 @@ export class IdbDriver implements DBDriver { clauses: WhereClause[], selectOptions: SelectOptions, ): Generator { - return yield* this.withTransaction( - "readonly", - [tableStoreName(table)], - async (tx) => - performScan( - tx, - this.tableDefinitions, - table, - indexName, - clauses, - selectOptions, - ), - ); + const tx = yield* this.beginTx("readonly"); + try { + return yield* tx.intervalScan(table, indexName, clauses, selectOptions); + } finally { + yield* tx.rollback(); + } } private async ensureSchema( @@ -1203,10 +1439,6 @@ export class IdbDriver implements DBDriver { run: (tx: IDBTransaction) => Promise, ): Generator { return yield* unwrapCb(async () => { - if (mode === "readonly") { - return this.withReadonlyTransaction(run); - } - const release = await this.lock.acquireWrite(); let tx: IDBTransaction | undefined; let done: Promise | undefined; @@ -1254,107 +1486,6 @@ export class IdbDriver implements DBDriver { }); } - private async withReadonlyTransaction( - run: (tx: IDBTransaction) => Promise, - ): Promise { - let canRetryInactiveTransaction = true; - - while (true) { - const active = await this.getReadonlyTransaction(); - - try { - return await run(active.tx); - } catch (error) { - if ( - canRetryInactiveTransaction && - isInactiveTransactionError(error) - ) { - canRetryInactiveTransaction = false; - this.clearReadonlyTransaction(active); - continue; - } - - abortQuietly(active.tx); - throw error; - } - } - } - - private async getReadonlyTransaction(): Promise { - if ( - this.activeReadonlyTransaction && - !this.activeReadonlyTransaction.finished - ) { - return this.activeReadonlyTransaction; - } - - const release = await this.lock.acquireRead(); - const transactionStartedAt = nowMs(); - - try { - this.throwIfClosed(); - const tx = this.createTransaction(this.loadedStoreNames(), "readonly"); - const active: ActiveReadonlyTransaction = { - tx, - release, - finished: false, - }; - - this.activeReadonlyTransaction = active; - logIdbOperation("transaction start", transactionStartedAt, { - mode: tx.mode, - }); - void txDone(tx) - .then( - () => { - if (!active.finished) { - logIdbOperation("transaction commit", transactionStartedAt, { - mode: tx.mode, - }); - } - }, - (error) => { - if (!active.finished) { - logIdbOperation( - "transaction rollback", - transactionStartedAt, - { - mode: tx.mode, - }, - error, - ); - } - }, - ) - .finally(() => { - if (!active.finished) { - active.finished = true; - } - this.clearReadonlyTransaction(active); - active.release(); - }); - - return active; - } catch (error) { - logIdbOperation( - "transaction start", - transactionStartedAt, - { - mode: "readonly", - }, - error, - ); - release(); - throw error; - } - } - - private clearReadonlyTransaction(active: ActiveReadonlyTransaction): void { - if (this.activeReadonlyTransaction === active) { - this.activeReadonlyTransaction = undefined; - } - } - private attachVersionChangeHandler(): void { this.db.onversionchange = (event) => { this.options.onVersionChange?.(event); diff --git a/packages/hyperdb/src/hyperdb/drivers/inmemory/bptree-inmem-driver.ts b/packages/hyperdb/src/hyperdb/drivers/inmemory/bptree-inmem-driver.ts index 4663ce3..7f125b3 100644 --- a/packages/hyperdb/src/hyperdb/drivers/inmemory/bptree-inmem-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/inmemory/bptree-inmem-driver.ts @@ -903,6 +903,10 @@ export class BptreeInmemDriver implements DBDriver { constructor() {} + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *beginTx(): Generator { if (this.isInTransaction) { throw new Error("can't run while transaction is in progress"); diff --git a/packages/hyperdb/src/hyperdb/drivers/sqlite/async-sql-driver.ts b/packages/hyperdb/src/hyperdb/drivers/sqlite/async-sql-driver.ts index 78c4b60..18d1bb3 100644 --- a/packages/hyperdb/src/hyperdb/drivers/sqlite/async-sql-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/sqlite/async-sql-driver.ts @@ -429,6 +429,10 @@ export class AsyncSqlDriver implements DBDriver { this.db = db; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *beginTx(): Generator { yield* unwrapCb(async () => { await this.txAndQueryLock.acquireAsync(); diff --git a/packages/hyperdb/src/hyperdb/drivers/sqlite/sql-driver.ts b/packages/hyperdb/src/hyperdb/drivers/sqlite/sql-driver.ts index 20d285c..2dd87ce 100644 --- a/packages/hyperdb/src/hyperdb/drivers/sqlite/sql-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/sqlite/sql-driver.ts @@ -242,6 +242,10 @@ export class SqlDriver implements DBDriver { this.db = db; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *beginTx(): Generator { if (this.isInTransaction) { throw new Error("can't run while transaction is in progress"); diff --git a/packages/hyperdb/src/hyperdb/runtime/db-tx.ts b/packages/hyperdb/src/hyperdb/runtime/db-tx.ts index c72dd88..3b77654 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db-tx.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db-tx.ts @@ -1,7 +1,7 @@ import { convertWhereToBound } from "../core/query/bounds"; import type { DBCmd } from "../commands/async"; import type { HyperDBTx } from "../core/contracts"; -import type { DBDriverTX } from "../core/driver"; +import type { DBDriverTX, DBTransactionMode } from "../core/driver"; import type { Row, SelectOptions, @@ -165,7 +165,13 @@ export class DBTx implements HyperDBTx { return this.options; } - *beginTx(): Generator { + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + + *beginTx( + _mode: DBTransactionMode = "readwrite", + ): Generator { if (this.isFinished.val) { throw new Error("Transaction is finished"); } diff --git a/packages/hyperdb/src/hyperdb/runtime/db-validation.test.ts b/packages/hyperdb/src/hyperdb/runtime/db-validation.test.ts index ec251c7..535df9b 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db-validation.test.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db-validation.test.ts @@ -19,6 +19,10 @@ class RecordingDriver implements DBDriver, DBDriverTX { upserted: Row[][] = []; scanRows: unknown[] = []; + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(_tables: TableDefinition[]): Generator {} *beginTx(): Generator { diff --git a/packages/hyperdb/src/hyperdb/runtime/db.ts b/packages/hyperdb/src/hyperdb/runtime/db.ts index 5367dc0..ec278f4 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db.ts @@ -2,7 +2,11 @@ import { convertWhereToBound } from "../core/query/bounds"; import type { DBCmd } from "../commands/async"; import type { HyperDB } from "../core/contracts"; -import type { BaseDBDriverOperations, DBDriver } from "../core/driver"; +import type { + BaseDBDriverOperations, + DBDriver, + DBTransactionMode, +} from "../core/driver"; import type { Row, SelectOptions, @@ -165,6 +169,10 @@ export class DB implements HyperDB { return db; } + canUseReadonlyTransactionsForSelectors(): boolean { + return this.driver.canUseReadonlyTransactionsForSelectors(); + } + getTraits(): Trait[] { return this.traits; } @@ -198,8 +206,8 @@ export class DB implements HyperDB { yield* this.driver.loadTables(tables); } - *beginTx(): Generator { - const tx = yield* this.driver.beginTx(); + *beginTx(mode: DBTransactionMode = "readwrite"): Generator { + const tx = yield* this.driver.beginTx(mode); return new DBTx(this, tx); } diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts index 691eb73..94eb5c6 100644 --- a/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts +++ b/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts @@ -352,7 +352,7 @@ const setSelectSource = ( }; export function* hybridIntervalScan( - primary: HyperDB, + primary: HyperDB | (() => Generator), cache: HyperDB, cachedIntervals: HybridIntervalCache, selectEvent: SelectCommandEvent | undefined, @@ -394,7 +394,8 @@ export function* hybridIntervalScan( } setSelectSource(selectEvent, "persist"); - const primaryRows = yield* primary.intervalScan( + const primaryDB = typeof primary === "function" ? yield* primary() : primary; + const primaryRows = yield* primaryDB.intervalScan( table, indexName, clauses, diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts index a3ecb9e..59cf9bf 100644 --- a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts @@ -10,6 +10,7 @@ import { getCurrentSelectEventForDB, type HyperDBTracerOption, } from "../core/tracer"; +import type { DBTransactionMode } from "../core/driver"; import { DEFAULT_CODEC_OPTIONS, type CodecOptions } from "../storage/codec"; import type { ExtractIndexes, @@ -38,6 +39,12 @@ type HybridDBTxState = { releaseLock: () => void; }; +type HybridReadonlyTxState = { + primaryTx?: HyperDBTx; + rollbacked: RefVar; + txCounter: RefVar; +}; + const createHybridDBState = (): HybridDBState => ({ cachedIntervals: createHybridIntervalCache(), lock: new AwaitLock(), @@ -51,6 +58,11 @@ const createHybridDBTxState = (releaseLock: () => void): HybridDBTxState => ({ releaseLock, }); +const createHybridReadonlyTxState = (): HybridReadonlyTxState => ({ + rollbacked: refVar(false), + txCounter: refVar(1), +}); + export type HybridDBOptions = { traits?: Trait[]; }; @@ -86,7 +98,7 @@ export class HybridDB implements HyperDB { primary: HyperDB; cache: HyperDB; traits: Trait[] = []; - private state: HybridDBState; + state: HybridDBState; constructor(primary: HyperDB, cache: HyperDB, options: HybridDBOptions = {}) { this.primary = primary; @@ -103,6 +115,10 @@ export class HybridDB implements HyperDB { return db; } + canUseReadonlyTransactionsForSelectors(): boolean { + return this.primary.canUseReadonlyTransactionsForSelectors(); + } + getTraits(): Trait[] { return [...this.traits, ...this.primary.getTraits()]; } @@ -124,22 +140,24 @@ export class HybridDB implements HyperDB { } *loadTables(tables: TableDefinition[]): Generator { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.loadTables(tables); - yield* this.cache.loadTables(tables); - this.state.cachedIntervals.clear(); - }.bind(this), - ); + const { cache, primary, state } = this; + yield* withHybridLock(this.state, function* () { + yield* primary.loadTables(tables); + yield* cache.loadTables(tables); + state.cachedIntervals.clear(); + }); } - *beginTx(): Generator { + *beginTx(mode: DBTransactionMode = "readwrite"): Generator { + if (mode === "readonly") { + return new HybridDBReadonlyTx(this); + } + const release = yield* acquireHybridLock(this.state); let primaryTx: HyperDBTx | undefined; try { - primaryTx = yield* this.primary.beginTx(); - const cacheTx = yield* this.cache.beginTx(); + primaryTx = yield* this.primary.beginTx("readwrite"); + const cacheTx = yield* this.cache.beginTx("readwrite"); return new HybridDBTx(this, primaryTx, cacheTx, release); } catch (error) { if (primaryTx) { @@ -163,14 +181,152 @@ export class HybridDB implements HyperDB { clauses: WhereClause[], selectOptions?: SelectOptions, ): Generator[]> { + const { cache, primary, state } = this; + const selectEvent = getCurrentSelectEventForDB(this); + return yield* withHybridLock(this.state, function* () { + return yield* hybridIntervalScan( + primary, + cache, + state.cachedIntervals, + selectEvent, + table, + indexName, + clauses, + selectOptions, + ); + }); + } + + *insert( + table: TTable, + records: ExtractSchema[], + ): Generator { + const { cache, primary } = this; + yield* withHybridLock(this.state, function* () { + yield* primary.insert(table, records); + yield* cache.insert(table, records); + }); + } + + *upsert( + table: TTable, + records: ExtractSchema[], + ): Generator { + const { cache, primary } = this; + yield* withHybridLock(this.state, function* () { + yield* primary.upsert(table, records); + yield* cache.upsert(table, records); + }); + } + + *delete( + table: TTable, + ids: string[], + ): Generator { + const { cache, primary } = this; + yield* withHybridLock(this.state, function* () { + yield* primary.delete(table, ids); + yield* cache.delete(table, ids); + }); + } + + mergeTxCoverage(intervals: HybridIntervalCache): void { + mergeCoverageMaps(this.state.cachedIntervals, intervals); + } +} + +class HybridDBReadonlyTx implements HyperDBTx { + private hybridDB: HybridDB; + private state: HybridReadonlyTxState; + private traits: Trait[]; + + constructor( + hybridDB: HybridDB, + state: HybridReadonlyTxState = createHybridReadonlyTxState(), + traits: Trait[] = [], + ) { + this.hybridDB = hybridDB; + this.state = state; + this.traits = traits; + } + + withTraits(...traits: Trait[]): HyperDBTx { + return new HybridDBReadonlyTx(this.hybridDB, this.state, [ + ...this.traits, + ...traits, + ]); + } + + getTraits(): Trait[] { + return [...this.traits, ...this.hybridDB.getTraits()]; + } + + getId(): string { + return this.hybridDB.getId(); + } + + getDBName(): string | undefined { + return this.hybridDB.getDBName?.(); + } + + getTracer(): HyperDBTracerOption | undefined { + return this.hybridDB.getTracer?.(); + } + + getOptions(): CodecOptions { + return this.hybridDB.getOptions?.() ?? DEFAULT_CODEC_OPTIONS; + } + + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + + *loadTables(): Generator { + throw new Error("Not supported"); + } + + *beginTx( + _mode: DBTransactionMode = "readwrite", + ): Generator { + this.throwIfDone(); + this.state.txCounter.val++; + return this; + } + + *intervalScan< + TTable extends TableDefinition, + K extends keyof ExtractIndexes, + >( + table: TTable, + indexName: K, + clauses: WhereClause[], + selectOptions?: SelectOptions, + ): Generator[]> { + this.throwIfDone(); + const { cache, state } = this.hybridDB; + const selectEvent = getCurrentSelectEventForDB(this); + const getPrimaryForRead = function* ( + this: HybridDBReadonlyTx, + ): Generator { + if (this.state.primaryTx) return this.state.primaryTx; + + const { primary } = this.hybridDB; + const tx = primary.canUseReadonlyTransactionsForSelectors() + ? yield* primary.beginTx("readonly") + : undefined; + + this.state.primaryTx = tx; + return tx ?? primary; + }.bind(this); + return yield* withHybridLock( - this.state, + state, function* () { return yield* hybridIntervalScan( - this.primary, - this.cache, - this.state.cachedIntervals, - getCurrentSelectEventForDB(this), + getPrimaryForRead, + cache, + state.cachedIntervals, + selectEvent, table, indexName, clauses, @@ -181,46 +337,45 @@ export class HybridDB implements HyperDB { } *insert( - table: TTable, - records: ExtractSchema[], + _table: TTable, + _records: ExtractSchema[], ): Generator { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.insert(table, records); - yield* this.cache.insert(table, records); - }.bind(this), - ); + throw new Error("Cannot write through a readonly transaction"); } *upsert( - table: TTable, - records: ExtractSchema[], + _table: TTable, + _records: ExtractSchema[], ): Generator { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.upsert(table, records); - yield* this.cache.upsert(table, records); - }.bind(this), - ); + throw new Error("Cannot write through a readonly transaction"); } *delete( - table: TTable, - ids: string[], + _table: TTable, + _ids: string[], ): Generator { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.delete(table, ids); - yield* this.cache.delete(table, ids); - }.bind(this), - ); + throw new Error("Cannot write through a readonly transaction"); } - mergeTxCoverage(intervals: HybridIntervalCache): void { - mergeCoverageMaps(this.state.cachedIntervals, intervals); + *commit(): Generator { + yield* this.rollback(); + } + + *rollback(): Generator { + this.throwIfDone(); + this.state.txCounter.val--; + if (this.state.txCounter.val !== 0) return; + + this.state.rollbacked.val = true; + if (this.state.primaryTx) { + yield* this.state.primaryTx.rollback(); + } + } + + private throwIfDone(): void { + if (this.state.rollbacked.val) { + throw new Error("Cannot modify a rollbacked tx"); + } } } @@ -277,11 +432,17 @@ class HybridDBTx implements HyperDBTx { return this.hybridDB.getOptions?.() ?? DEFAULT_CODEC_OPTIONS; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(): Generator { throw new Error("Not supported"); } - *beginTx(): Generator { + *beginTx( + _mode: DBTransactionMode = "readwrite", + ): Generator { this.throwIfDone(); this.state.txCounter.val++; return this; diff --git a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts index 048e48d..5031676 100644 --- a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts @@ -9,6 +9,7 @@ import type { import { runCommandGenerator } from "../commands/runner"; import type { DBCmd } from "../commands/async"; import { DEFAULT_CODEC_OPTIONS, type CodecOptions } from "../storage/codec"; +import type { DBTransactionMode } from "../core/driver"; // import { collectAll } from "../commands/async"; import type { ExtractIndexes, @@ -147,6 +148,10 @@ export class SubscribableDBTx implements HyperDBTx { return this.subDb.getOptions?.() ?? DEFAULT_CODEC_OPTIONS; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(): Generator { throw new Error("Not supported"); } @@ -158,7 +163,9 @@ export class SubscribableDBTx implements HyperDBTx { ]); } - *beginTx(): Generator { + *beginTx( + _mode: DBTransactionMode = "readwrite", + ): Generator { this.state.txCounter.val++; return this; @@ -456,6 +463,7 @@ export class SubscribableDB implements HyperDB { private state: SubscribableDBState; constructor(db: HyperDB); + constructor(db: HyperDB, state: SubscribableDBState, traits?: Trait[]); constructor( db: HyperDB, state: SubscribableDBState = createSubscribableDBState(), @@ -505,8 +513,12 @@ export class SubscribableDB implements HyperDB { return this.db.loadTables(tables); } - *beginTx(): Generator { - return new SubscribableDBTx(this, yield* this.delegateDB().beginTx()); + *beginTx(mode: DBTransactionMode = "readwrite"): Generator { + return new SubscribableDBTx(this, yield* this.delegateDB().beginTx(mode)); + } + + canUseReadonlyTransactionsForSelectors(): boolean { + return this.delegateDB().canUseReadonlyTransactionsForSelectors(); } withTraits(...traits: Trait[]): HyperDB { diff --git a/packages/hyperdb/src/hyperdb/tracing/runtime.test.ts b/packages/hyperdb/src/hyperdb/tracing/runtime.test.ts index 7df871d..781f34a 100644 --- a/packages/hyperdb/src/hyperdb/tracing/runtime.test.ts +++ b/packages/hyperdb/src/hyperdb/tracing/runtime.test.ts @@ -81,6 +81,10 @@ class FakeAsyncDriverTx implements DBDriverTX { class FakeAsyncDriver implements DBDriver { private readonly driver = new BptreeInmemDriver(); + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(tables: Parameters[0]) { yield* unwrap(Promise.resolve()); yield* this.driver.loadTables(tables); diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 4b580f1..f77d2be 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -16,7 +16,7 @@ const mocks = { cleanup: undefined as undefined | (() => void), db: undefined as unknown as MockDB, refs: [] as { current: unknown }[], - setResult: vi.fn(), + setState: vi.fn(), initCachedSelector: vi.fn(), runSelectorAsync: vi.fn(), runSelectorMaybeAsync: vi.fn(), @@ -35,7 +35,10 @@ const fakeReactHooks = { mocks.refs.push(ref); return ref; }), - useState: vi.fn((initial) => [initial, mocks.setResult]), + useState: vi.fn((initial) => [ + typeof initial === "function" ? initial() : initial, + mocks.setState, + ]), useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()), }; @@ -85,7 +88,7 @@ describe("useAsyncSelector", () => { mocks.cleanup = undefined; mocks.db = createMockDB(); mocks.refs = []; - mocks.setResult.mockReset(); + mocks.setState.mockReset(); mocks.initCachedSelector.mockReset(); mocks.runSelectorAsync.mockReset(); mocks.runSelectorMaybeAsync.mockReset(); @@ -123,18 +126,12 @@ describe("useAsyncSelector", () => { selector, args: { projectId: "project-1" }, defaultValue: [], - gcTime: 30_000, }); expect(result).toEqual(["task-1"]); - expect(mocks.initCachedSelector).toHaveBeenCalledWith( - mocks.db, - selector, - { projectId: "project-1" }, - { - gcTime: 30_000, - }, - ); + expect(mocks.initCachedSelector).toHaveBeenCalledWith(mocks.db, selector, { + projectId: "project-1", + }); }); it("returns default value for disabled sync selectors without creating cache entries", () => { @@ -183,6 +180,7 @@ describe("useAsyncSelector", () => { expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.db.subscribe).toHaveBeenCalledTimes(1); + mocks.setState.mockClear(); mocks.db.emit([{ id: "op-1" }]); mocks.db.emit([{ id: "op-2" }]); @@ -193,15 +191,20 @@ describe("useAsyncSelector", () => { first.resolve("stale"); await flushPromises(); - expect(mocks.setResult).not.toHaveBeenCalled(); + expect(mocks.setState).not.toHaveBeenCalled(); expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(2); second.resolve("latest"); await flushPromises(); - expect(mocks.setResult).toHaveBeenCalledTimes(1); - expect(mocks.setResult).toHaveBeenCalledWith("latest"); - expect(mocks.refs[0].current).toEqual([secondCmd]); + expect(mocks.setState).toHaveBeenCalledTimes(1); + expect(mocks.setState).toHaveBeenCalledWith( + expect.objectContaining({ + data: "latest", + status: "success", + }), + ); + expect(mocks.refs[2].current).toEqual([secondCmd]); const ignoredOps = [{ id: "ignored" }]; mocks.isNeedToRerunRange.mockReturnValue(false); @@ -233,8 +236,9 @@ describe("useAsyncSelector", () => { args: {}, }); - const selectRangeCmdsRef = mocks.refs[0]; + const selectRangeCmdsRef = mocks.refs[2]; expect(mocks.db.subscriberCount()).toBe(1); + mocks.setState.mockClear(); mocks.cleanup?.(); @@ -243,7 +247,7 @@ describe("useAsyncSelector", () => { pending.resolve("late"); await flushPromises(); - expect(mocks.setResult).not.toHaveBeenCalled(); + expect(mocks.setState).not.toHaveBeenCalled(); expect(selectRangeCmdsRef.current).toEqual([]); }); @@ -259,12 +263,42 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(result).toEqual([]); + expect(result.data).toEqual([]); + expect(result.status).toBe("pending"); + expect(result.fetchStatus).toBe("idle"); + expect(result.isEnabled).toBe(false); expect(mocks.stableSerializeSelectorArgs).not.toHaveBeenCalled(); expect(mocks.runSelectorMaybeAsync).not.toHaveBeenCalled(); expect(mocks.db.subscribe).not.toHaveBeenCalled(); }); + it("resolves refetch with the freshly fetched selector result", async () => { + const pending = deferred(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + mocks.runSelectorMaybeAsync.mockReturnValue(pending.promise); + + const result = useAsyncSelector({ + selector, + args: {}, + initialData: ["stale"], + subscribed: false, + }); + + const refetchPromise = result.refetch(); + pending.resolve(["fresh"]); + await flushPromises(); + const refetchResult = await refetchPromise; + + expect(refetchResult.data).toEqual(["fresh"]); + expect(refetchResult.status).toBe("success"); + expect(refetchResult.isSuccess).toBe(true); + expect(refetchResult.isFetching).toBe(false); + expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.db.subscribe).not.toHaveBeenCalled(); + }); + it("applies fully synchronous async selector runs without waiting for a promise turn", () => { const cmd = { table: "tasks", range: "sync" }; const selector = vi.fn(function* selector(_args: { projectId: string }) { @@ -288,8 +322,13 @@ describe("useAsyncSelector", () => { expect(selector).toHaveBeenCalledWith({ projectId: "project-1" }); expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.runSelectorAsync).not.toHaveBeenCalled(); - expect(mocks.setResult).toHaveBeenCalledWith(["task-1"]); - expect(mocks.refs[0].current).toEqual([cmd]); + expect(mocks.setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: ["task-1"], + status: "success", + }), + ); + expect(mocks.refs[2].current).toEqual([cmd]); }); it("runs object-form async selectors with args", async () => { @@ -313,7 +352,12 @@ describe("useAsyncSelector", () => { expect(selector).toHaveBeenCalledWith({ projectId: "project-1" }); expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.db.subscribe).toHaveBeenCalledTimes(1); - expect(mocks.setResult).toHaveBeenCalledWith(["task-1"]); + expect(mocks.setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: ["task-1"], + status: "success", + }), + ); }); it("resets object-form async selector result when args key changes", () => { @@ -337,8 +381,12 @@ describe("useAsyncSelector", () => { defaultValue: ["loading-2"], }); - expect(mocks.setResult).toHaveBeenCalledWith(["loading-1"]); - expect(mocks.setResult).toHaveBeenCalledWith(["loading-2"]); + expect(mocks.setState).toHaveBeenCalledWith( + expect.objectContaining({ data: ["loading-1"] }), + ); + expect(mocks.setState).toHaveBeenCalledWith( + expect.objectContaining({ data: ["loading-2"] }), + ); expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index c9ac1cd..f3b243d 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -31,7 +31,6 @@ type SyncSelectorEnabledOptions = { args: SelectorArgs; enabled?: true; defaultValue?: SelectorReturn; - gcTime?: number; }; type SyncSelectorMaybeDisabledOptions = { @@ -39,21 +38,114 @@ type SyncSelectorMaybeDisabledOptions = { args: SelectorArgs; enabled: boolean; defaultValue: SelectorReturn; - gcTime?: number; }; -type AsyncSelectorEnabledOptions = { +export type AsyncSelectorStatus = "pending" | "error" | "success"; +export type AsyncSelectorFetchStatus = "fetching" | "paused" | "idle"; + +export type UseAsyncSelectorRefetchOptions = { + throwOnError?: boolean; + cancelRefetch?: boolean; +}; + +export type UseAsyncSelectorResult = { + 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; + refetch: ( + options?: UseAsyncSelectorRefetchOptions, + ) => Promise>; + status: AsyncSelectorStatus; +}; + +export type UseAsyncSelectorDefinedResult< + TData, + TError = unknown, +> = UseAsyncSelectorResult & { + data: TData; +}; + +type AsyncSelectorBaseOptions< + TSelector extends AnyObjectSelector, + TError = unknown, +> = { selector: TSelector; args: SelectorArgs; - enabled?: true; + enabled?: boolean; defaultValue?: SelectorReturn; + initialData?: SelectorReturn | (() => SelectorReturn); + initialDataUpdatedAt?: number | (() => number | undefined); + placeholderData?: + | SelectorReturn + | (( + previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => SelectorReturn); + subscribed?: boolean; + throwOnError?: + | boolean + | (( + error: TError, + result: UseAsyncSelectorResult, TError>, + ) => boolean); }; -type AsyncSelectorMaybeDisabledOptions = { - selector: TSelector; - args: SelectorArgs; - enabled: boolean; - defaultValue: SelectorReturn; +type AsyncSelectorDefinedOptions< + TSelector extends AnyObjectSelector, + TError = unknown, +> = AsyncSelectorBaseOptions & + ( + | { defaultValue: SelectorReturn } + | { + initialData: + | SelectorReturn + | (() => SelectorReturn); + } + | { + placeholderData: + | SelectorReturn + | (( + previousValue: SelectorReturn | undefined, + previousQuery: undefined, + ) => SelectorReturn); + } + ); + +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; }; const createDisabledStore = (defaultValue: TReturn) => ({ @@ -66,6 +158,145 @@ const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => (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 defaultHookDeps = { useCallback, useEffect, @@ -120,10 +351,8 @@ export function useSyncSelector( ); } - return hookDeps.initCachedSelector(db, input.selector, input.args, { - gcTime: input.gcTime, - }); - }, [db, input.selector, argsKey, enabled, input.defaultValue, input.gcTime]); + return hookDeps.initCachedSelector(db, input.selector, input.args); + }, [db, input.selector, argsKey, enabled, input.defaultValue]); return hookDeps.useSyncExternalStore( selector.subscribe, @@ -133,113 +362,269 @@ export function useSyncSelector( } export function useAsyncSelector( - options: AsyncSelectorEnabledOptions & { - defaultValue: SelectorReturn; - }, -): SelectorReturn; -export function useAsyncSelector( - options: AsyncSelectorEnabledOptions, -): SelectorReturn | undefined; + options: AsyncSelectorDefinedOptions, +): UseAsyncSelectorDefinedResult>; export function useAsyncSelector( - options: AsyncSelectorMaybeDisabledOptions, -): SelectorReturn; -export function useAsyncSelector( - input: - | AsyncSelectorEnabledOptions - | AsyncSelectorMaybeDisabledOptions, -): SelectorReturn | undefined { + options: AsyncSelectorBaseOptions, +): UseAsyncSelectorResult>; +export function useAsyncSelector< + TSelector extends AnyObjectSelector, + TError = unknown, +>( + input: AsyncSelectorBaseOptions, +): UseAsyncSelectorResult, TError> { 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 [result, setResult] = hookDeps.useState< - SelectorReturn | undefined - >(input.defaultValue); + const promiseControllerRef = hookDeps.useRef( + createPromiseController>(), + ); + const [queryState, setQueryStateRaw] = hookDeps.useState< + AsyncSelectorState, TError> + >(() => + createAsyncSelectorState, TError>(input, { + canFetch, + promise: promiseControllerRef.current.promise, + }), + ); + const queryStateRef = hookDeps.useRef(queryState); const selectRangeCmdsRef = hookDeps.useRef([]); + 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 genRef = hookDeps.useRef< () => Generator, unknown> >(() => input.selector(input.args)); + const runRef = hookDeps.useRef< + ( + options?: UseAsyncSelectorRefetchOptions, + ) => Promise, TError>> + >(() => Promise.resolve(resultRef.current)); genRef.current = () => input.selector(input.args); + const setQueryState = hookDeps.useCallback( + ( + updater: ( + previous: AsyncSelectorState, TError>, + ) => AsyncSelectorState, TError>, + ) => { + const next = updater(queryStateRef.current); + queryStateRef.current = next; + setQueryStateRaw(next); + return next; + }, + [], + ); + + const refetch = hookDeps.useCallback( + (options?: UseAsyncSelectorRefetchOptions) => runRef.current(options), + [], + ); + + const result = createUseAsyncSelectorResult(queryState, { + enabled, + refetch, + }); + resultRef.current = result; + hookDeps.useEffect(() => { - if ("defaultValue" in input) { - setResult(input.defaultValue); - } + const promiseController = + createPromiseController>(); + promiseControllerRef.current = promiseController; + selectRangeCmdsRef.current = []; + setQueryState((previous) => + createAsyncSelectorState, TError>(input, { + canFetch, + previousData: previous.data, + promise: promiseController.promise, + }), + ); }, [argsKey]); hookDeps.useEffect(() => { - if (!enabled) { - return; - } + cancelledRef.current = false; - let cancelled = false; - let isRunning = false; - let rerunRequested = false; + const run = (options?: UseAsyncSelectorRefetchOptions) => { + if (isRunningRef.current) { + rerunRequestedRef.current = true; - const run = () => { - if (isRunning) { - rerunRequested = true; - return; + if ( + options?.cancelRefetch === false && + inFlightResultRef.current !== null + ) { + return inFlightResultRef.current; + } + + return inFlightResultRef.current ?? Promise.resolve(resultRef.current); } - isRunning = true; - - try { - do { - rerunRequested = false; - const cmds: SelectRangeCmd[] = []; - const value = hookDeps.runSelectorMaybeAsync( - db, - genRef.current, - cmds, - ); - - if (isPromiseLike(value)) { - void Promise.resolve(value) - .then((resolvedValue) => { - if (cancelled || rerunRequested) { - return; - } - - selectRangeCmdsRef.current = cmds; - setResult(resolvedValue); - }) - .catch((error: unknown) => { - void Promise.reject(error); - }) - .finally(() => { - isRunning = false; - if (rerunRequested && !cancelled) { - run(); - } - }); - return; - } + isRunningRef.current = true; + const promiseController = + createPromiseController>(); + promiseControllerRef.current = promiseController; + const resultPromise = new Promise< + UseAsyncSelectorResult, TError> + >((resolve, reject) => { + const resolveCurrentResult = () => { + resolve(resultRef.current); + }; + const finishSuccess = ( + value: SelectorReturn, + cmds: SelectRangeCmd[], + ) => { + if (cancelledRef.current) return; - if (cancelled) { - isRunning = false; + selectRangeCmdsRef.current = cmds; + 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(nextState, { + 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(nextState, { + enabled, + refetch, + }); + promiseController.reject(error); + isRunningRef.current = false; + inFlightResultRef.current = null; + + if (options?.throwOnError === true) { + reject(error); return; } - if (rerunRequested) continue; - - selectRangeCmdsRef.current = cmds; - setResult(value); - } while (rerunRequested); - - isRunning = false; - } catch (error) { - isRunning = false; - void Promise.reject(error); + resolveCurrentResult(); + }; + const runOnce = () => { + try { + do { + rerunRequestedRef.current = false; + const cmds: SelectRangeCmd[] = []; + const value = hookDeps.runSelectorMaybeAsync( + db, + genRef.current, + 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; - run(); + if (!canFetch) { + return; + } + + void run(); const unsubscribe = db.subscribe((ops) => { - if (isRunning) { - rerunRequested = true; + if (isRunningRef.current) { + rerunRequestedRef.current = true; return; } @@ -251,17 +636,25 @@ export function useAsyncSelector( return; } - run(); + void run(); }); return () => { - cancelled = true; + cancelledRef.current = true; + isRunningRef.current = false; unsubscribe(); }; - }, [db, input.selector, argsKey, enabled]); + }, [db, input.selector, argsKey, canFetch, enabled, refetch]); - if (input.enabled === false) { - return input.defaultValue; + if (result.isError && input.throwOnError) { + const shouldThrow = + typeof input.throwOnError === "function" + ? input.throwOnError(queryState.error as TError, result) + : input.throwOnError; + + if (shouldThrow) { + throw queryState.error; + } } return result;