Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions docs/core/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ul>
{catalogs.entries.map(({ key, query }) => (
<li key={key}>
{key}: {query.data?.tables.length ?? 'loading'}
</li>
))}
</ul>
);
}
```

`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:
Expand Down
46 changes: 46 additions & 0 deletions docs/guides/platform-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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 |

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Public types:
for the `selector()` and thunk-prop patterns. A row callback is reconciled by
`<For>` 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
Expand Down Expand Up @@ -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()`.
59 changes: 59 additions & 0 deletions examples/platform-recipes/dynamic-schema-browser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Database schemas">
<p role="status">
{catalogs.settled
? `${catalogs.results.size} schemas ready`
: 'Loading schemas...'}
</p>
<ul>
{catalogs.entries.map(({ key, query }) => (
<li key={key}>
<h2>{key}</h2>
{query.loading ? <p>Loading...</p> : null}
{query.error ? (
<button type="button" onClick={() => void catalogs.retry(key)}>
Retry {key}
</button>
) : null}
{query.data ? <p>{query.data.tables.length} tables</p> : null}
</li>
))}
</ul>
</section>
);
}
5 changes: 5 additions & 0 deletions src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export type {
Mutation,
MutationOptions,
Query,
QueryCollection,
QueryCollectionEntry,
QueryCollectionKey,
QueryCollectionOptions,
QueryConsistency,
QueryKeyPart,
QueryScope,
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/data/query-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@ export class QueryCell<T> {
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 ||
Expand Down
Loading