From 5be41bdde6e3470107bc1ca9b7c23dd3b6529528 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 26 Jun 2026 00:41:30 +0300 Subject: [PATCH 1/5] fix: improve async selector + improve idb tx and add doc summary --- README.md | 7 +- packages/hyperdb-demo/src/BenchmarkApp.tsx | 19 +- .../src/content/docs/integrations/react.md | 51 +- .../src/content/docs/runtime/db.md | 4 +- .../src/content/docs/runtime/drivers.md | 7 +- .../src/content/docs/start/quickstart.md | 4 + packages/hyperdb-doc/summary.md | 3 +- packages/hyperdb/README.md | 3 +- .../src/hyperdb/commands/selector/selector.ts | 108 +++- .../hyperdb/src/hyperdb/core/contracts.ts | 3 + packages/hyperdb/src/hyperdb/core/driver.ts | 16 + .../drivers/idb/idb-driver.browser.test.ts | 143 ++++- .../src/hyperdb/drivers/idb/idb-driver.ts | 159 +++-- packages/hyperdb/src/hyperdb/runtime/db.ts | 60 +- .../hyperdb/runtime/hybrid-db-intervals.ts | 5 +- .../hyperdb/src/hyperdb/runtime/hybrid-db.ts | 125 ++-- .../src/hyperdb/runtime/subscribable-db.ts | 20 + packages/hyperdb/src/react/hooks.test.ts | 57 +- packages/hyperdb/src/react/hooks.ts | 562 +++++++++++++++--- 19 files changed, 1118 insertions(+), 238 deletions(-) diff --git a/README.md b/README.md index c02022d..c304506 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,15 @@ 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. With an IndexedDB primary, readonly transaction + reuse is scoped to a single selector run and 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/integrations/react.md b/packages/hyperdb-doc/src/content/docs/integrations/react.md index d7ecb15..d6f8621 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -76,22 +76,63 @@ Options: ### `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 | +| `staleTime` | Time in ms before successful data is considered stale, or `"static"` | +| `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 +182,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..4ab7b3d 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"; diff --git a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md index a98568a..c66ed02 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md @@ -312,8 +312,11 @@ 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. +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 +transaction scopes, 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..ea90143 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`, `gcTime`, 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..a6b721b 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,27 @@ type RunSelectorOptions = Pick; const makeVisited = (options: RunSelectorOptions): ChildVisited | undefined => options.childMemo ? new Map() : undefined; +const createReadonlyScopedDB = ( + db: HyperDB, +): + | { + db: HyperDB; + close: () => Generator; + } + | undefined => { + const scope = db.createReadonlyTransactionScope?.(); + if (scope === undefined) return undefined; + + return { + db: db.withReadonlyTransactionScope?.(scope) ?? db, + close: function* () { + if (db.closeReadonlyTransactionScope) { + yield* db.closeReadonlyTransactionScope(scope); + } + }, + }; +}; + export function runSelector( db: HyperDB, gen: () => Generator, @@ -356,14 +378,26 @@ export function runSelector( ): TReturn { selectRangeCmds.splice(0, selectRangeCmds.length); + const scoped = createReadonlyScopedDB(db); + const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); - const result = execSync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), - ); - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); + try { + const result = execSync( + runCommandGenerator(runnerDB, gen(), { + ...options, + selectRangeCmds, + visited, + }), + ); + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); + } + return result; + } finally { + if (scoped) { + execSync(scoped.close()); + } } - return result; } export async function runSelectorAsync( @@ -374,14 +408,26 @@ export async function runSelectorAsync( ): Promise { selectRangeCmds.splice(0, selectRangeCmds.length); + const scoped = createReadonlyScopedDB(db); + const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); - const result = await execAsync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), - ); - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); + try { + const result = await execAsync( + runCommandGenerator(runnerDB, gen(), { + ...options, + selectRangeCmds, + visited, + }), + ); + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); + } + return result; + } finally { + if (scoped) { + await execAsync(scoped.close()); + } } - return result; } export function runSelectorMaybeAsync( @@ -392,24 +438,42 @@ export function runSelectorMaybeAsync( ): TReturn | Promise { selectRangeCmds.splice(0, selectRangeCmds.length); + const scoped = createReadonlyScopedDB(db); + const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); + const closeScope = () => { + if (scoped) { + return execMaybeAsync(scoped.close()); + } + }; + const result = execMaybeAsync( - runCommandGenerator(db, gen(), { ...options, selectRangeCmds, visited }), + runCommandGenerator(runnerDB, gen(), { + ...options, + selectRangeCmds, + visited, + }), ); if (result instanceof Promise) { - return result.then((value) => { - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); - } - return value; - }); + return result + .then((value) => { + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); + } + return value; + }) + .finally(closeScope); } - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); + try { + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); + } + return result; + } finally { + void closeScope(); } - return result; } export function initSelector( diff --git a/packages/hyperdb/src/hyperdb/core/contracts.ts b/packages/hyperdb/src/hyperdb/core/contracts.ts index 5910946..1fed81a 100644 --- a/packages/hyperdb/src/hyperdb/core/contracts.ts +++ b/packages/hyperdb/src/hyperdb/core/contracts.ts @@ -36,6 +36,9 @@ export interface HyperDB { getDBName?(): string | undefined; getTracer?(): HyperDBTracerOption | undefined; getOptions?(): CodecOptions; + createReadonlyTransactionScope?(): unknown; + withReadonlyTransactionScope?(scope: unknown): HyperDB; + closeReadonlyTransactionScope?(scope: unknown): Generator; beginTx(): Generator; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/hyperdb/src/hyperdb/core/driver.ts b/packages/hyperdb/src/hyperdb/core/driver.ts index 297a802..555d018 100644 --- a/packages/hyperdb/src/hyperdb/core/driver.ts +++ b/packages/hyperdb/src/hyperdb/core/driver.ts @@ -24,3 +24,19 @@ export interface DBDriverTX extends BaseDBDriverOperations { commit(): Generator; rollback(): Generator; } + +export type DBReadonlyTransactionScope = unknown; + +export interface DBReadonlyTransactionScopeDriver { + createReadonlyTransactionScope(): DBReadonlyTransactionScope; + closeReadonlyTransactionScope( + scope: DBReadonlyTransactionScope, + ): Generator; + intervalScanWithReadonlyTransactionScope( + scope: DBReadonlyTransactionScope, + table: string, + indexName: string, + clauses: WhereClause[], + selectOptions: SelectOptions, + ): Generator; +} 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..18a0f54 100644 --- a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts @@ -37,7 +37,14 @@ type LockRelease = () => void; type ActiveReadonlyTransaction = { tx: IDBTransaction; release: LockRelease; + scope: IdbReadonlyTransactionScope; finished: boolean; + released: boolean; +}; + +type IdbReadonlyTransactionScope = { + active?: ActiveReadonlyTransaction; + closed: 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}`); } } @@ -923,7 +932,7 @@ export class IdbDriver implements DBDriver { private readonly options: OpenIndexedDBDriverOptions; private tableDefinitions = new Map(); private lock = new AsyncReadWriteLock(); - private activeReadonlyTransaction: ActiveReadonlyTransaction | undefined; + private readonlyScopes = new Set(); private closedReason: Error | null = null; constructor( @@ -943,15 +952,24 @@ 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 scope of [...this.readonlyScopes]) { + this.disposeReadonlyTransactionScope(scope); } this.db.close(); } + createReadonlyTransactionScope(): IdbReadonlyTransactionScope { + const scope: IdbReadonlyTransactionScope = { closed: false }; + this.readonlyScopes.add(scope); + return scope; + } + + *closeReadonlyTransactionScope(scope: unknown): Generator { + this.disposeReadonlyTransactionScope( + this.assertReadonlyTransactionScope(scope), + ); + } + *beginTx(): Generator { const release = yield* unwrapCb(async () => this.lock.acquireWrite()); @@ -1051,6 +1069,29 @@ export class IdbDriver implements DBDriver { ); } + *intervalScanWithReadonlyTransactionScope( + scope: unknown, + table: string, + indexName: string, + clauses: WhereClause[], + selectOptions: SelectOptions, + ): Generator { + return yield* unwrapCb(async () => + this.withReadonlyTransaction( + this.assertReadonlyTransactionScope(scope), + async (tx) => + performScan( + tx, + this.tableDefinitions, + table, + indexName, + clauses, + selectOptions, + ), + ), + ); + } + private async ensureSchema( tableDefinitions: TableDefinition[], ): Promise { @@ -1204,7 +1245,12 @@ export class IdbDriver implements DBDriver { ): Generator { return yield* unwrapCb(async () => { if (mode === "readonly") { - return this.withReadonlyTransaction(run); + const scope = this.createReadonlyTransactionScope(); + try { + return await this.withReadonlyTransaction(scope, run); + } finally { + this.disposeReadonlyTransactionScope(scope); + } } const release = await this.lock.acquireWrite(); @@ -1255,12 +1301,13 @@ export class IdbDriver implements DBDriver { } private async withReadonlyTransaction( + scope: IdbReadonlyTransactionScope, run: (tx: IDBTransaction) => Promise, ): Promise { let canRetryInactiveTransaction = true; while (true) { - const active = await this.getReadonlyTransaction(); + const active = await this.getReadonlyTransaction(scope); try { return await run(active.tx); @@ -1270,22 +1317,24 @@ export class IdbDriver implements DBDriver { isInactiveTransactionError(error) ) { canRetryInactiveTransaction = false; - this.clearReadonlyTransaction(active); + logIdbOperation("transaction reopen", nowMs(), { + mode: active.tx.mode, + }); + this.finishReadonlyTransaction(active, false); continue; } - abortQuietly(active.tx); + this.finishReadonlyTransaction(active, true); throw error; } } } - private async getReadonlyTransaction(): Promise { - if ( - this.activeReadonlyTransaction && - !this.activeReadonlyTransaction.finished - ) { - return this.activeReadonlyTransaction; + private async getReadonlyTransaction( + scope: IdbReadonlyTransactionScope, + ): Promise { + if (scope.active && !scope.active.finished) { + return scope.active; } const release = await this.lock.acquireRead(); @@ -1293,14 +1342,19 @@ export class IdbDriver implements DBDriver { try { this.throwIfClosed(); + if (scope.closed) { + throw new Error("Readonly transaction scope is closed"); + } const tx = this.createTransaction(this.loadedStoreNames(), "readonly"); const active: ActiveReadonlyTransaction = { tx, release, + scope, finished: false, + released: false, }; - this.activeReadonlyTransaction = active; + scope.active = active; logIdbOperation("transaction start", transactionStartedAt, { mode: tx.mode, }); @@ -1327,11 +1381,7 @@ export class IdbDriver implements DBDriver { }, ) .finally(() => { - if (!active.finished) { - active.finished = true; - } - this.clearReadonlyTransaction(active); - active.release(); + this.finishReadonlyTransaction(active, false); }); return active; @@ -1349,9 +1399,48 @@ export class IdbDriver implements DBDriver { } } - private clearReadonlyTransaction(active: ActiveReadonlyTransaction): void { - if (this.activeReadonlyTransaction === active) { - this.activeReadonlyTransaction = undefined; + private assertReadonlyTransactionScope( + scope: unknown, + ): IdbReadonlyTransactionScope { + if ( + typeof scope !== "object" || + scope === null || + !this.readonlyScopes.has(scope as IdbReadonlyTransactionScope) + ) { + throw new Error("Invalid readonly transaction scope"); + } + + return scope as IdbReadonlyTransactionScope; + } + + private disposeReadonlyTransactionScope( + scope: IdbReadonlyTransactionScope, + ): void { + if (scope.closed) return; + scope.closed = true; + + if (scope.active) { + this.finishReadonlyTransaction(scope.active, true); + } + this.readonlyScopes.delete(scope); + } + + private finishReadonlyTransaction( + active: ActiveReadonlyTransaction, + abort: boolean, + ): void { + if (active.finished) return; + + active.finished = true; + if (active.scope.active === active) { + active.scope.active = undefined; + } + if (abort) { + abortQuietly(active.tx); + } + if (!active.released) { + active.released = true; + active.release(); } } diff --git a/packages/hyperdb/src/hyperdb/runtime/db.ts b/packages/hyperdb/src/hyperdb/runtime/db.ts index 5367dc0..927528f 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, + DBReadonlyTransactionScopeDriver, +} from "../core/driver"; import type { Row, SelectOptions, @@ -73,6 +77,7 @@ function* performScan( clauses: WhereClause[], options: CodecOptions, selectOptions?: SelectOptions, + readonlyTransactionScope?: unknown, ) { if (clauses.length === 0) { throw new Error("scan clauses must be provided"); @@ -91,16 +96,33 @@ function* performScan( // Validation-only; driver handles conversion. convertWhereToBound(indexConfig.cols as string[], clauses); - const records = yield* driver.intervalScan( - table.tableName, - indexName as string, - clauses, - selectOptions || {}, - ); + const records = + readonlyTransactionScope !== undefined && + isReadonlyTransactionScopeDriver(driver) + ? yield* driver.intervalScanWithReadonlyTransactionScope( + readonlyTransactionScope, + table.tableName, + indexName as string, + clauses, + selectOptions || {}, + ) + : yield* driver.intervalScan( + table.tableName, + indexName as string, + clauses, + selectOptions || {}, + ); return validateRecordsFromDriver(table, records, options); } +const isReadonlyTransactionScopeDriver = ( + driver: BaseDBDriverOperations, +): driver is BaseDBDriverOperations & DBReadonlyTransactionScopeDriver => + "createReadonlyTransactionScope" in driver && + "closeReadonlyTransactionScope" in driver && + "intervalScanWithReadonlyTransactionScope" in driver; + function* performInsert( driver: BaseDBDriverOperations, table: TableDefinition, @@ -146,6 +168,7 @@ export class DB implements HyperDB { driver: DBDriver; traits: Trait[] = []; private state: DBState; + private readonlyTransactionScope: unknown; constructor(driver: DBDriver, options?: DBOptions); constructor(driver: DBDriver, options: DBOptions = {}) { @@ -162,9 +185,31 @@ export class DB implements HyperDB { db.driver = this.driver; db.traits = [...this.traits, ...traits]; db.state = this.state; + db.readonlyTransactionScope = this.readonlyTransactionScope; + return db; + } + + createReadonlyTransactionScope(): unknown { + return isReadonlyTransactionScopeDriver(this.driver) + ? this.driver.createReadonlyTransactionScope() + : undefined; + } + + withReadonlyTransactionScope(scope: unknown): HyperDB { + const db = Object.create(DB.prototype) as DB; + db.driver = this.driver; + db.traits = this.traits; + db.state = this.state; + db.readonlyTransactionScope = scope; return db; } + *closeReadonlyTransactionScope(scope: unknown): Generator { + if (isReadonlyTransactionScopeDriver(this.driver)) { + yield* this.driver.closeReadonlyTransactionScope(scope); + } + } + getTraits(): Trait[] { return this.traits; } @@ -219,6 +264,7 @@ export class DB implements HyperDB { clauses, this.options, selectOptions, + this.readonlyTransactionScope, ) as Generator[]>; } diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts index 691eb73..9bc6087 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 | (() => HyperDB), 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" ? 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..11f3789 100644 --- a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts @@ -30,6 +30,11 @@ type HybridDBState = { lock: AwaitLock; }; +type HybridReadonlyTransactionScope = { + primaryScope?: unknown; + primaryDB?: HyperDB; +}; + type HybridDBTxState = { cachedIntervals: HybridIntervalCache; committed: RefVar; @@ -87,6 +92,7 @@ export class HybridDB implements HyperDB { cache: HyperDB; traits: Trait[] = []; private state: HybridDBState; + private readonlyTransactionScope?: HybridReadonlyTransactionScope; constructor(primary: HyperDB, cache: HyperDB, options: HybridDBOptions = {}) { this.primary = primary; @@ -100,9 +106,35 @@ export class HybridDB implements HyperDB { traits: [...this.traits, ...traits], }); db.state = this.state; + db.readonlyTransactionScope = this.readonlyTransactionScope; + return db; + } + + createReadonlyTransactionScope(): unknown { + return {}; + } + + withReadonlyTransactionScope(scope: unknown): HyperDB { + const db = new HybridDB(this.primary, this.cache, { + traits: this.traits, + }); + db.state = this.state; + db.readonlyTransactionScope = scope as HybridReadonlyTransactionScope; return db; } + *closeReadonlyTransactionScope(scope: unknown): Generator { + const hybridScope = scope as HybridReadonlyTransactionScope; + if ( + hybridScope.primaryScope !== undefined && + this.primary.closeReadonlyTransactionScope + ) { + yield* this.primary.closeReadonlyTransactionScope( + hybridScope.primaryScope, + ); + } + } + getTraits(): Trait[] { return [...this.traits, ...this.primary.getTraits()]; } @@ -124,14 +156,12 @@ 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 { @@ -163,60 +193,67 @@ export class HybridDB implements HyperDB { clauses: WhereClause[], selectOptions?: SelectOptions, ): Generator[]> { - return yield* withHybridLock( - this.state, - function* () { - return yield* hybridIntervalScan( - this.primary, - this.cache, - this.state.cachedIntervals, - getCurrentSelectEventForDB(this), - table, - indexName, - clauses, - selectOptions, - ); - }.bind(this), - ); + const { cache, primary, state } = this; + const selectEvent = getCurrentSelectEventForDB(this); + const readonlyScope = this.readonlyTransactionScope; + const getPrimary = () => { + if (!readonlyScope) return primary; + if (readonlyScope.primaryDB) return readonlyScope.primaryDB; + + const primaryScope = primary.createReadonlyTransactionScope?.(); + readonlyScope.primaryScope = primaryScope; + readonlyScope.primaryDB = + primaryScope !== undefined && primary.withReadonlyTransactionScope + ? primary.withReadonlyTransactionScope(primaryScope) + : primary; + return readonlyScope.primaryDB; + }; + + return yield* withHybridLock(this.state, function* () { + return yield* hybridIntervalScan( + getPrimary, + cache, + state.cachedIntervals, + selectEvent, + table, + indexName, + clauses, + selectOptions, + ); + }); } *insert( table: TTable, records: ExtractSchema[], ): Generator { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.insert(table, records); - yield* this.cache.insert(table, records); - }.bind(this), - ); + 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 { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.upsert(table, records); - yield* this.cache.upsert(table, records); - }.bind(this), - ); + 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 { - yield* withHybridLock( - this.state, - function* () { - yield* this.primary.delete(table, ids); - yield* this.cache.delete(table, ids); - }.bind(this), - ); + const { cache, primary } = this; + yield* withHybridLock(this.state, function* () { + yield* primary.delete(table, ids); + yield* cache.delete(table, ids); + }); } mergeTxCoverage(intervals: HybridIntervalCache): void { diff --git a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts index 048e48d..02a791f 100644 --- a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts @@ -456,6 +456,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(), @@ -516,6 +517,25 @@ export class SubscribableDB implements HyperDB { return db; } + createReadonlyTransactionScope(): unknown { + return this.delegateDB().createReadonlyTransactionScope?.(); + } + + withReadonlyTransactionScope(scope: unknown): HyperDB { + const delegate = this.delegateDB(); + return new SubscribableDB( + delegate.withReadonlyTransactionScope?.(scope) ?? delegate, + this.state, + ); + } + + *closeReadonlyTransactionScope(scope: unknown): Generator { + const delegate = this.delegateDB(); + if (delegate.closeReadonlyTransactionScope) { + yield* delegate.closeReadonlyTransactionScope(scope); + } + } + getTraits(): Trait[] { return [...this.traits, ...this.db.getTraits()]; } diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 4b580f1..9e3d3e0 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(); @@ -183,6 +186,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 +197,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 +242,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 +253,7 @@ describe("useAsyncSelector", () => { pending.resolve("late"); await flushPromises(); - expect(mocks.setResult).not.toHaveBeenCalled(); + expect(mocks.setState).not.toHaveBeenCalled(); expect(selectRangeCmdsRef.current).toEqual([]); }); @@ -259,7 +269,10 @@ 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(); @@ -288,8 +301,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 +331,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 +360,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..6548d6f 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -42,18 +42,113 @@ type SyncSelectorMaybeDisabledOptions = { 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); + staleTime?: number | "static"; + 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 +161,113 @@ 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 getStaleTime = (staleTime: number | "static" | undefined) => + staleTime ?? 0; + const defaultHookDeps = { useCallback, useEffect, @@ -133,113 +335,285 @@ export function useSyncSelector( } export function useAsyncSelector( - options: AsyncSelectorEnabledOptions & { - defaultValue: SelectorReturn; - }, -): SelectorReturn; -export function useAsyncSelector( - options: AsyncSelectorEnabledOptions, -): SelectorReturn | undefined; -export function useAsyncSelector( - options: AsyncSelectorMaybeDisabledOptions, -): SelectorReturn; + options: AsyncSelectorDefinedOptions, +): UseAsyncSelectorDefinedResult>; 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 isStale = (() => { + if (queryState.status !== "success") return true; + + const staleTime = getStaleTime(input.staleTime); + if (staleTime === "static" || staleTime === Infinity) return false; + + return Date.now() - queryState.dataUpdatedAt > staleTime; + })(); + const isPending = queryState.status === "pending"; + const isError = queryState.status === "error"; + const isFetching = queryState.fetchStatus === "fetching"; + const isPaused = queryState.fetchStatus === "paused"; + const result: UseAsyncSelectorResult, TError> = { + ...queryState, + isEnabled: 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, + }; + 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; + 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); + 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; + 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); + 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 +625,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]); - if (input.enabled === false) { - return input.defaultValue; + if (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; From cad1ff7729e30100f4642979eae96db2ae3c030b Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 26 Jun 2026 00:45:01 +0300 Subject: [PATCH 2/5] fix: format --- packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts index 18a0f54..5b4504b 100644 --- a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts +++ b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts @@ -1312,10 +1312,7 @@ export class IdbDriver implements DBDriver { try { return await run(active.tx); } catch (error) { - if ( - canRetryInactiveTransaction && - isInactiveTransactionError(error) - ) { + if (canRetryInactiveTransaction && isInactiveTransactionError(error)) { canRetryInactiveTransaction = false; logIdbOperation("transaction reopen", nowMs(), { mode: active.tx.mode, From 9a559c74f198f738cef8a92a522f99bd996271d7 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 26 Jun 2026 01:30:05 +0300 Subject: [PATCH 3/5] feat: add readonly tx --- README.md | 4 +- .../src/content/docs/runtime/db.md | 2 + .../src/content/docs/runtime/drivers.md | 4 +- .../src/hyperdb/commands/selector/selector.ts | 108 ++--- .../hyperdb/src/hyperdb/core/contracts.ts | 10 +- packages/hyperdb/src/hyperdb/core/driver.ts | 21 +- .../src/hyperdb/drivers/idb/idb-driver.ts | 457 ++++++++++-------- packages/hyperdb/src/hyperdb/runtime/db-tx.ts | 4 +- packages/hyperdb/src/hyperdb/runtime/db.ts | 67 +-- .../hyperdb/runtime/hybrid-db-intervals.ts | 4 +- .../hyperdb/src/hyperdb/runtime/hybrid-db.ts | 215 ++++++-- .../src/hyperdb/runtime/subscribable-db.ts | 30 +- 12 files changed, 504 insertions(+), 422 deletions(-) diff --git a/README.md b/README.md index c304506..dfe2b82 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ state libraries start to strain: 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. With an IndexedDB primary, readonly transaction - reuse is scoped to a single selector run and starts only when the persistent - tier is actually read. + reuse is scoped to a single selector execution and 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 diff --git a/packages/hyperdb-doc/src/content/docs/runtime/db.md b/packages/hyperdb-doc/src/content/docs/runtime/db.md index 4ab7b3d..740df02 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/db.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/db.md @@ -140,6 +140,8 @@ 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 may also support readonly transactions with `beginTx("readonly")`; +HyperDB uses that internally for selector-scoped IndexedDB reads. 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 c66ed02..04c49f0 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md @@ -315,8 +315,8 @@ persistent backends. Selector reads use readonly IndexedDB transactions; 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 -transaction scopes, and an inactive readonly transaction is reopened once for -the current scan. +transactions, and an inactive readonly transaction is reopened once for the +current scan. ## Sync vs. async, in practice diff --git a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts index a6b721b..c6a7a55 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts @@ -349,26 +349,24 @@ type RunSelectorOptions = Pick; const makeVisited = (options: RunSelectorOptions): ChildVisited | undefined => options.childMemo ? new Map() : undefined; -const createReadonlyScopedDB = ( +function* runCommandGeneratorWithReadonlyTransaction( db: HyperDB, -): - | { - db: HyperDB; - close: () => Generator; - } - | undefined => { - const scope = db.createReadonlyTransactionScope?.(); - if (scope === undefined) return undefined; + gen: Generator, + options: CommandRunnerOptions, +): Generator { + const tx = db.beginReadonlyTransactionForSelectors + ? yield* db.beginReadonlyTransactionForSelectors() + : undefined; + const runnerDB = tx ?? db; - return { - db: db.withReadonlyTransactionScope?.(scope) ?? db, - close: function* () { - if (db.closeReadonlyTransactionScope) { - yield* db.closeReadonlyTransactionScope(scope); - } - }, - }; -}; + try { + return yield* runCommandGenerator(runnerDB, gen, options); + } finally { + if (tx) { + yield* tx.rollback(); + } + } +} export function runSelector( db: HyperDB, @@ -378,26 +376,18 @@ export function runSelector( ): TReturn { selectRangeCmds.splice(0, selectRangeCmds.length); - const scoped = createReadonlyScopedDB(db); - const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); - try { - const result = execSync( - runCommandGenerator(runnerDB, gen(), { + const result = execSync( + runCommandGeneratorWithReadonlyTransaction(db, gen(), { ...options, selectRangeCmds, visited, }), - ); - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); - } - return result; - } finally { - if (scoped) { - execSync(scoped.close()); - } + ); + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); } + return result; } export async function runSelectorAsync( @@ -408,26 +398,18 @@ export async function runSelectorAsync( ): Promise { selectRangeCmds.splice(0, selectRangeCmds.length); - const scoped = createReadonlyScopedDB(db); - const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); - try { - const result = await execAsync( - runCommandGenerator(runnerDB, gen(), { + const result = await execAsync( + runCommandGeneratorWithReadonlyTransaction(db, gen(), { ...options, selectRangeCmds, visited, }), - ); - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); - } - return result; - } finally { - if (scoped) { - await execAsync(scoped.close()); - } + ); + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); } + return result; } export function runSelectorMaybeAsync( @@ -438,17 +420,9 @@ export function runSelectorMaybeAsync( ): TReturn | Promise { selectRangeCmds.splice(0, selectRangeCmds.length); - const scoped = createReadonlyScopedDB(db); - const runnerDB = scoped?.db ?? db; const visited = makeVisited(options); - const closeScope = () => { - if (scoped) { - return execMaybeAsync(scoped.close()); - } - }; - const result = execMaybeAsync( - runCommandGenerator(runnerDB, gen(), { + runCommandGeneratorWithReadonlyTransaction(db, gen(), { ...options, selectRangeCmds, visited, @@ -456,24 +430,18 @@ export function runSelectorMaybeAsync( ); if (result instanceof Promise) { - return result - .then((value) => { - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); - } - return value; - }) - .finally(closeScope); + return result.then((value) => { + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); + } + return value; + }); } - try { - if (options.childMemo && visited) { - pruneChildMemo(options.childMemo, visited); - } - return result; - } finally { - void closeScope(); + if (options.childMemo && visited) { + pruneChildMemo(options.childMemo, visited); } + return result; } export function initSelector( diff --git a/packages/hyperdb/src/hyperdb/core/contracts.ts b/packages/hyperdb/src/hyperdb/core/contracts.ts index 1fed81a..5c9d6e1 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,11 +37,12 @@ export interface HyperDB { getDBName?(): string | undefined; getTracer?(): HyperDBTracerOption | undefined; getOptions?(): CodecOptions; - createReadonlyTransactionScope?(): unknown; - withReadonlyTransactionScope?(scope: unknown): HyperDB; - closeReadonlyTransactionScope?(scope: unknown): Generator; + beginReadonlyTransactionForSelectors?(): Generator< + DBCmd, + HyperDBTx | undefined + >; - 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 555d018..1093f53 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,26 +19,11 @@ 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 { commit(): Generator; rollback(): Generator; } - -export type DBReadonlyTransactionScope = unknown; - -export interface DBReadonlyTransactionScopeDriver { - createReadonlyTransactionScope(): DBReadonlyTransactionScope; - closeReadonlyTransactionScope( - scope: DBReadonlyTransactionScope, - ): Generator; - intervalScanWithReadonlyTransactionScope( - scope: DBReadonlyTransactionScope, - table: string, - indexName: string, - clauses: WhereClause[], - selectOptions: SelectOptions, - ): Generator; -} diff --git a/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts b/packages/hyperdb/src/hyperdb/drivers/idb/idb-driver.ts index 5b4504b..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,16 +41,12 @@ type LockRelease = () => void; type ActiveReadonlyTransaction = { tx: IDBTransaction; release: LockRelease; - scope: IdbReadonlyTransactionScope; + done: Promise; + startedAt: number; finished: boolean; released: boolean; }; -type IdbReadonlyTransactionScope = { - active?: ActiveReadonlyTransaction; - closed: boolean; -}; - export type OpenIndexedDBDriverOptions = { indexedDB?: IDBFactory; version?: number; @@ -925,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; @@ -932,7 +1116,7 @@ export class IdbDriver implements DBDriver { private readonly options: OpenIndexedDBDriverOptions; private tableDefinitions = new Map(); private lock = new AsyncReadWriteLock(); - private readonlyScopes = new Set(); + private readonlyTransactions = new Set(); private closedReason: Error | null = null; constructor( @@ -952,25 +1136,23 @@ export class IdbDriver implements DBDriver { if (!this.closedReason) { this.closedReason = reason; } - for (const scope of [...this.readonlyScopes]) { - this.disposeReadonlyTransactionScope(scope); + for (const tx of [...this.readonlyTransactions]) { + tx.dispose(); } this.db.close(); } - createReadonlyTransactionScope(): IdbReadonlyTransactionScope { - const scope: IdbReadonlyTransactionScope = { closed: false }; - this.readonlyScopes.add(scope); - return scope; + canUseReadonlyTransactionsForSelectors(): boolean { + return true; } - *closeReadonlyTransactionScope(scope: unknown): Generator { - this.disposeReadonlyTransactionScope( - this.assertReadonlyTransactionScope(scope), - ); - } + *beginTx( + mode: DBTransactionMode = "readwrite", + ): Generator { + if (mode === "readonly") { + return yield* this.beginReadonlyTx(); + } - *beginTx(): Generator { const release = yield* unwrapCb(async () => this.lock.acquireWrite()); let tx: IDBTransaction; @@ -989,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 { @@ -1054,42 +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, - ), - ); - } - - *intervalScanWithReadonlyTransactionScope( - scope: unknown, - table: string, - indexName: string, - clauses: WhereClause[], - selectOptions: SelectOptions, - ): Generator { - return yield* unwrapCb(async () => - this.withReadonlyTransaction( - this.assertReadonlyTransactionScope(scope), - 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( @@ -1244,15 +1439,6 @@ export class IdbDriver implements DBDriver { run: (tx: IDBTransaction) => Promise, ): Generator { return yield* unwrapCb(async () => { - if (mode === "readonly") { - const scope = this.createReadonlyTransactionScope(); - try { - return await this.withReadonlyTransaction(scope, run); - } finally { - this.disposeReadonlyTransactionScope(scope); - } - } - const release = await this.lock.acquireWrite(); let tx: IDBTransaction | undefined; let done: Promise | undefined; @@ -1300,147 +1486,6 @@ export class IdbDriver implements DBDriver { }); } - private async withReadonlyTransaction( - scope: IdbReadonlyTransactionScope, - run: (tx: IDBTransaction) => Promise, - ): Promise { - let canRetryInactiveTransaction = true; - - while (true) { - const active = await this.getReadonlyTransaction(scope); - - try { - return await run(active.tx); - } catch (error) { - if (canRetryInactiveTransaction && isInactiveTransactionError(error)) { - canRetryInactiveTransaction = false; - logIdbOperation("transaction reopen", nowMs(), { - mode: active.tx.mode, - }); - this.finishReadonlyTransaction(active, false); - continue; - } - - this.finishReadonlyTransaction(active, true); - throw error; - } - } - } - - private async getReadonlyTransaction( - scope: IdbReadonlyTransactionScope, - ): Promise { - if (scope.active && !scope.active.finished) { - return scope.active; - } - - const release = await this.lock.acquireRead(); - const transactionStartedAt = nowMs(); - - try { - this.throwIfClosed(); - if (scope.closed) { - throw new Error("Readonly transaction scope is closed"); - } - const tx = this.createTransaction(this.loadedStoreNames(), "readonly"); - const active: ActiveReadonlyTransaction = { - tx, - release, - scope, - finished: false, - released: false, - }; - - scope.active = 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(() => { - this.finishReadonlyTransaction(active, false); - }); - - return active; - } catch (error) { - logIdbOperation( - "transaction start", - transactionStartedAt, - { - mode: "readonly", - }, - error, - ); - release(); - throw error; - } - } - - private assertReadonlyTransactionScope( - scope: unknown, - ): IdbReadonlyTransactionScope { - if ( - typeof scope !== "object" || - scope === null || - !this.readonlyScopes.has(scope as IdbReadonlyTransactionScope) - ) { - throw new Error("Invalid readonly transaction scope"); - } - - return scope as IdbReadonlyTransactionScope; - } - - private disposeReadonlyTransactionScope( - scope: IdbReadonlyTransactionScope, - ): void { - if (scope.closed) return; - scope.closed = true; - - if (scope.active) { - this.finishReadonlyTransaction(scope.active, true); - } - this.readonlyScopes.delete(scope); - } - - private finishReadonlyTransaction( - active: ActiveReadonlyTransaction, - abort: boolean, - ): void { - if (active.finished) return; - - active.finished = true; - if (active.scope.active === active) { - active.scope.active = undefined; - } - if (abort) { - abortQuietly(active.tx); - } - if (!active.released) { - active.released = true; - active.release(); - } - } - private attachVersionChangeHandler(): void { this.db.onversionchange = (event) => { this.options.onVersionChange?.(event); diff --git a/packages/hyperdb/src/hyperdb/runtime/db-tx.ts b/packages/hyperdb/src/hyperdb/runtime/db-tx.ts index c72dd88..b160dba 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,7 @@ export class DBTx implements HyperDBTx { return this.options; } - *beginTx(): Generator { + *beginTx(_mode: DBTransactionMode = "readwrite"): Generator { if (this.isFinished.val) { throw new Error("Transaction is finished"); } diff --git a/packages/hyperdb/src/hyperdb/runtime/db.ts b/packages/hyperdb/src/hyperdb/runtime/db.ts index 927528f..17dd629 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { convertWhereToBound } from "../core/query/bounds"; import type { DBCmd } from "../commands/async"; -import type { HyperDB } from "../core/contracts"; +import type { HyperDB, HyperDBTx } from "../core/contracts"; import type { BaseDBDriverOperations, DBDriver, - DBReadonlyTransactionScopeDriver, + DBTransactionMode, } from "../core/driver"; import type { Row, @@ -77,7 +77,6 @@ function* performScan( clauses: WhereClause[], options: CodecOptions, selectOptions?: SelectOptions, - readonlyTransactionScope?: unknown, ) { if (clauses.length === 0) { throw new Error("scan clauses must be provided"); @@ -96,33 +95,16 @@ function* performScan( // Validation-only; driver handles conversion. convertWhereToBound(indexConfig.cols as string[], clauses); - const records = - readonlyTransactionScope !== undefined && - isReadonlyTransactionScopeDriver(driver) - ? yield* driver.intervalScanWithReadonlyTransactionScope( - readonlyTransactionScope, - table.tableName, - indexName as string, - clauses, - selectOptions || {}, - ) - : yield* driver.intervalScan( - table.tableName, - indexName as string, - clauses, - selectOptions || {}, - ); + const records = yield* driver.intervalScan( + table.tableName, + indexName as string, + clauses, + selectOptions || {}, + ); return validateRecordsFromDriver(table, records, options); } -const isReadonlyTransactionScopeDriver = ( - driver: BaseDBDriverOperations, -): driver is BaseDBDriverOperations & DBReadonlyTransactionScopeDriver => - "createReadonlyTransactionScope" in driver && - "closeReadonlyTransactionScope" in driver && - "intervalScanWithReadonlyTransactionScope" in driver; - function* performInsert( driver: BaseDBDriverOperations, table: TableDefinition, @@ -168,7 +150,6 @@ export class DB implements HyperDB { driver: DBDriver; traits: Trait[] = []; private state: DBState; - private readonlyTransactionScope: unknown; constructor(driver: DBDriver, options?: DBOptions); constructor(driver: DBDriver, options: DBOptions = {}) { @@ -185,29 +166,18 @@ export class DB implements HyperDB { db.driver = this.driver; db.traits = [...this.traits, ...traits]; db.state = this.state; - db.readonlyTransactionScope = this.readonlyTransactionScope; return db; } - createReadonlyTransactionScope(): unknown { - return isReadonlyTransactionScopeDriver(this.driver) - ? this.driver.createReadonlyTransactionScope() - : undefined; - } - - withReadonlyTransactionScope(scope: unknown): HyperDB { - const db = Object.create(DB.prototype) as DB; - db.driver = this.driver; - db.traits = this.traits; - db.state = this.state; - db.readonlyTransactionScope = scope; - return db; - } - - *closeReadonlyTransactionScope(scope: unknown): Generator { - if (isReadonlyTransactionScopeDriver(this.driver)) { - yield* this.driver.closeReadonlyTransactionScope(scope); + *beginReadonlyTransactionForSelectors(): Generator< + DBCmd, + HyperDBTx | undefined + > { + if (this.driver.canUseReadonlyTransactionsForSelectors?.() !== true) { + return undefined; } + + return yield* this.beginTx("readonly"); } getTraits(): Trait[] { @@ -243,8 +213,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); } @@ -264,7 +234,6 @@ export class DB implements HyperDB { clauses, this.options, selectOptions, - this.readonlyTransactionScope, ) as Generator[]>; } diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db-intervals.ts index 9bc6087..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 | (() => HyperDB), + primary: HyperDB | (() => Generator), cache: HyperDB, cachedIntervals: HybridIntervalCache, selectEvent: SelectCommandEvent | undefined, @@ -394,7 +394,7 @@ export function* hybridIntervalScan( } setSelectSource(selectEvent, "persist"); - const primaryDB = typeof primary === "function" ? primary() : primary; + const primaryDB = typeof primary === "function" ? yield* primary() : primary; const primaryRows = yield* primaryDB.intervalScan( table, indexName, diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts index 11f3789..84ac224 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, @@ -30,11 +31,6 @@ type HybridDBState = { lock: AwaitLock; }; -type HybridReadonlyTransactionScope = { - primaryScope?: unknown; - primaryDB?: HyperDB; -}; - type HybridDBTxState = { cachedIntervals: HybridIntervalCache; committed: RefVar; @@ -43,6 +39,12 @@ type HybridDBTxState = { releaseLock: () => void; }; +type HybridReadonlyTxState = { + primaryTx?: HyperDBTx; + rollbacked: RefVar; + txCounter: RefVar; +}; + const createHybridDBState = (): HybridDBState => ({ cachedIntervals: createHybridIntervalCache(), lock: new AwaitLock(), @@ -56,6 +58,11 @@ const createHybridDBTxState = (releaseLock: () => void): HybridDBTxState => ({ releaseLock, }); +const createHybridReadonlyTxState = (): HybridReadonlyTxState => ({ + rollbacked: refVar(false), + txCounter: refVar(1), +}); + export type HybridDBOptions = { traits?: Trait[]; }; @@ -91,8 +98,7 @@ export class HybridDB implements HyperDB { primary: HyperDB; cache: HyperDB; traits: Trait[] = []; - private state: HybridDBState; - private readonlyTransactionScope?: HybridReadonlyTransactionScope; + state: HybridDBState; constructor(primary: HyperDB, cache: HyperDB, options: HybridDBOptions = {}) { this.primary = primary; @@ -106,33 +112,11 @@ export class HybridDB implements HyperDB { traits: [...this.traits, ...traits], }); db.state = this.state; - db.readonlyTransactionScope = this.readonlyTransactionScope; return db; } - createReadonlyTransactionScope(): unknown { - return {}; - } - - withReadonlyTransactionScope(scope: unknown): HyperDB { - const db = new HybridDB(this.primary, this.cache, { - traits: this.traits, - }); - db.state = this.state; - db.readonlyTransactionScope = scope as HybridReadonlyTransactionScope; - return db; - } - - *closeReadonlyTransactionScope(scope: unknown): Generator { - const hybridScope = scope as HybridReadonlyTransactionScope; - if ( - hybridScope.primaryScope !== undefined && - this.primary.closeReadonlyTransactionScope - ) { - yield* this.primary.closeReadonlyTransactionScope( - hybridScope.primaryScope, - ); - } + *beginReadonlyTransactionForSelectors(): Generator { + return new HybridDBReadonlyTx(this); } getTraits(): Trait[] { @@ -164,12 +148,18 @@ export class HybridDB implements HyperDB { }); } - *beginTx(): Generator { + *beginTx( + mode: DBTransactionMode = "readwrite", + ): Generator { + if (mode === "readonly") { + return yield* this.beginReadonlyTransactionForSelectors(); + } + 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) { @@ -195,23 +185,9 @@ export class HybridDB implements HyperDB { ): Generator[]> { const { cache, primary, state } = this; const selectEvent = getCurrentSelectEventForDB(this); - const readonlyScope = this.readonlyTransactionScope; - const getPrimary = () => { - if (!readonlyScope) return primary; - if (readonlyScope.primaryDB) return readonlyScope.primaryDB; - - const primaryScope = primary.createReadonlyTransactionScope?.(); - readonlyScope.primaryScope = primaryScope; - readonlyScope.primaryDB = - primaryScope !== undefined && primary.withReadonlyTransactionScope - ? primary.withReadonlyTransactionScope(primaryScope) - : primary; - return readonlyScope.primaryDB; - }; - return yield* withHybridLock(this.state, function* () { return yield* hybridIntervalScan( - getPrimary, + primary, cache, state.cachedIntervals, selectEvent, @@ -261,6 +237,143 @@ export class HybridDB implements HyperDB { } } +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; + } + + *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.beginReadonlyTransactionForSelectors + ? yield* primary.beginReadonlyTransactionForSelectors() + : undefined; + + this.state.primaryTx = tx; + return tx ?? primary; + }.bind(this); + + return yield* withHybridLock(state, function* () { + return yield* hybridIntervalScan( + getPrimaryForRead, + cache, + state.cachedIntervals, + selectEvent, + table, + indexName, + clauses, + selectOptions, + ); + }.bind(this)); + } + + *insert( + _table: TTable, + _records: ExtractSchema[], + ): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *upsert( + _table: TTable, + _records: ExtractSchema[], + ): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *delete( + _table: TTable, + _ids: string[], + ): Generator { + throw new Error("Cannot write through a readonly transaction"); + } + + *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"); + } + } +} + class HybridDBTx implements HyperDBTx { private hybridDB: HybridDB; private primaryTx: HyperDBTx; @@ -318,7 +431,9 @@ class HybridDBTx implements HyperDBTx { 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 02a791f..537c188 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, @@ -158,7 +159,7 @@ export class SubscribableDBTx implements HyperDBTx { ]); } - *beginTx(): Generator { + *beginTx(_mode: DBTransactionMode = "readwrite"): Generator { this.state.txCounter.val++; return this; @@ -506,8 +507,8 @@ 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)); } withTraits(...traits: Trait[]): HyperDB { @@ -517,23 +518,16 @@ export class SubscribableDB implements HyperDB { return db; } - createReadonlyTransactionScope(): unknown { - return this.delegateDB().createReadonlyTransactionScope?.(); - } - - withReadonlyTransactionScope(scope: unknown): HyperDB { + *beginReadonlyTransactionForSelectors(): Generator< + DBCmd, + HyperDBTx | undefined + > { const delegate = this.delegateDB(); - return new SubscribableDB( - delegate.withReadonlyTransactionScope?.(scope) ?? delegate, - this.state, - ); - } + const tx = delegate.beginReadonlyTransactionForSelectors + ? yield* delegate.beginReadonlyTransactionForSelectors() + : undefined; - *closeReadonlyTransactionScope(scope: unknown): Generator { - const delegate = this.delegateDB(); - if (delegate.closeReadonlyTransactionScope) { - yield* delegate.closeReadonlyTransactionScope(scope); - } + return tx ? new SubscribableDBTx(this, tx) : undefined; } getTraits(): Trait[] { From b881446360286fec459725a0bb000b33dec6406a Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 26 Jun 2026 13:47:57 +0300 Subject: [PATCH 4/5] feat: improve readonly tx --- README.md | 7 +-- .../src/content/docs/runtime/db.md | 6 ++- .../src/content/docs/runtime/drivers.md | 12 ++--- .../src/hyperdb/commands/selector/selector.ts | 20 ++++---- .../hyperdb/src/hyperdb/core/contracts.ts | 5 +- packages/hyperdb/src/hyperdb/core/driver.ts | 2 +- .../drivers/inmemory/bptree-inmem-driver.ts | 4 ++ .../drivers/sqlite/async-sql-driver.ts | 4 ++ .../src/hyperdb/drivers/sqlite/sql-driver.ts | 4 ++ packages/hyperdb/src/hyperdb/runtime/db-tx.ts | 8 ++- .../src/hyperdb/runtime/db-validation.test.ts | 4 ++ packages/hyperdb/src/hyperdb/runtime/db.ts | 13 ++--- .../hyperdb/src/hyperdb/runtime/hybrid-db.ts | 49 +++++++++++-------- .../src/hyperdb/runtime/subscribable-db.ts | 24 +++++---- .../src/hyperdb/tracing/runtime.test.ts | 4 ++ 15 files changed, 96 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index dfe2b82..cfbe186 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ 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. With an IndexedDB primary, readonly transaction - reuse is scoped to a single selector execution and starts only when the - persistent tier is actually read. + 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 diff --git a/packages/hyperdb-doc/src/content/docs/runtime/db.md b/packages/hyperdb-doc/src/content/docs/runtime/db.md index 740df02..7fdde0e 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/db.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/db.md @@ -140,8 +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 may also support readonly transactions with `beginTx("readonly")`; -HyperDB uses that internally for selector-scoped IndexedDB reads. +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 04c49f0..1f904e7 100644 --- a/packages/hyperdb-doc/src/content/docs/runtime/drivers.md +++ b/packages/hyperdb-doc/src/content/docs/runtime/drivers.md @@ -311,12 +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 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. +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/src/hyperdb/commands/selector/selector.ts b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts index c6a7a55..22e18ea 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts @@ -354,8 +354,8 @@ function* runCommandGeneratorWithReadonlyTransaction( gen: Generator, options: CommandRunnerOptions, ): Generator { - const tx = db.beginReadonlyTransactionForSelectors - ? yield* db.beginReadonlyTransactionForSelectors() + const tx = db.canUseReadonlyTransactionsForSelectors() + ? yield* db.beginTx("readonly") : undefined; const runnerDB = tx ?? db; @@ -379,10 +379,10 @@ export function runSelector( const visited = makeVisited(options); const result = execSync( runCommandGeneratorWithReadonlyTransaction(db, gen(), { - ...options, - selectRangeCmds, - visited, - }), + ...options, + selectRangeCmds, + visited, + }), ); if (options.childMemo && visited) { pruneChildMemo(options.childMemo, visited); @@ -401,10 +401,10 @@ export async function runSelectorAsync( const visited = makeVisited(options); const result = await execAsync( runCommandGeneratorWithReadonlyTransaction(db, gen(), { - ...options, - selectRangeCmds, - visited, - }), + ...options, + selectRangeCmds, + visited, + }), ); if (options.childMemo && visited) { pruneChildMemo(options.childMemo, visited); diff --git a/packages/hyperdb/src/hyperdb/core/contracts.ts b/packages/hyperdb/src/hyperdb/core/contracts.ts index 5c9d6e1..af884bb 100644 --- a/packages/hyperdb/src/hyperdb/core/contracts.ts +++ b/packages/hyperdb/src/hyperdb/core/contracts.ts @@ -37,10 +37,7 @@ export interface HyperDB { getDBName?(): string | undefined; getTracer?(): HyperDBTracerOption | undefined; getOptions?(): CodecOptions; - beginReadonlyTransactionForSelectors?(): Generator< - DBCmd, - HyperDBTx | undefined - >; + canUseReadonlyTransactionsForSelectors(): boolean; beginTx(mode?: DBTransactionMode): Generator; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/hyperdb/src/hyperdb/core/driver.ts b/packages/hyperdb/src/hyperdb/core/driver.ts index 1093f53..e3e362f 100644 --- a/packages/hyperdb/src/hyperdb/core/driver.ts +++ b/packages/hyperdb/src/hyperdb/core/driver.ts @@ -20,7 +20,7 @@ export type BaseDBDriverOperations = { export interface DBDriver extends BaseDBDriverOperations { loadTables(table: TableDefinition[]): Generator; beginTx(mode?: DBTransactionMode): Generator; - canUseReadonlyTransactionsForSelectors?(): boolean; + canUseReadonlyTransactionsForSelectors(): boolean; } export interface DBDriverTX extends BaseDBDriverOperations { 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 b160dba..3b77654 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db-tx.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db-tx.ts @@ -165,7 +165,13 @@ export class DBTx implements HyperDBTx { return this.options; } - *beginTx(_mode: DBTransactionMode = "readwrite"): 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 17dd629..ec278f4 100644 --- a/packages/hyperdb/src/hyperdb/runtime/db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/db.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { convertWhereToBound } from "../core/query/bounds"; import type { DBCmd } from "../commands/async"; -import type { HyperDB, HyperDBTx } from "../core/contracts"; +import type { HyperDB } from "../core/contracts"; import type { BaseDBDriverOperations, DBDriver, @@ -169,15 +169,8 @@ export class DB implements HyperDB { return db; } - *beginReadonlyTransactionForSelectors(): Generator< - DBCmd, - HyperDBTx | undefined - > { - if (this.driver.canUseReadonlyTransactionsForSelectors?.() !== true) { - return undefined; - } - - return yield* this.beginTx("readonly"); + canUseReadonlyTransactionsForSelectors(): boolean { + return this.driver.canUseReadonlyTransactionsForSelectors(); } getTraits(): Trait[] { diff --git a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts index 84ac224..59cf9bf 100644 --- a/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/hybrid-db.ts @@ -115,8 +115,8 @@ export class HybridDB implements HyperDB { return db; } - *beginReadonlyTransactionForSelectors(): Generator { - return new HybridDBReadonlyTx(this); + canUseReadonlyTransactionsForSelectors(): boolean { + return this.primary.canUseReadonlyTransactionsForSelectors(); } getTraits(): Trait[] { @@ -148,11 +148,9 @@ export class HybridDB implements HyperDB { }); } - *beginTx( - mode: DBTransactionMode = "readwrite", - ): Generator { + *beginTx(mode: DBTransactionMode = "readwrite"): Generator { if (mode === "readonly") { - return yield* this.beginReadonlyTransactionForSelectors(); + return new HybridDBReadonlyTx(this); } const release = yield* acquireHybridLock(this.state); @@ -279,6 +277,10 @@ class HybridDBReadonlyTx implements HyperDBTx { return this.hybridDB.getOptions?.() ?? DEFAULT_CODEC_OPTIONS; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(): Generator { throw new Error("Not supported"); } @@ -309,26 +311,29 @@ class HybridDBReadonlyTx implements HyperDBTx { if (this.state.primaryTx) return this.state.primaryTx; const { primary } = this.hybridDB; - const tx = primary.beginReadonlyTransactionForSelectors - ? yield* primary.beginReadonlyTransactionForSelectors() + const tx = primary.canUseReadonlyTransactionsForSelectors() + ? yield* primary.beginTx("readonly") : undefined; this.state.primaryTx = tx; return tx ?? primary; }.bind(this); - return yield* withHybridLock(state, function* () { - return yield* hybridIntervalScan( - getPrimaryForRead, - cache, - state.cachedIntervals, - selectEvent, - table, - indexName, - clauses, - selectOptions, - ); - }.bind(this)); + return yield* withHybridLock( + state, + function* () { + return yield* hybridIntervalScan( + getPrimaryForRead, + cache, + state.cachedIntervals, + selectEvent, + table, + indexName, + clauses, + selectOptions, + ); + }.bind(this), + ); } *insert( @@ -427,6 +432,10 @@ class HybridDBTx implements HyperDBTx { return this.hybridDB.getOptions?.() ?? DEFAULT_CODEC_OPTIONS; } + canUseReadonlyTransactionsForSelectors(): boolean { + return false; + } + *loadTables(): Generator { throw new Error("Not supported"); } diff --git a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts index 537c188..5031676 100644 --- a/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts +++ b/packages/hyperdb/src/hyperdb/runtime/subscribable-db.ts @@ -148,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"); } @@ -159,7 +163,9 @@ export class SubscribableDBTx implements HyperDBTx { ]); } - *beginTx(_mode: DBTransactionMode = "readwrite"): Generator { + *beginTx( + _mode: DBTransactionMode = "readwrite", + ): Generator { this.state.txCounter.val++; return this; @@ -511,6 +517,10 @@ export class SubscribableDB implements HyperDB { return new SubscribableDBTx(this, yield* this.delegateDB().beginTx(mode)); } + canUseReadonlyTransactionsForSelectors(): boolean { + return this.delegateDB().canUseReadonlyTransactionsForSelectors(); + } + withTraits(...traits: Trait[]): HyperDB { const db = new SubscribableDB(this.db); db.state = this.state; @@ -518,18 +528,6 @@ export class SubscribableDB implements HyperDB { return db; } - *beginReadonlyTransactionForSelectors(): Generator< - DBCmd, - HyperDBTx | undefined - > { - const delegate = this.delegateDB(); - const tx = delegate.beginReadonlyTransactionForSelectors - ? yield* delegate.beginReadonlyTransactionForSelectors() - : undefined; - - return tx ? new SubscribableDBTx(this, tx) : undefined; - } - getTraits(): Trait[] { return [...this.traits, ...this.db.getTraits()]; } 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); From 2da1aa0c5ae4beb7c50f6c8c5df13c5d918e9051 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 26 Jun 2026 14:09:47 +0300 Subject: [PATCH 5/5] fix: improve after review --- .../docs/database/selectors-reactivity.md | 2 - .../src/content/docs/integrations/react.md | 14 ++- packages/hyperdb-doc/summary.md | 4 +- packages/hyperdb/src/react/hooks.test.ts | 39 ++++++-- packages/hyperdb/src/react/hooks.ts | 91 +++++++++++-------- 5 files changed, 89 insertions(+), 61 deletions(-) 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 d6f8621..9dc4d54 100644 --- a/packages/hyperdb-doc/src/content/docs/integrations/react.md +++ b/packages/hyperdb-doc/src/content/docs/integrations/react.md @@ -66,13 +66,12 @@ 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` @@ -112,7 +111,6 @@ Options: | `initialData` | Initial successful data for the result | | `initialDataUpdatedAt` | Timestamp for `initialData` | | `placeholderData` | Temporary data while the selector is still pending | -| `staleTime` | Time in ms before successful data is considered stale, or `"static"` | | `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 | diff --git a/packages/hyperdb-doc/summary.md b/packages/hyperdb-doc/summary.md index ea90143..bdac24d 100644 --- a/packages/hyperdb-doc/summary.md +++ b/packages/hyperdb-doc/summary.md @@ -103,8 +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`, the React Query-style async selector result, 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/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 9e3d3e0..f77d2be 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -126,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", () => { @@ -278,6 +272,33 @@ describe("useAsyncSelector", () => { 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 }) { diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index 6548d6f..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,7 +38,6 @@ type SyncSelectorMaybeDisabledOptions = { args: SelectorArgs; enabled: boolean; defaultValue: SelectorReturn; - gcTime?: number; }; export type AsyncSelectorStatus = "pending" | "error" | "success"; @@ -104,7 +102,6 @@ type AsyncSelectorBaseOptions< previousValue: SelectorReturn | undefined, previousQuery: undefined, ) => SelectorReturn); - staleTime?: number | "static"; subscribed?: boolean; throwOnError?: | boolean @@ -265,8 +262,40 @@ const createAsyncSelectorState = ( }; }; -const getStaleTime = (staleTime: number | "static" | undefined) => - staleTime ?? 0; +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, @@ -322,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, @@ -409,34 +436,10 @@ export function useAsyncSelector< [], ); - const isStale = (() => { - if (queryState.status !== "success") return true; - - const staleTime = getStaleTime(input.staleTime); - if (staleTime === "static" || staleTime === Infinity) return false; - - return Date.now() - queryState.dataUpdatedAt > staleTime; - })(); - const isPending = queryState.status === "pending"; - const isError = queryState.status === "error"; - const isFetching = queryState.fetchStatus === "fetching"; - const isPaused = queryState.fetchStatus === "paused"; - const result: UseAsyncSelectorResult, TError> = { - ...queryState, - isEnabled: 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 result = createUseAsyncSelectorResult(queryState, { + enabled, refetch, - }; + }); resultRef.current = result; hookDeps.useEffect(() => { @@ -487,7 +490,7 @@ export function useAsyncSelector< if (cancelledRef.current) return; selectRangeCmdsRef.current = cmds; - setQueryState((previous) => ({ + const nextState = setQueryState((previous) => ({ ...previous, data: value, dataUpdatedAt: Date.now(), @@ -500,6 +503,10 @@ export function useAsyncSelector< isPlaceholderData: false, status: "success", })); + resultRef.current = createUseAsyncSelectorResult(nextState, { + enabled, + refetch, + }); promiseController.resolve(value); isRunningRef.current = false; inFlightResultRef.current = null; @@ -513,7 +520,7 @@ export function useAsyncSelector< if (cancelledRef.current) return; const typedError = error as TError; - setQueryState((previous) => ({ + const nextState = setQueryState((previous) => ({ ...previous, error: typedError, errorUpdatedAt: Date.now(), @@ -526,6 +533,10 @@ export function useAsyncSelector< isPlaceholderData: false, status: "error", })); + resultRef.current = createUseAsyncSelectorResult(nextState, { + enabled, + refetch, + }); promiseController.reject(error); isRunningRef.current = false; inFlightResultRef.current = null; @@ -633,9 +644,9 @@ export function useAsyncSelector< isRunningRef.current = false; unsubscribe(); }; - }, [db, input.selector, argsKey, canFetch]); + }, [db, input.selector, argsKey, canFetch, enabled, refetch]); - if (isError && input.throwOnError) { + if (result.isError && input.throwOnError) { const shouldThrow = typeof input.throwOnError === "function" ? input.throwOnError(queryState.error as TError, result)