diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bf5e05..dedddd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- feat(data): add lifecycle-owned dynamic keyed query collections with bounded + initial loading, aggregate state, per-key retry, and shared query caching. - fix(runtime): reject recursive `derive()` and `selector()` reads before a memoized value can bypass the self-evaluation guard. - fix(runtime): route scheduled descendant and portal materialization failures diff --git a/capabilities.json b/capabilities.json index 47b59d80..c8462881 100644 --- a/capabilities.json +++ b/capabilities.json @@ -16,10 +16,17 @@ "intent": "data queries and mutations", "package": "@askrjs/askr", "import": "@askrjs/askr/data", - "exports": ["createQuery", "defineQuery", "queryScope", "createMutation"], + "exports": [ + "createQuery", + "createQueryCollection", + "defineQuery", + "queryScope", + "createMutation" + ], "constraints": [ "query keys identify shared cache entries", - "fetches are cancellable" + "fetches are cancellable", + "query collections require component render scope" ], "stability": "stable", "docs": "https://github.com/askrjs/askr/blob/main/docs/core/data.md", diff --git a/docs/core/data.md b/docs/core/data.md index 1a247c84..a3f697a2 100644 --- a/docs/core/data.md +++ b/docs/core/data.md @@ -190,6 +190,72 @@ surface as stale-with-value so apps can keep rendering the last committed data. `null` or `undefined` from `fetch()`. Nullish thrown values are normalized before they reach `error`, so any surfaced query error is always non-null. +### Dynamic query collections + +Use `createQueryCollection()` when one component owns a changing set of inputs +for one `QueryDefinition`. The collection uses the same `DataRuntime` cache and +query cells as `createQuery()`, while bounding the first loads and collection +retries that it starts: + +```tsx +import { state } from '@askrjs/askr'; +import { createQueryCollection, defineQuery } from '@askrjs/askr/data'; + +const schemaByDatabase = defineQuery({ + key: ({ database }: { database: string }) => `schemas:${database}`, + fetch: async ({ database, signal }) => { + const response = await fetch(`/api/databases/${database}/schema`, { + signal, + }); + return (await response.json()) as { tables: readonly string[] }; + }, +}); + +function SchemaBrowser() { + const databases = state(['postgres', 'analytics', 'warehouse']); + const catalogs = createQueryCollection({ + query: schemaByDatabase, + inputs: () => databases().map((database) => ({ database })), + key: ({ database }) => database, + concurrency: 3, + }); + + return ( + + ); +} +``` + +`entries` preserves the input order and exposes each underlying `query`. +`results` and `errors` are keyed maps containing settled data and per-key +errors. `loading` is true while any entry is loading or refreshing, and +`settled` is its inverse. Use `retry(key)` to retry one entry through the +collection's concurrency queue. + +Collection identity and lifecycle are deterministic: + +- The first input for a duplicate collection key wins. Reordering a key keeps + its query reader; changing the query key for that collection key replaces it. +- Growth starts only uncached entries. Shrinkage detaches removed readers and + aborts their work when the collection held the last cache reader. +- Component unmount detaches every reader, cancels queued starts, and aborts + in-flight work that no other query reader owns. +- Query-definition keys still own cache identity, request deduplication, + freshness, and prefix invalidation. Two collection keys that resolve to the + same query key share one query cell. +- `concurrency` defaults to 4 and must be a positive integer. It bounds initial + collection loads and `retry()` calls. Direct `entry.query.refresh()` and + global `invalidate()` retain their existing immediate query semantics. +- During SSR and SSG rendering, the collection reads hydrated query data but + does not start client fetches. Prefetch the definition's inputs into the + request-owned runtime before rendering. + ### Query UI cookbook Use the explicit query fields directly in app UI: diff --git a/docs/guides/platform-recipes.md b/docs/guides/platform-recipes.md index 27bf33fe..9384a2f8 100644 --- a/docs/guides/platform-recipes.md +++ b/docs/guides/platform-recipes.md @@ -15,6 +15,7 @@ subpaths independently. | Persistent routed shell | `@askrjs/askr/router` | | Browser-safe search | `@askrjs/askr`, `/control`, `/resources`, `/router` | | Query hydration | `@askrjs/askr/data` | +| Dynamic schema browser | `@askrjs/askr`, `/data` | | Error boundary placement | `@askrjs/askr`, `/components`, `/router` | | Consumer behavior testing | `@askrjs/askr/testing` and the application's configured runner | @@ -29,6 +30,7 @@ instead of copying their recipes. | Active navigation in a persistent layout | [Persistent routed shell](#persistent-routed-shell) | Yes | Yes | Yes | | Browser listeners and controlled search | [SSR-safe route-driven search](#ssr-safe-route-driven-search) | Yes | Yes | Yes | | Loading, failure, invalidation, hydration | [Hydrated query data](#hydrated-query-data) | Yes | Yes | Yes | +| Dynamic keyed data with bounded loading | [Dynamic schema browser](#dynamic-schema-browser) | Yes | Data | Data | | Local and route-level recovery | [Error boundary placement](#error-boundary-placement) | Yes | Local | Local | | Public component and router tests | [Test the recipes](#test-the-recipes) | Yes | N/A | N/A | @@ -186,6 +188,50 @@ Failure and empty states: is stale. - Model a valid empty result as an object or array, not `null` or `undefined`. +## Dynamic schema browser + +Turn a reactive database list into one lifecycle-owned collection instead of +calling a changing number of hooks from a loop. The collection shares normal +query cache entries, starts at most three collection-owned requests at once, +and keeps per-database retry available through `retry(key)`. + +The complete component is +[dynamic-schema-browser.tsx](../../examples/platform-recipes/dynamic-schema-browser.tsx). + +```tsx +import { createQueryCollection, type QueryDefinition } from '@askrjs/askr/data'; + +type DatabaseInput = { database: string }; +declare const databases: () => readonly string[]; +declare const schemaByDatabase: QueryDefinition< + DatabaseInput, + { tables: readonly string[] } +>; + +const catalogs = createQueryCollection({ + query: schemaByDatabase, + inputs: () => databases().map((database) => ({ database })), + key: ({ database }: DatabaseInput) => database, + concurrency: 3, +}); +``` + +Lifecycle and cleanup: + +- Reordering preserves keyed readers and does not refetch fresh cache entries. +- Removed keys and component unmount detach readers; last-reader removal aborts + active work and queued collection starts never run. +- Duplicate collection keys use the first input in the current input order. + +Failure and empty states: + +- `errors` maps collection keys to their per-query errors; the entry still + exposes its complete query state. +- `results` contains successful values only. An empty input array is already + settled and produces empty `entries`, `results`, and `errors`. +- `loading` covers initial and refresh work; use `settled` for aggregate search + progress and `retry(key)` for a bounded per-database retry. + ## Error boundary placement Place a local boundary around an optional or independently recoverable widget. diff --git a/docs/reference/api.md b/docs/reference/api.md index c220c241..26b3f431 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -50,7 +50,7 @@ Public types: for the `selector()` and thunk-prop patterns. A row callback is reconciled by `` and should not rely on plain closure captures of changing parent state. -- `@askrjs/askr/data` - `createDataRuntime`, `getDefaultDataRuntime`, `createQuery`, `createMutation`, `invalidate`, and `invalidateOnInterval` +- `@askrjs/askr/data` - `createDataRuntime`, `getDefaultDataRuntime`, `createQuery`, `createQueryCollection`, `createMutation`, `invalidate`, and `invalidateOnInterval` - `@askrjs/askr/testing` - component harness helpers such as `render`, `mount`, `renderRoute`, `dispatch`, `flush`, and `cleanup`, plus query and router fixtures - `@askrjs/askr/resources` - async resource helpers such as `resource`, `stream`, `on`, `timer`, `task`, `capture`, `getSignal`, `routeActive`, `documentVisible`, and `windowFocused` - `@askrjs/askr/router` - typed `RouteRef` declarations and destinations, metadata, critical `routeData`, and deferred `Resolve` boundaries @@ -112,6 +112,7 @@ await createSPA({ root: document.body, registry }); - Router page components, `lazy()` route components, and router layout functions also return normal renderable content rather than imperative DOM `Node` values. - `lazy()` preserves its import factory until the route is matched. Call the returned component's `preload()` method when an interaction or application policy should fetch that route earlier. - `createQuery()` exposes `consistency` plus `staleReason` so settled stale states can be narrowed into `inconsistent`, `aborted`, or `error` without guessing from broad booleans alone. +- `createQueryCollection()` owns a dynamic keyed set of one query definition, bounds collection-started loads and retries, and exposes aggregate results and per-key errors without introducing another cache. - `createDataRuntime()` creates isolated query and mutation state for tests, embedded apps, and multi-root shells; pass it through data operation options with `runtime`. - `resource()` is available from `@askrjs/askr/resources`. - `renderToString()`, `renderToStream()`, `resolveRequest()`, and `createStaticGen()` accept route registries captured with `createRouteRegistry()`. diff --git a/examples/platform-recipes/dynamic-schema-browser.tsx b/examples/platform-recipes/dynamic-schema-browser.tsx new file mode 100644 index 00000000..f7cc96af --- /dev/null +++ b/examples/platform-recipes/dynamic-schema-browser.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource @askrjs/askr */ + +import { state } from '@askrjs/askr'; +import { createQueryCollection, defineQuery } from '@askrjs/askr/data'; + +type DatabaseInput = { database: string }; +type DatabaseSchema = { + database: string; + tables: readonly string[]; +}; + +const schemaByDatabase = defineQuery({ + key: ({ database }: DatabaseInput) => `schemas:${database}`, + fetch: async ({ database, signal }) => { + const response = await fetch(`/api/databases/${database}/schema`, { + signal, + }); + if (!response.ok) throw new Error(`Could not load ${database}`); + return (await response.json()) as DatabaseSchema; + }, +}); + +export function DynamicSchemaBrowser({ + initialDatabases, +}: { + initialDatabases: readonly string[]; +}) { + const databases = state(initialDatabases); + const catalogs = createQueryCollection({ + query: schemaByDatabase, + inputs: () => databases().map((database) => ({ database })), + key: ({ database }) => database, + concurrency: 3, + }); + + return ( +
+

+ {catalogs.settled + ? `${catalogs.results.size} schemas ready` + : 'Loading schemas...'} +

+ +
+ ); +} diff --git a/src/data/index.ts b/src/data/index.ts index 979e67e2..14d9b895 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -6,6 +6,10 @@ export type { Mutation, MutationOptions, Query, + QueryCollection, + QueryCollectionEntry, + QueryCollectionKey, + QueryCollectionOptions, QueryConsistency, QueryKeyPart, QueryScope, @@ -19,6 +23,7 @@ export { createDataRuntime, getDefaultDataRuntime } from './data-runtime'; export { invalidate, invalidateOnInterval, queryScope } from './invalidation'; export { createMutation } from './mutation-cell'; export { createQuery } from './query-cell'; +export { createQueryCollection } from './query-collection'; export { defineQuery, serveQuery, diff --git a/src/data/query-cell.ts b/src/data/query-cell.ts index b3860476..fb90e466 100644 --- a/src/data/query-cell.ts +++ b/src/data/query-cell.ts @@ -224,6 +224,17 @@ export class QueryCell { return this.state.staleReason; } + /** @internal Whether a collection should schedule this cell's first load. */ + needsInitialStart(): boolean { + return ( + !this.destroyed && + this.state.data === null && + this.state.error === null && + !this.pendingRefresh && + !this.startQueued + ); + } + ensureStarted(): void { if ( this.destroyed || diff --git a/src/data/query-collection.ts b/src/data/query-collection.ts new file mode 100644 index 00000000..6177725c --- /dev/null +++ b/src/data/query-collection.ts @@ -0,0 +1,396 @@ +import { getActiveRenderContext } from '../common/render-context'; +import { claimHookIndex, getCurrentComponentInstance } from '../runtime'; +import { resolveDataRuntimeState, type DataRuntimeState } from './data-runtime'; +import { QueryCell } from './query-cell'; +import type { + Query, + QueryCollection, + QueryCollectionEntry, + QueryCollectionKey, + QueryCollectionOptions, + QueryDefinition, +} from './types'; + +const DEFAULT_QUERY_COLLECTION_CONCURRENCY = 4; + +type CollectionRecord< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey, +> = QueryCollectionEntry & { + input: TInput; + readonly queryKey: string; + readonly cell: QueryCell; + readonly owner: object; +}; + +type CollectionTask = { + readonly cell: QueryCell; + readonly promise: Promise; + readonly resolve: () => void; + state: 'queued' | 'active' | 'cancelled' | 'done'; +}; + +type QueryCollectionSlot = { + readonly runtimeState: DataRuntimeState; + readonly collection: QueryCollectionCell; +}; + +const collectionSlotsByGeneration = new WeakMap< + object, + Map +>(); + +/** @internal Validate and normalize a query collection's concurrency cap. */ +export function normalizeQueryCollectionConcurrency( + concurrency: number | undefined +): number { + const value = concurrency ?? DEFAULT_QUERY_COLLECTION_CONCURRENCY; + if (!Number.isInteger(value) || value < 1) { + throw new Error( + '[Askr] createQueryCollection() concurrency must be a positive integer.' + ); + } + return value; +} + +function getCollectionStore( + generation: object +): Map { + let store = collectionSlotsByGeneration.get(generation); + if (!store) { + store = new Map(); + collectionSlotsByGeneration.set(generation, store); + } + return store; +} + +function createCellOptions( + runtimeState: DataRuntimeState, + query: QueryDefinition, + input: TInput, + queryKey: string +) { + return { + key: queryKey, + definitionIdentity: query, + fetch: ({ signal }: { signal: AbortSignal }) => + query.fetch({ ...input, signal }), + isConsistent: query.isConsistent, + reconcile: query.reconcile, + initialData: runtimeState.queryData.get(queryKey) as TResult | undefined, + skipInitialFetch: true, + }; +} + +class QueryCollectionCell< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey, +> implements QueryCollection { + private records = new Map>(); + private ordered: readonly CollectionRecord[] = []; + private readonly tasks = new Map< + QueryCell, + CollectionTask + >(); + private readonly queue: CollectionTask[] = []; + private activeCount = 0; + private concurrency = DEFAULT_QUERY_COLLECTION_CONCURRENCY; + private disposed = false; + + constructor(private readonly runtimeState: DataRuntimeState) {} + + get entries(): readonly QueryCollectionEntry[] { + return this.ordered; + } + + get loading(): boolean { + return this.ordered.some(({ query }) => query.loading || query.refreshing); + } + + get settled(): boolean { + return !this.loading; + } + + get results(): ReadonlyMap { + const results = new Map(); + for (const { key, query } of this.ordered) { + if (query.data !== null) { + results.set(key, query.data); + } + } + return results; + } + + get errors(): ReadonlyMap { + const errors = new Map(); + for (const { key, query } of this.ordered) { + if (query.error !== null) { + errors.set(key, query.error); + } + } + return errors; + } + + get(key: TKey): QueryCollectionEntry | undefined { + return this.records.get(key); + } + + retry(key: TKey): Promise { + const record = this.records.get(key); + return record ? this.schedule(record.cell) : Promise.resolve(); + } + + update( + query: QueryDefinition, + inputs: readonly TInput[], + keyForInput: (input: TInput) => TKey, + concurrency: number, + startInitialFetches: boolean + ): void { + if (this.disposed) return; + + this.concurrency = concurrency; + const desired: Array<{ input: TInput; key: TKey; queryKey: string }> = []; + const seenKeys = new Set(); + for (const input of inputs) { + const key = keyForInput(input); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + desired.push({ input, key, queryKey: query.key(input) }); + } + + const nextRecords = new Map< + TKey, + CollectionRecord + >(); + const nextOrdered: CollectionRecord[] = []; + const startCandidates = new Set>(); + const detachedCells = new Set>(); + + for (const { input, key, queryKey } of desired) { + const cellOptions = createCellOptions( + this.runtimeState, + query, + input, + queryKey + ); + const previous = this.records.get(key); + let record: CollectionRecord; + + if (previous?.queryKey === queryKey) { + previous.input = input; + previous.cell.warnOnConflictingDefinition(cellOptions); + record = previous; + } else { + if (previous) { + this.detach(previous); + detachedCells.add(previous.cell); + } + const cache = this.runtimeState.queryCache; + let cell = cache.get(queryKey) as QueryCell | undefined; + if (!cell) { + cell = new QueryCell(cellOptions, queryKey, cache); + cache.set(queryKey, cell as QueryCell); + } else { + cell.warnOnConflictingDefinition(cellOptions); + } + + const owner = {}; + cell.attach(owner, 0); + record = { + key, + input, + query: cell as unknown as Query, + queryKey, + cell, + owner, + }; + if (startInitialFetches && cell.needsInitialStart()) { + startCandidates.add(cell); + } + } + + nextRecords.set(key, record); + nextOrdered.push(record); + } + + for (const [key, record] of this.records) { + if (!nextRecords.has(key)) { + this.detach(record); + detachedCells.add(record.cell); + } + } + + this.records = nextRecords; + this.ordered = Object.freeze(nextOrdered); + for (const cell of detachedCells) this.cancelIfUnused(cell); + for (const cell of startCandidates) this.schedule(cell); + this.pump(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + + const cells = new Set>(); + for (const record of this.records.values()) { + cells.add(record.cell); + record.cell.detach(record.owner, 0); + } + this.records.clear(); + this.ordered = []; + for (const cell of cells) this.cancelIfUnused(cell); + } + + private detach(record: CollectionRecord): void { + record.cell.detach(record.owner, 0); + } + + private isCellUsed(cell: QueryCell): boolean { + for (const record of this.records.values()) { + if (record.cell === cell) return true; + } + return false; + } + + private cancelIfUnused(cell: QueryCell): void { + if (this.isCellUsed(cell)) return; + const task = this.tasks.get(cell); + if (!task || task.state === 'cancelled' || task.state === 'done') return; + + if (task.state === 'active') this.activeCount -= 1; + task.state = 'cancelled'; + this.tasks.delete(cell); + task.resolve(); + this.pump(); + } + + private schedule(cell: QueryCell): Promise { + if (this.disposed) return Promise.resolve(); + const existing = this.tasks.get(cell); + if (existing) return existing.promise; + + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + const task: CollectionTask = { + cell, + promise, + resolve, + state: 'queued', + }; + this.tasks.set(cell, task); + this.queue.push(task); + this.pump(); + return promise; + } + + private pump(): void { + if (this.disposed) return; + while (this.activeCount < this.concurrency) { + const task = this.queue.shift(); + if (!task) return; + if (task.state !== 'queued' || this.tasks.get(task.cell) !== task) { + continue; + } + + task.state = 'active'; + this.activeCount += 1; + void task.cell.refresh().finally(() => this.finish(task)); + } + } + + private finish(task: CollectionTask): void { + if (task.state !== 'active') return; + task.state = 'done'; + this.activeCount -= 1; + if (this.tasks.get(task.cell) === task) this.tasks.delete(task.cell); + task.resolve(); + this.pump(); + } +} + +/** + * Create one lifecycle-owned collection of dynamically keyed readers for a + * reusable query definition, with bounded collection-started fetches. + */ +export function createQueryCollection< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey = string, +>( + options: QueryCollectionOptions +): QueryCollection { + const instance = getCurrentComponentInstance(); + if (!instance) { + throw new Error( + '[Askr] createQueryCollection() must be called during component render execution.' + ); + } + + const hookIndex = claimHookIndex(instance, 'createQueryCollection'); + const concurrency = normalizeQueryCollectionConcurrency(options.concurrency); + const inputs = options.inputs(); + if (!Array.isArray(inputs)) { + throw new Error( + '[Askr] createQueryCollection() inputs must return a readonly array.' + ); + } + + const generation = instance._ownershipGeneration; + const runtimeState = resolveDataRuntimeState(options.runtime); + const store = getCollectionStore(generation); + let slot = store.get(hookIndex); + + if (slot && slot.runtimeState !== runtimeState) { + slot.collection.dispose(); + store.delete(hookIndex); + slot = undefined; + } + + if (!slot) { + const collection = new QueryCollectionCell(runtimeState); + slot = { + runtimeState, + collection: collection as QueryCollectionCell< + unknown, + {}, + QueryCollectionKey + >, + }; + store.set(hookIndex, slot); + (instance.cleanupFns ??= []).push(() => { + const current = store.get(hookIndex); + current?.collection.dispose(); + store.delete(hookIndex); + if ( + store.size === 0 && + collectionSlotsByGeneration.get(generation) === store + ) { + collectionSlotsByGeneration.delete(generation); + } + }); + } + + const context = getActiveRenderContext() as { mode?: 'ssr' | 'spa' } | null; + const startInitialFetches = !( + context?.mode === 'ssr' || + (context?.mode === undefined && typeof window === 'undefined') + ); + const collection = slot.collection as unknown as QueryCollectionCell< + TInput, + TResult, + TKey + >; + collection.update( + options.query, + inputs, + options.key, + concurrency, + startInitialFetches + ); + return collection; +} diff --git a/src/data/types.ts b/src/data/types.ts index b11b5042..b732640f 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -39,6 +39,48 @@ export interface QueryDefinition { ) => Promise | boolean; } +/** Stable identity for one member of a {@link QueryCollection}. */ +export type QueryCollectionKey = string | number | symbol; + +/** One keyed input and its underlying cache-backed query reader. */ +export interface QueryCollectionEntry< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey = string, +> { + readonly key: TKey; + readonly input: TInput; + readonly query: Query; +} + +/** Options for {@link createQueryCollection}. */ +export interface QueryCollectionOptions< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey = string, +> { + readonly query: QueryDefinition; + readonly inputs: () => readonly TInput[]; + readonly key: (input: TInput) => TKey; + readonly concurrency?: number; + readonly runtime?: DataRuntime; +} + +/** Aggregate reactive state for a lifecycle-owned dynamic query collection. */ +export interface QueryCollection< + TInput, + TResult extends {}, + TKey extends QueryCollectionKey = string, +> { + readonly entries: readonly QueryCollectionEntry[]; + readonly loading: boolean; + readonly settled: boolean; + readonly results: ReadonlyMap; + readonly errors: ReadonlyMap; + get(key: TKey): QueryCollectionEntry | undefined; + retry(key: TKey): Promise; +} + /** Context passed to server prefetch callbacks, exposing a scoped `prefetch` helper. */ export interface QueryPrefetchContext { readonly runtime: DataRuntime; diff --git a/src/index.ts b/src/index.ts index 18d0b4a9..78064da4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export { createRef } from './ref'; export type { Ref } from './ref'; export { createQuery, + createQueryCollection, defineQuery, serveQuery, defineServerQueries, @@ -56,6 +57,10 @@ export { } from './data'; export type { QueryDefinition, + QueryCollection, + QueryCollectionEntry, + QueryCollectionKey, + QueryCollectionOptions, QueryPrefetchContext, ServerQueryHandler, DataRuntime, diff --git a/test-utils/playwright-app/src/main.tsx b/test-utils/playwright-app/src/main.tsx index 4778035a..158d945a 100644 --- a/test-utils/playwright-app/src/main.tsx +++ b/test-utils/playwright-app/src/main.tsx @@ -9,6 +9,7 @@ import { } from '@askrjs/auth'; import { ErrorBoundary } from '@askrjs/askr/components'; import { For } from '@askrjs/askr/control'; +import { createQueryCollection, defineQuery } from '@askrjs/askr/data'; import { cleanupApp, createIsland, @@ -1408,6 +1409,59 @@ function mountDeepComponentNestingScenario(depth: number): void { }); } +function mountQueryCollectionScenario(): void { + resetRoot(); + + type DatabaseInput = { database: string }; + const schemaByDatabase = defineQuery({ + key: ({ database }: DatabaseInput) => `browser-schemas:${database}`, + fetch: async ({ database, signal }) => { + const response = await fetch(`/api/schemas/${database}`, { signal }); + if (!response.ok) throw new Error(`Schema request failed: ${database}`); + return (await response.json()) as { database: string; tables: number }; + }, + }); + + const App = () => { + const databases = state([ + { database: 'postgres' }, + { database: 'analytics' }, + { database: 'warehouse' }, + ]); + const catalogs = createQueryCollection({ + query: schemaByDatabase, + inputs: databases, + key: ({ database }) => database, + concurrency: 2, + }); + + return ( +
+

+ {catalogs.settled ? 'Settled' : 'Loading'} +

+ +
    + {catalogs.entries.map(({ key, query }) => ( +
  • + {key}:{query.data?.tables ?? 'loading'} +
  • + ))} +
+
+ ); + }; + + createIsland({ root, component: App }); +} + async function runBrowserPerf(): Promise> { const rows = Array.from({ length: 1000 }, (_, index) => ({ id: index + 1, @@ -1553,6 +1607,7 @@ Object.assign(window, { mountNavLinkForScenario, mountAdjacentForBoundariesScenario, mountDeepComponentNestingScenario, + mountQueryCollectionScenario, profileBenchmarkOperations, runBrowserBench, runBrowserBenchSuite, @@ -1586,6 +1641,7 @@ export { mountNavLinkForScenario, mountAdjacentForBoundariesScenario, mountDeepComponentNestingScenario, + mountQueryCollectionScenario, mountOrdersScenario, mountRoutedShellScenario, mountRouteDataDehydrationScenario, diff --git a/tests/browser/query-collection.test.ts b/tests/browser/query-collection.test.ts new file mode 100644 index 00000000..655b3dda --- /dev/null +++ b/tests/browser/query-collection.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/browser/context'; +import { loadBrowserHarness, mockJsonFetch } from './_helpers'; + +test('should load a dynamic schema collection with bounded browser work', async () => { + const started: string[] = []; + const releases = new Map void>(); + let active = 0; + let maxActive = 0; + + mockJsonFetch((request) => { + const database = new URL(request.url).pathname.split('/').at(-1)!; + started.push(database); + active += 1; + maxActive = Math.max(maxActive, active); + + return new Promise((resolve) => { + releases.set(database, () => { + active -= 1; + resolve( + new Response(JSON.stringify({ database, tables: database.length }), { + headers: { 'content-type': 'application/json' }, + }) + ); + }); + }); + }); + + const app = await loadBrowserHarness(); + app.mountQueryCollectionScenario(); + + await expect.poll(() => started).toEqual(['postgres', 'analytics']); + expect(maxActive).toBe(2); + + releases.get('postgres')?.(); + await expect + .poll(() => started) + .toEqual(['postgres', 'analytics', 'warehouse']); + expect(maxActive).toBe(2); + + releases.get('analytics')?.(); + releases.get('warehouse')?.(); + await expect + .element(page.getByTestId('collection-status')) + .toHaveTextContent('Settled'); + await expect.element(page.getByText('warehouse:9')).toBeVisible(); + + await page.getByRole('button', { name: 'Add archive database' }).click(); + await expect.poll(() => started.at(-1)).toBe('archive'); + releases.get('archive')?.(); + + await expect.element(page.getByText('archive:7')).toBeVisible(); + await expect + .element(page.getByTestId('collection-status')) + .toHaveTextContent('Settled'); +}); diff --git a/tests/checks/docs/platform-recipes.test.ts b/tests/checks/docs/platform-recipes.test.ts index dda58765..c0c12168 100644 --- a/tests/checks/docs/platform-recipes.test.ts +++ b/tests/checks/docs/platform-recipes.test.ts @@ -26,6 +26,7 @@ describe('verified platform recipe documentation', () => { 'Persistent routed shell', 'SSR-safe route-driven search', 'Hydrated query data', + 'Dynamic schema browser', 'Error boundary placement', 'Test the recipes', ]) { @@ -40,6 +41,9 @@ describe('verified platform recipe documentation', () => { expect(recipes).toMatch( /Loading, failure, invalidation, hydration[^\n]*\|\s*Yes\s*\|\s*Yes\s*\|\s*Yes\s*\|/ ); + expect(recipes).toMatch( + /Dynamic keyed data with bounded loading[^\n]*\|\s*Yes\s*\|\s*Data\s*\|\s*Data\s*\|/ + ); expect(recipes).toMatch( /Local and route-level recovery[^\n]*\|\s*Yes\s*\|\s*Local\s*\|\s*Local\s*\|/ ); @@ -52,6 +56,7 @@ describe('verified platform recipe documentation', () => { 'routed-shell.tsx', 'browser-search.tsx', 'data-hydration.tsx', + 'dynamic-schema-browser.tsx', 'error-boundaries.tsx', ]) { expect(recipes).toContain(`../../examples/platform-recipes/${fileName}`); diff --git a/tests/checks/public-api.snapshot.json b/tests/checks/public-api.snapshot.json index bf5de1b9..5544d821 100644 --- a/tests/checks/public-api.snapshot.json +++ b/tests/checks/public-api.snapshot.json @@ -13,6 +13,10 @@ "Match", "MatchProps", "Props", + "QueryCollection", + "QueryCollectionEntry", + "QueryCollectionKey", + "QueryCollectionOptions", "QueryDefinition", "QueryPrefetchContext", "Ref", @@ -29,6 +33,7 @@ "StateTuple", "configureRenderDiagnostics", "createQuery", + "createQueryCollection", "createRef", "createRuntime", "cspNonce", @@ -208,6 +213,10 @@ "Mutation", "MutationOptions", "Query", + "QueryCollection", + "QueryCollectionEntry", + "QueryCollectionKey", + "QueryCollectionOptions", "QueryConsistency", "QueryDefinition", "QueryKeyPart", @@ -220,6 +229,7 @@ "createDataRuntime", "createMutation", "createQuery", + "createQueryCollection", "createQueryPrefetchContext", "defineQuery", "defineServerQueries", diff --git a/tests/jsdom/operations/query-collection.test.tsx b/tests/jsdom/operations/query-collection.test.tsx new file mode 100644 index 00000000..6a258fbb --- /dev/null +++ b/tests/jsdom/operations/query-collection.test.tsx @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import type { JSXElement } from '../../../src/jsx/types'; +import { state } from '../../../src'; +import { + createDataRuntime, + createQuery, + createQueryCollection, + defineQuery, + invalidate, + type Query, + type QueryCollection, +} from '../../../src/data'; +import { createIsland } from '../../../test-utils/render/create-island'; +import { + createTestContainer, + flushScheduler, + waitForNextEvaluation, +} from '../../../test-utils/render/test-renderer'; + +async function settle(): Promise { + await waitForNextEvaluation(); + flushScheduler(); +} + +async function settleCollection(collection: { + readonly settled: boolean; +}): Promise { + for (let attempt = 0; attempt < 10 && !collection.settled; attempt += 1) { + await settle(); + } +} + +describe('query collections', () => { + it('should bound dynamic keyed work and aggregate query entries', async () => { + const runtime = createDataRuntime(); + const started: string[] = []; + const resolvers = new Map void>(); + let active = 0; + let maxActive = 0; + let collection!: QueryCollection< + { database: string }, + { database: string }, + string + >; + + const schemaByDatabase = defineQuery({ + key: ({ database }: { database: string }) => `schemas:${database}`, + fetch: ({ database, signal }) => { + started.push(database); + active += 1; + maxActive = Math.max(maxActive, active); + + return new Promise<{ database: string }>((resolve, reject) => { + const abort = () => { + active -= 1; + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', abort, { once: true }); + resolvers.set(database, (value) => { + signal.removeEventListener('abort', abort); + active -= 1; + resolve(value); + }); + }); + }, + }); + + const App = (): JSXElement => { + collection = createQueryCollection({ + runtime, + query: schemaByDatabase, + inputs: () => [ + { database: 'postgres' }, + { database: 'analytics' }, + { database: 'warehouse' }, + ], + key: ({ database }) => database, + concurrency: 2, + }); + + return ( +
{collection.entries.map((entry) => entry.key).join(',')}
+ ); + }; + + const { container, cleanup } = createTestContainer(); + try { + createIsland({ root: container, component: App }); + flushScheduler(); + await settle(); + + expect(container.textContent).toBe('postgres,analytics,warehouse'); + expect(started).toEqual(['postgres', 'analytics']); + expect(maxActive).toBe(2); + expect(collection.loading).toBe(true); + expect(collection.settled).toBe(false); + expect(collection.results.size).toBe(0); + + resolvers.get('postgres')?.({ database: 'postgres' }); + await settle(); + + expect(started).toEqual(['postgres', 'analytics', 'warehouse']); + expect(maxActive).toBe(2); + + resolvers.get('analytics')?.({ database: 'analytics' }); + resolvers.get('warehouse')?.({ database: 'warehouse' }); + await settle(); + + expect(collection.loading).toBe(false); + expect(collection.settled).toBe(true); + expect([...collection.results]).toEqual([ + ['postgres', { database: 'postgres' }], + ['analytics', { database: 'analytics' }], + ['warehouse', { database: 'warehouse' }], + ]); + expect(collection.errors.size).toBe(0); + expect(collection.get('analytics')?.query.data).toEqual({ + database: 'analytics', + }); + } finally { + cleanup(); + } + }); + + it('should preserve keyed entries across reorder, duplicates, invalidation, growth, and shrinkage', async () => { + const runtime = createDataRuntime(); + const fetchCounts = new Map(); + type DatabaseInput = { database: string }; + let setDatabases!: (value: readonly DatabaseInput[]) => void; + let collection!: QueryCollection< + DatabaseInput, + { database: string }, + string + >; + + const schemaByDatabase = defineQuery({ + key: ({ database }: DatabaseInput) => `schemas:${database}`, + fetch: async ({ signal, database }) => { + signal.throwIfAborted(); + fetchCounts.set(database, (fetchCounts.get(database) ?? 0) + 1); + return { database }; + }, + }); + + const App = (): JSXElement => { + const databases = state([ + { database: 'postgres' }, + { database: 'postgres' }, + { database: 'analytics' }, + ]); + setDatabases = databases.set; + collection = createQueryCollection({ + runtime, + query: schemaByDatabase, + inputs: databases, + key: ({ database }) => database, + concurrency: 2, + }); + + return
{collection.entries.map(({ key }) => key).join(',')}
; + }; + + const { container, cleanup } = createTestContainer(); + try { + createIsland({ root: container, component: App }); + flushScheduler(); + await settleCollection(collection); + + expect(container.textContent).toBe('postgres,analytics'); + expect(fetchCounts).toEqual( + new Map([ + ['postgres', 1], + ['analytics', 1], + ]) + ); + const postgresQuery = collection.get('postgres')?.query; + const analyticsQuery = collection.get('analytics')?.query; + + setDatabases([ + { database: 'analytics' }, + { database: 'postgres' }, + { database: 'analytics' }, + { database: 'warehouse' }, + ]); + flushScheduler(); + await settleCollection(collection); + + expect(container.textContent).toBe('analytics,postgres,warehouse'); + expect(collection.get('postgres')?.query).toBe(postgresQuery); + expect(collection.get('analytics')?.query).toBe(analyticsQuery); + expect(fetchCounts.get('warehouse')).toBe(1); + + invalidate('schemas:analytics', { runtime }); + await settleCollection(collection); + expect(fetchCounts.get('analytics')).toBe(2); + + setDatabases([{ database: 'warehouse' }, { database: 'analytics' }]); + flushScheduler(); + + expect(runtime.queryCache.has('schemas:postgres')).toBe(false); + expect([...collection.results.keys()]).toEqual([ + 'warehouse', + 'analytics', + ]); + } finally { + cleanup(); + } + }); + + it('should share cache entries and request deduplication with createQuery', async () => { + const runtime = createDataRuntime(); + const fetch = vi.fn( + async ({ id }: { id: string; signal: AbortSignal }) => ({ + id, + }) + ); + const userById = defineQuery({ + key: ({ id }: { id: string }) => `users:${id}`, + fetch, + }); + let singleQuery!: Query<{ id: string }>; + let collection!: QueryCollection<{ id: string }, { id: string }, string>; + + const App = (): JSXElement => { + singleQuery = createQuery(userById, { id: '123' }, { runtime }); + collection = createQueryCollection({ + runtime, + query: userById, + inputs: () => [{ id: '123' }], + key: ({ id }) => id, + }); + return
{collection.get('123')?.query.data?.id ?? 'loading'}
; + }; + + const { container, cleanup } = createTestContainer(); + try { + createIsland({ root: container, component: App }); + flushScheduler(); + await settleCollection(collection); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(collection.get('123')?.query).toBe(singleQuery); + expect(container.textContent).toBe('123'); + } finally { + cleanup(); + } + }); + + it('should surface per-key errors and retry through the collection queue', async () => { + type RetryInput = { id: string }; + const attempts = new Map(); + const query = defineQuery({ + key: ({ id }: RetryInput) => `retry:${id}`, + fetch: async ({ signal, id }) => { + signal.throwIfAborted(); + const attempt = (attempts.get(id) ?? 0) + 1; + attempts.set(id, attempt); + if (id === 'failed' && attempt === 1) throw new Error('try again'); + return { id, attempt }; + }, + }); + let collection!: QueryCollection< + RetryInput, + { id: string; attempt: number }, + string + >; + + const App = (): JSXElement => { + collection = createQueryCollection({ + query, + inputs: () => [{ id: 'failed' }, { id: 'healthy' }], + key: ({ id }) => id, + concurrency: 1, + }); + return
{collection.errors.size}
; + }; + + const { container, cleanup } = createTestContainer(); + try { + createIsland({ root: container, component: App }); + flushScheduler(); + await settleCollection(collection); + + expect(collection.errors.get('failed')).toBeInstanceOf(Error); + expect(collection.results.get('healthy')).toEqual({ + id: 'healthy', + attempt: 1, + }); + + const retry = collection.retry('failed'); + flushScheduler(); + await settleCollection(collection); + await retry; + + expect(collection.errors.has('failed')).toBe(false); + expect(collection.results.get('failed')).toEqual({ + id: 'failed', + attempt: 2, + }); + } finally { + cleanup(); + } + }); + + it('should abort active work and discard queued work as keys leave or the owner unmounts', async () => { + const started: string[] = []; + const aborted: string[] = []; + type LifecycleInput = { id: string }; + let setKeys!: (value: readonly LifecycleInput[]) => void; + let collection!: QueryCollection; + + const query = defineQuery({ + key: ({ id }: LifecycleInput) => `lifecycle:${id}`, + fetch: ({ signal, id }) => { + started.push(id); + return new Promise<{ id: string }>((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted.push(id); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true } + ); + }); + }, + }); + + const App = (): JSXElement => { + const keys = state([ + { id: 'first' }, + { id: 'second' }, + { id: 'third' }, + ]); + setKeys = keys.set; + collection = createQueryCollection({ + query, + inputs: keys, + key: ({ id }) => id, + concurrency: 1, + }); + return
{collection.entries.length}
; + }; + + const { container, cleanup } = createTestContainer(); + try { + createIsland({ root: container, component: App }); + flushScheduler(); + await settle(); + expect(started).toEqual(['first']); + + setKeys([{ id: 'second' }, { id: 'third' }]); + flushScheduler(); + await settle(); + + expect(aborted).toEqual(['first']); + expect(started).toEqual(['first', 'second']); + + cleanup(); + await Promise.resolve(); + + expect(aborted).toEqual(['first', 'second']); + expect(started).toEqual(['first', 'second']); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/types/data.test-d.ts b/tests/types/data.test-d.ts index 1f66c02c..3bc110ec 100644 --- a/tests/types/data.test-d.ts +++ b/tests/types/data.test-d.ts @@ -3,6 +3,7 @@ import { createDataRuntime, createMutation, createQuery, + createQueryCollection, createQueryPrefetchContext, defineServerQueries, defineQuery, @@ -23,6 +24,10 @@ import { type Mutation, type MutationOptions, type Query, + type QueryCollection, + type QueryCollectionEntry, + type QueryCollectionKey, + type QueryCollectionOptions, type QueryConsistency, type QueryDefinition, type QueryKeyPart, @@ -59,6 +64,34 @@ const userDefinition = defineQuery({ }); expectType>(userDefinition); +const userCollectionOptions: QueryCollectionOptions< + { id: string }, + { id: string }, + string +> = { + runtime: dataRuntime, + query: userDefinition, + inputs: () => [{ id: '123' }, { id: '456' }] as const, + key: ({ id }) => id, + concurrency: 2, +}; +const userCollection = createQueryCollection(userCollectionOptions); +expectType>( + userCollection +); +expectType< + readonly QueryCollectionEntry<{ id: string }, { id: string }, string>[] +>(userCollection.entries); +expectType(userCollection.loading); +expectType(userCollection.settled); +expectType>(userCollection.results); +expectType>(userCollection.errors); +expectType< + QueryCollectionEntry<{ id: string }, { id: string }, string> | undefined +>(userCollection.get('123')); +expectType>(userCollection.retry('123')); +expectAssignable('123'); + const userHandler: ServerQueryHandler<{ id: string }, { id: string }> = ({ input, signal, @@ -305,6 +338,21 @@ expectError(scoped.invalidate('buckets')); expectError(invalidateOnInterval('user:')); expectError(invalidateOnInterval('user:', { activeOn: '/' })); expectError(invalidateOnInterval('user:', { intervalMs: '1000' })); +expectError( + createQueryCollection({ + query: userDefinition, + inputs: () => [{ id: '123' }], + key: ({ id }) => ({ id }), + }) +); +expectError( + createQueryCollection({ + query: userDefinition, + inputs: () => [{ id: '123' }], + key: ({ id }) => id, + concurrency: '2', + }) +); expectError( createQuery({ key: 'bad', diff --git a/tests/unit/data/query-collection.test.ts b/tests/unit/data/query-collection.test.ts new file mode 100644 index 00000000..6fbb1c2e --- /dev/null +++ b/tests/unit/data/query-collection.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import { + createQueryCollection, + normalizeQueryCollectionConcurrency, +} from '../../../src/data/query-collection'; +import { createDataRuntime } from '../../../src/data/data-runtime'; +import { defineQuery } from '../../../src/data/query-registry'; +import { renderToStringSync } from '../../../src/ssr'; + +describe('query collection concurrency', () => { + it('should use a bounded default and accept positive integers', () => { + expect(normalizeQueryCollectionConcurrency(undefined)).toBe(4); + expect(normalizeQueryCollectionConcurrency(1)).toBe(1); + expect(normalizeQueryCollectionConcurrency(8)).toBe(8); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'should reject invalid concurrency %s', + (concurrency) => { + expect(() => normalizeQueryCollectionConcurrency(concurrency)).toThrow( + '[Askr] createQueryCollection() concurrency must be a positive integer.' + ); + } + ); + + it('should consume hydrated data without starting fetches during SSR', () => { + const runtime = createDataRuntime(); + runtime.queryData.set('ssr-schema:postgres', { database: 'postgres' }); + const fetch = vi.fn(async ({ database }: { database: string }) => ({ + database, + })); + const query = defineQuery({ + key: ({ database }: { database: string }) => `ssr-schema:${database}`, + fetch, + }); + + const html = renderToStringSync(() => { + const collection = createQueryCollection({ + runtime, + query, + inputs: () => [{ database: 'postgres' }], + key: ({ database }) => database, + }); + return collection.results.get('postgres')?.database ?? 'loading'; + }); + + expect(html).toContain('postgres'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/public-entrypoints-resolve.test.ts b/tests/unit/utils/public-entrypoints-resolve.test.ts index 228070ce..c5a9a173 100644 --- a/tests/unit/utils/public-entrypoints-resolve.test.ts +++ b/tests/unit/utils/public-entrypoints-resolve.test.ts @@ -40,6 +40,7 @@ describe('public entrypoint resolution', () => { expect(typeof control.Match).toBe('function'); expect(typeof data.createQuery).toBe('function'); + expect(typeof data.createQueryCollection).toBe('function'); expect(typeof data.createMutation).toBe('function'); expect(typeof data.invalidate).toBe('function');