blob: 5630602f068c0074ae909bbbf4243df8fb9242af [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* QuerySlot: Declarative async data fetching for synchronous render cycles.
*
* ## Why it exists
*
* UI components (Mithril views, canvas tracks) render synchronously but need
* async data from SQL queries. QuerySlot bridges this gap:
* - Call `use()` every render cycle with the current parameters
* - Get back whatever data is available (cached, stale, or undefined)
* - New queries are scheduled automatically when parameters change
* - Serial execution prevents race conditions with shared resources (temp tables)
*
* ## Usage
*
* ```typescript
* class MyPanel implements m.ClassComponent<Attrs> {
* private readonly taskQueue = new SerialTaskQueue();
* private readonly dataSlot = new QuerySlot<MyData>(this.taskQueue);
*
* view({attrs}: m.CVnode<Attrs>) {
* const result = this.dataSlot.use({
* key: {filters: attrs.filters, pagination: this.pagination},
* queryFn: () => fetchData(attrs.filters, this.pagination),
* retainOn: ['pagination'], // Show stale data during pagination changes
* });
*
* return m('div', result.data ? renderData(result.data) : 'Loading...');
* }
*
* onremove() {
* // Cancel pending queries and cleanup resources
* this.dataSlot.dispose();
* }
* }
* ```
*
* ## Key concepts
*
* - **key**: Object identifying the query. Changes trigger re-fetch.
* - **retainOn**: Key fields that allow showing previous data while fetching.
* E.g., `retainOn: ['pagination']` shows old data during scroll for smoothness,
* but `filters` changing would show loading state.
* - **enabled**: Truthy value required before query runs. Use for dependencies
* like `enabled: tableResult.data` to wait for a temp table to be created.
*
* ## Behavior
*
* - Tasks within a queue run serially (no interleaving)
* - Only the latest pending task per slot is kept (intermediates dropped)
* - Each slot has a single-entry cache (most recent result)
* - If queryFn returns an AsyncDisposable, it is automatically disposed before
* running the next query and when the slot is disposed. Disposal runs through
* the queue to stay synchronized with in-flight queries.
*/
import m from 'mithril';
import {stringifyJsonWithBigints} from './json_utils';
/**
* Signal passed to queryFn to check if the query has been cancelled.
* Check this periodically during long-running operations to bail out early.
*/
export interface CancellationSignal {
readonly isCancelled: boolean;
}
/**
* Special return value from queryFn indicating the query was cancelled.
* When returned, the result is not cached.
*/
export const QUERY_CANCELLED = Symbol('QUERY_CANCELLED');
function isAsyncDisposable(value: unknown): value is AsyncDisposable {
return (
value !== null &&
typeof value === 'object' &&
Symbol.asyncDispose in value &&
typeof (value as AsyncDisposable)[Symbol.asyncDispose] === 'function'
);
}
// Simple alias for a function that returns a Promise of type T.
type AsyncFunc<T> = () => Promise<T>;
// TODO(stevegolton): Eventually these types should be moved to json_utils.ts
// and used in the definitions for stringifyJsonWithBigints and friends, but
// there are too many dependencies that depend on these functions using 'any'
// right now. They should instead use zod to narrow the types properly.
type JSONPrimitive = string | number | boolean | null | undefined | bigint;
type JSONObject = {
[key: string]: JSONValue;
};
type JSONValue = JSONPrimitive | JSONValue[] | JSONObject;
type JSONCompatible<T> = unknown extends T
? never
: {
[P in keyof T]: T[P] extends JSONValue
? T[P]
: T[P] extends NotAssignableToJson
? never
: JSONCompatible<T[P]>;
};
type NotAssignableToJson = symbol | Function;
/**
* Runs async tasks one at a time with cancellation support.
*
* Tasks are keyed by an object reference. If a new task is scheduled with
* the same key, it replaces any pending task for that key ("latest wins").
* Tasks that have already started running cannot be cancelled.
*/
export class SerialTaskQueue {
private pending = new Map<object, AsyncFunc<void>>();
private running = false;
/**
* Schedule a task. If a task with this key is already pending,
* it gets replaced.
*/
schedule(key: object, task: AsyncFunc<void>): void {
this.pending.set(key, task);
this.runNext();
}
/**
* Cancel any pending task for this key.
* Has no effect if the task is already running.
*/
cancel(key: object): void {
this.pending.delete(key);
}
private async runNext(): Promise<void> {
if (this.running) return;
const first = this.pending.entries().next();
if (first.done) return;
const [key, task] = first.value;
this.pending.delete(key);
this.running = true;
try {
await task();
} catch (e) {
console.error('Task failed:', e);
} finally {
this.running = false;
this.runNext();
}
}
}
export interface QueryOptions<T, K extends JSONCompatible<K>> {
key: K;
// Query function receives a cancellation signal. Check signal.isCancelled
// periodically during long operations and return QUERY_CANCELLED to bail out.
queryFn: (signal: CancellationSignal) => Promise<T | typeof QUERY_CANCELLED>;
// If provided, query only runs when this is truthy
// e.g., enabled: viewResult.data
enabled?: boolean;
retainOn?: (keyof K)[];
}
export interface QueryResult<T> {
data: T | undefined;
isPending: boolean;
isFresh: boolean;
}
/**
* A single query slot with a single-entry cache.
*
* Created once per query on a component. Multiple slots share a
* SerialTaskQueue for serialized execution.
*
* If T is an AsyncDisposable, the slot will automatically dispose of
* previous results before running a new query and when the slot is disposed.
*/
export class QuerySlot<T> {
private cache?: {key: object; keyStr: string; data: T};
private pendingKey?: object;
private disposed = false;
private currentSignal?: {cancelled: boolean};
// Stores error keyed by keyStr - thrown on next use() with same key
private error?: {keyStr: string; error: Error};
constructor(
private readonly queue: SerialTaskQueue = new SerialTaskQueue(),
) {}
/**
* Call every render cycle to get the current query result.
* @throws Error if called after dispose()
*/
use<K extends JSONCompatible<K>>(
options: QueryOptions<T, K>,
): QueryResult<T> {
if (this.disposed) {
throw new Error('QuerySlot.use() called after dispose()');
}
const {key, queryFn, enabled, retainOn = []} = options;
const keyStr = stringifyJsonWithBigints(key);
// If we have a stored error for this key, throw it
if (this.error?.keyStr === keyStr) {
throw this.error.error;
}
// Check if we need to schedule a new query
const pendingKeyStr = this.pendingKey
? stringifyJsonWithBigints(this.pendingKey)
: undefined;
const cachedKeyStr = this.cache?.keyStr;
const isKeyDifferentFromPending = pendingKeyStr !== keyStr;
const isKeyDifferentFromCache = cachedKeyStr !== keyStr;
// enabled: undefined means no dependencies (satisfied)
// enabled: <value> means satisfied only if truthy
const canRun = enabled === undefined || enabled;
if (isKeyDifferentFromPending && isKeyDifferentFromCache && canRun) {
// Cancel any in-flight query
if (this.currentSignal) {
this.currentSignal.cancelled = true;
}
// Create new signal for this query
const signal = {cancelled: false};
this.currentSignal = signal;
this.pendingKey = key;
this.queue.schedule(this, async () => {
try {
// Dispose of previous result before running new query
await this.disposeCache();
const result = await queryFn({
get isCancelled() {
return signal.cancelled;
},
});
this.finaliseQuery(key, result);
} catch (e) {
// Support both throwing and returning QUERY_CANCELLED
if (e === QUERY_CANCELLED) {
this.finaliseQuery(key, QUERY_CANCELLED);
} else {
this.finaliseError(key, e);
}
} finally {
m.redraw();
}
});
}
// Determine what to return
const isPending = this.pendingKey !== undefined;
if (!this.cache) {
return {data: undefined, isPending, isFresh: false};
}
// Check if we can use stale data
const isFresh = this.cache.keyStr === keyStr;
if (isFresh) {
return {data: this.cache.data, isPending, isFresh: true};
}
// Key differs - can we use stale data?
const canUseStale = canUseStaleData(
this.cache.key,
key,
retainOn as string[],
);
if (canUseStale) {
return {data: this.cache.data, isPending, isFresh: false};
}
// Can't use stale data
return {data: undefined, isPending, isFresh: false};
}
/**
* Called when a query completes. Clears the pending state and optionally
* caches the result (if not cancelled).
*/
private finaliseQuery(key: object, result: T | typeof QUERY_CANCELLED): void {
const keyStr = stringifyJsonWithBigints(key);
// Clear pending if it matches
if (
this.pendingKey &&
stringifyJsonWithBigints(this.pendingKey) === keyStr
) {
this.pendingKey = undefined;
}
// Cache the result (unless cancelled)
if (result !== QUERY_CANCELLED) {
this.cache = {key, keyStr, data: result};
}
}
/**
* Called when a query fails with an error. Stores the error keyed by the
* query key - next use() with the same key will throw this error.
*/
private finaliseError(key: object, e: unknown): void {
const keyStr = stringifyJsonWithBigints(key);
this.error = {
keyStr,
error: e instanceof Error ? e : new Error(String(e)),
};
// Clear pending if it matches (don't clear if a different query was scheduled)
if (
this.pendingKey &&
stringifyJsonWithBigints(this.pendingKey) === keyStr
) {
this.pendingKey = undefined;
}
}
private async disposeCache(): Promise<void> {
if (this.cache && isAsyncDisposable(this.cache.data)) {
await this.cache.data[Symbol.asyncDispose]();
// Only clear cache for AsyncDisposable - the resource is now invalid
// For non-AsyncDisposable data, keep the cache until setCache replaces it
this.cache = undefined;
}
}
/**
* Clear the cached result and any stored error, forcing the next use()
* with any key to re-run its queryFn. Unlike dispose(), the slot remains
* usable. Cancels any in-flight query and disposes cached AsyncDisposable
* data through the queue to stay synchronized with pending work.
*/
invalidate(): void {
if (this.disposed) return;
// Cancel any in-flight query and clear pending state so the next use()
// reschedules from scratch.
if (this.currentSignal) {
this.currentSignal.cancelled = true;
this.currentSignal = undefined;
}
this.pendingKey = undefined;
this.error = undefined;
// Schedule cache disposal/clearing through the queue so it runs after any
// in-flight query settles.
this.queue.schedule(this, async () => {
await this.disposeCache();
this.cache = undefined;
});
}
/**
* Dispose this slot. Cancels pending work and disposes any cached
* AsyncDisposable through the queue to maintain synchronization.
* Call this in component's onremove() to prevent orphaned queries.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.queue.cancel(this);
this.pendingKey = undefined;
// Schedule cache disposal through the queue. This runs after any
// in-flight query completes, ensuring we dispose whatever ends up
// in the cache (either existing data or newly fetched data).
this.queue.schedule(this, async () => {
await this.disposeCache();
});
}
}
/**
* Check if stale data can be used by comparing cached and current keys.
*
* Returns true if only fields listed in retainOn differ.
* Returns false if any non-retainOn field differs.
*/
function canUseStaleData(
cachedKey: object,
currentKey: object,
retainOn: string[],
): boolean {
const cached = cachedKey as Record<string, unknown>;
const current = currentKey as Record<string, unknown>;
// Check all fields in current key
for (const field of Object.keys(current)) {
const cachedStr = stringifyJsonWithBigints(cached[field]);
const currentStr = stringifyJsonWithBigints(current[field]);
if (cachedStr !== currentStr) {
// Field differs - is it in retainOn?
if (!retainOn.includes(field)) {
return false; // Non-retainOn field changed → need fresh data
}
}
}
// Also check for fields in cached that aren't in current (removed fields)
for (const field of Object.keys(cached)) {
if (!(field in current)) {
if (!retainOn.includes(field)) {
return false;
}
}
}
return true; // Only retainOn fields changed → stale OK
}