diff --git a/AGENTS.md b/AGENTS.md index 13f5b4f..90b1aad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,10 @@ -Figbird is a library that provides effortless realtime data management for React + Feathers applications. It's a data fetching library that caches the data and ingest realtime events to update the cache. +Figbird caches data and applies realtime events for React + Feathers applications. -When updating the code, use the following after updates: -* `npm run tsc` to type check -* `npm run lint` to lint (oxlint) -* `npm run ava` to run the tests -* `npm run test` to run the full test suite including all of the above +After code changes, run: -Since this is a library, we typically avoid using `any` at all. If the code produces the `Unexpected any` lint error, fix those by using better types. In some rare cases it does make sense to use any if that makes the public API of the library or a test implementation simpler - add an `oxlint-disable-next-line` comment in those cases. +- `npm run tsc` for types +- `npm run lint` for lint +- `npm run ava` for tests +- `npm run test` for the full suite -Update tests if needed to make them work with the updated code. -Bug fixes come with one focused regression test that fails without the fix. Otherwise don't add tests unless explicitly asked for that task - otherwise we'll accumulate too many noisy tests. +Prefer precise types over `any`. If `any` is necessary for a public API or test, use a focused `oxlint-disable-next-line` comment. diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index ff0c07d..bf3fcf7 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -33,7 +33,6 @@ import { } from './queryBuilder.js' import { resolveQueryInput, type PreparedQuery, type QueryInput } from './queryDefinition.js' import { - explainQueryNode, type ClassificationReason, type ExplainReport, type QueryNodeClass, @@ -1018,15 +1017,6 @@ export class Figbird< const stats = this.queryStore.getQueryStats(query.queryId) const generation = this.queryStore.getQueryGeneration(query.queryId) if (generation === undefined) continue - const explanation = - query.desc.method === 'find' - ? explainQueryNode(q, { - server: query.config.server, - allPages: 'allPages' in query.config && query.config.allPages === true, - localOperators: locallySupportedOperators(this.adapter, serviceName), - snapshot: queryPolicy(query.maintenance).responseMode === 'snapshot', - }) - : null rows.push({ queryId: query.queryId, generation, @@ -1043,7 +1033,7 @@ export class Figbird< } : {}), classification: query.maintenance.classification, - classificationReasons: explanation?.reasons ?? [], + classificationReasons: queryPolicy(query.maintenance).classificationReasons, realtimeStrategy: queryPolicy(query.maintenance).realtimeStrategy, skipped: query.config.skip === true, status: query.state.status, @@ -1117,7 +1107,7 @@ export interface InspectedQuery { /** Native adapter page details. Offset pages remain visible in `query` as `$skip`/`$limit`. */ page?: { request: PageRequest; info?: PageInfo } classification: QueryNodeClass | 'get' - classificationReasons?: ClassificationReason[] + classificationReasons?: readonly Readonly[] realtimeStrategy?: 'merge' | 'refetch' | 'manual' /** True when this entry was materialized with `skip: true`. */ skipped?: boolean diff --git a/lib/core/queryClassification.ts b/lib/core/queryClassification.ts index 5b4704f..cc47db4 100644 --- a/lib/core/queryClassification.ts +++ b/lib/core/queryClassification.ts @@ -65,17 +65,6 @@ export interface ClassifyOptions { localOperators?: ReadonlySet | undefined } -/** - * Classify a query node by how it can be maintained. `server: true` (the `.server()` - * escape hatch) forces server-authoritative; `allPages: true` neutralises window - * filters because the full result set is fetched, making membership locally provable; - * `localOperators` extends the locally-evaluable operator set with adapter-registered - * custom operators. - */ -export function classifyQueryNode(query: unknown, options: ClassifyOptions = {}): QueryNodeClass { - return explainQueryNode(query, options).class -} - /** Explain-only affordances layered on top of classification. */ export interface ExplainNodeOptions extends ClassifyOptions { /** Override the default `.server()` reason when another plan owns server authority. */ @@ -90,8 +79,7 @@ export interface ExplainNodeOptions extends ClassifyOptions { } /** - * Like `classifyQueryNode`, but says *why*: returns the classification together with - * the structured reasons that produced it. Powers `figbird.explain()`. + * Classify a query and retain the reasons for execution and diagnostics. */ export function explainQueryNode( query: unknown, @@ -175,15 +163,6 @@ function walkQueryKeysAtDepth( } } -/** True when the query projects rows (`$select` anywhere) — its results are partial rows, not full entities. */ -export function isProjectionQuery(query: unknown): boolean { - let found = false - walkQueryKeys(query, key => { - if (PROJECTION_QUERY_FILTERS.has(key)) found = true - }) - return found -} - /** * Why a root is server-authoritative by plan rather than by its operators: an * explicit `.server()`, and relational filters the client can't evaluate (see @@ -232,15 +211,6 @@ export function rootAllPages(kind: string): boolean { return kind === 'all' } -/** - * True when every predicate in the query is evaluable by the local matcher. - * Window filters are ignored — windowing doesn't affect whether a predicate can - * be evaluated, only whether membership is provable. - */ -export function isLocallyEvaluable(query: unknown, localOperators?: ReadonlySet): boolean { - return classifyQueryNode(query, { allPages: true, localOperators }) === 'local-exact' -} - /** * A stored query's classification: `desc`/`config` are frozen at materialize time, * so this is computed once and carried on the `Query` record. `'get'` marks get @@ -248,27 +218,6 @@ export function isLocallyEvaluable(query: unknown, localOperators?: ReadonlySet< */ export type StoredQueryClass = QueryNodeClass | 'get' -/** - * Classify a query at materialize time. Gets classify as 'get' — except when they - * carry conditions the client can't evaluate (`.get(id).where({ $regex })`): those - * are server-authoritative like any other non-local query, so realtime reconciles - * them by refetch and the merge path never tries (and fails) to build a local - * matcher for them. Gets deliberately ignore the `server` flag — a get is answered - * by id either way. Finds classify per `classifyQueryNode`. - */ -export function classifyStoredQuery( - method: 'get' | 'find', - query: Record | undefined, - { server, allPages, localOperators }: ClassifyOptions = {}, -): StoredQueryClass { - if (method === 'get') { - return query && Object.keys(query).length > 0 && !isLocallyEvaluable(query, localOperators) - ? 'server-authoritative' - : 'get' - } - return classifyQueryNode(query, { server, allPages, localOperators }) -} - /** * A query the client must not merge realtime events into blindly. Server-window * queries merge the provable subset of events and refetch for the rest; diff --git a/lib/core/queryMaintenance.ts b/lib/core/queryMaintenance.ts index e2f4d5c..9fcfcf9 100644 --- a/lib/core/queryMaintenance.ts +++ b/lib/core/queryMaintenance.ts @@ -1,6 +1,6 @@ import { - classifyStoredQuery, - isProjectionQuery, + explainQueryNode, + type ClassificationReason, isServerMaintained, usesFetchOwnedRows, type StoredQueryClass, @@ -27,6 +27,7 @@ export interface QueryMaintenance { /** Immutable execution rules kept out of subscriber-visible query state. */ interface QueryPolicy { + classificationReasons: readonly Readonly[] responseMode: FetchResponseMode /** Preferred row ownership; missing IDs still force a query to retain its own values. */ rowSource: 'entities' | 'values' @@ -82,12 +83,17 @@ export function compileQueryMaintenance({ matcher: (query: Record | undefined) => (item: unknown) => MatchResult }): QueryMaintenance { const query = queryOfParams(desc.params) - const classification = classifyStoredQuery(desc.method, query, { - server: config.server, - allPages: 'allPages' in config && config.allPages === true, + const explanation = explainQueryNode(query, { + server: desc.method === 'find' && config.server, + allPages: desc.method === 'get' || ('allPages' in config && config.allPages === true), localOperators, + snapshot: config.realtime === 'disabled', }) - const isProjection = isProjectionQuery(query) + const classification = + desc.method === 'get' && explanation.class !== 'server-authoritative' + ? 'get' + : explanation.class + const isProjection = explanation.reasons.some(reason => reason.code === 'select-projection') const responseMode: FetchResponseMode = config.realtime === 'disabled' ? 'snapshot' @@ -115,6 +121,9 @@ export function compileQueryMaintenance({ isProjection, } policies.set(maintenance, { + classificationReasons: Object.freeze( + (desc.method === 'find' ? explanation.reasons : []).map(reason => Object.freeze(reason)), + ), responseMode, rowSource: responseMode !== 'entity' || config.fetchPolicy === 'network-only' ? 'values' : 'entities', diff --git a/lib/core/queryStore.ts b/lib/core/queryStore.ts index 7f9acb4..5fb0cbd 100644 --- a/lib/core/queryStore.ts +++ b/lib/core/queryStore.ts @@ -19,7 +19,6 @@ import { applyEventsToService, applyVisibleEventToQuery, createServiceState, - findInsertIndex, isCompleteSetQuery, groupEventsByService, replayFetchedQueryFromEvents, @@ -53,6 +52,7 @@ import { defaultRetryDelay, resolveRetryDelay } from './retryDelay.js' import { normalizeError } from './errors.js' import { isWithinStaleTime } from './staleTime.js' import { sameValue } from './valueEquality.js' +import { findInsertIndex } from './windowDecision.js' import { compareValues, type ValueComparator } from './sort.js' /** @@ -1226,7 +1226,7 @@ export class QueryStore< const desc = query.desc if (desc.method !== 'get') return null // Gets with non-local conditions stored as 'server-authoritative' at - // materialize time (see classifyStoredQuery) — never answered locally. + // materialize time — never answered locally. if (query.maintenance.classification !== 'get') return null const service = this.#state.get(desc.serviceName) if (!service) return null diff --git a/lib/core/relationalQuery.ts b/lib/core/relationalQuery.ts index ac68c78..a07082a 100644 --- a/lib/core/relationalQuery.ts +++ b/lib/core/relationalQuery.ts @@ -271,6 +271,8 @@ export class RelationalQueryRef< #name: string | undefined #rootOverride: RelationalRootOverride | null #relationPlans: RelationPlan[] + #pendingRelationSyncs = new Map() + #syncingRelations = false constructor( host: RelationalQueryHost, @@ -950,24 +952,44 @@ export class RelationalQueryRef< } /** - * Reconciles relation subscriptions with the current parent data at this AST level. - * - Creates a relation subscription when needed - * - Grows it when new source values appear (e.g. a new root item introduced a new id): - * only the new ids are fetched, in a new `$in` chunk; existing chunks stay live - * - Settles immediately when source values are empty so we don't hang on loading - * - * Recurses into nested relations when their parent query has resolved. Recursion happens - * both immediately (if the child query is already succeeded from a previous cycle) and - * lazily (child subscription callback calls back in). + * Drain ready relation levels until no new work remains. Warm subscriptions can + * produce rows synchronously; they enqueue the next level instead of recursing. + * A level queued again while waiting uses its latest parent rows. + */ + #syncRelations(parentData: unknown[], plans: RelationPlan[]): void { + if (plans.length === 0) return + this.#pendingRelationSyncs.set(plans, parentData) + if (this.#syncingRelations) return + this.#syncingRelations = true + try { + while (this.#pendingRelationSyncs.size > 0) { + const next = this.#pendingRelationSyncs.entries().next() + if (next.done) break + const [readyPlans, rows] = next.value + this.#pendingRelationSyncs.delete(readyPlans) + this.#syncRelationLevel(rows, readyPlans) + } + } finally { + this.#syncingRelations = false + this.#pendingRelationSyncs.clear() + } + } + + /** + * Reconcile subscriptions with the current parent rows at one relation level. + * New source values grow the subscription in new `$in` chunks; existing chunks + * stay live. Empty source values settle immediately rather than stay loading. + * Ready child rows enqueue their nested relation levels through #syncRelations. * * Three relation kinds are supported: - * - single-hop `'one'` / `'many'` — fan-in IN(...) on `destField` keyed by `sourceField` - * - `'embedded'` — `sourceField` is a list of dest ids on each parent; flat-mapped into - * the same IN(...) shape, no junction - * - two-hop `'many'` (`relDef.via` set) — first fetch the junction service, then fetch - * the destination keyed by ids collected from the junction + * - single-hop 'one' / 'many' queries use `$in` on `destField`, keyed by `sourceField`; + * - 'embedded' flattens each parent's list of destination ids into the same query, + * without a junction; + * - two-hop 'many' with `via` fetches the junction first, then the destination + * using ids collected from the junction. + * Windowed relations use a separate query per parent instead of a shared fan-in. */ - #syncRelations(parentData: unknown[], plans: RelationPlan[]): void { + #syncRelationLevel(parentData: unknown[], plans: RelationPlan[]): void { for (const plan of plans) { switch (plan.kind) { case 'missing': @@ -1377,6 +1399,7 @@ export class RelationalQueryRef< this.#disposeRelationSub(sub) } this.#relationSubs.clear() + this.#pendingRelationSyncs.clear() this.#lastSnapshot = null this.#lastWrappedSnapshot = null this.#lastWrappedInner = null diff --git a/lib/core/windowDecision.ts b/lib/core/windowDecision.ts new file mode 100644 index 0000000..0c325ad --- /dev/null +++ b/lib/core/windowDecision.ts @@ -0,0 +1,206 @@ +import type { EventType, MatchResult } from './queryTypes.js' + +export interface WindowState { + rows: readonly unknown[] + skip: number + /** The served page size, after applying any server cap. */ + limit: number | undefined +} + +export interface WindowChange { + type: EventType + item: unknown + previousItem: unknown | null + itemId: string + hasItem: boolean +} + +export interface WindowContext { + matches(item: unknown): MatchResult + compare: ((a: unknown, b: unknown) => number) | undefined + keyOf(item: unknown): string | undefined +} + +export type WindowDecision = + | { type: 'noop' } + | { type: 'refetch'; replaceVisible?: boolean } + | { type: 'update'; data: readonly unknown[]; metaOp: 'added' | 'removed' | null } + +/** First index whose row sorts strictly after the item; ties follow their equals. */ +export function findInsertIndex( + rows: readonly unknown[], + item: unknown, + compare: (a: unknown, b: unknown) => number, +): number { + let low = 0 + let high = rows.length + while (low < high) { + const middle = (low + high) >> 1 + if (compare(item, rows[middle]) < 0) high = middle + else low = middle + 1 + } + return low +} + +/** + * Decide whether one realtime event can merge into a server window without a refetch. + * + * The visible rows are a contiguous run of the server result, at positions + * `$skip .. $skip + rows.length`, and the window's predicate is locally evaluable. + * Server-only predicates are classified as server-authoritative before this call. + * An event's effect is provable whenever its position relative to the run's + * boundaries is known: + * + * - a visible patch that keeps its membership and sort position updates in place; + * - an underfilled window is the final page, so past-the-end inserts and visible + * removals resolve locally; + * - an item sorting strictly inside the run belongs there, so insert it and evict + * any overflow row, which returns on the next fetch of a later window; + * - membership changes provably beyond the window only adjust the metadata total. + * + * Everything unprovable refetches, including shifts before an offset page and + * removals from a full window whose replacement row is unseen. The caller compiles + * the comparator from `$sort`, or from `defaultSort` matching the backend's implicit + * order. With neither, visible patches keep their position and an underfilled + * first page appends. Membership stays exact; order is approximate until a fetch. + * Boundary ties refetch because the server's tiebreak decides membership there. + * The caller applies row changes and adapter-specific metadata after the decision. + */ +export function maintainWindow( + state: WindowState, + change: WindowChange, + context: WindowContext, +): WindowDecision { + const { rows, skip, limit } = state + const { type, item, previousItem, itemId, hasItem } = change + const { matches: match, compare: cmp, keyOf } = context + const full = limit !== undefined && rows.length >= limit + const matches = type !== 'removed' && match(item) + const last = rows.length > 0 ? rows[rows.length - 1] : undefined + // Is an invisible member provably past the window? At skip 0 everything before + // the window is visible, so invisible ⇒ beyond; at an offset we need the + // comparator to prove it sorts after the last visible row. + const beyondWindow = (x: unknown) => + skip === 0 ? true : cmp !== undefined && last !== undefined && cmp(x, last) > 0 + // Prior result-set membership: judged by the cached previous entity when there is + // one; a removed event carries the full removed record, which serves the same + // purpose when the row was never cached (it lived beyond the window). + const wasMember = previousItem != null ? match(previousItem) : type === 'removed' && match(item) + // Membership local state can't decide is unprovable by definition. + if (matches === 'unknown' || wasMember === 'unknown') { + return { type: 'refetch', replaceVisible: hasItem && matches !== false } + } + + if (!hasItem) { + if (!matches) { + // Invisible before and after — only the result-set total can be affected. + if (!wasMember) return { type: 'noop' } + if (beyondWindow(previousItem ?? item)) { + return { + type: 'update', + data: rows, + metaOp: 'removed', + } + } + // Left the result set from before the window — the page shifts. + return { type: 'refetch' } + } + + // The item belongs to the result set now. A created item is certainly new; a + // cached non-matching previous certainly entered; an uncached patch at an + // offset window may have come from anywhere, including an earlier page. + const metaOp = + type === 'created' || (previousItem != null && !wasMember) ? ('added' as const) : null + if (skip > 0 && previousItem == null && type !== 'created') { + return { type: 'refetch' } + } + if (wasMember && !beyondWindow(previousItem)) { + // It was in the result set before the window start — its move shifts the page. + return { type: 'refetch' } + } + if (full && rows.length === 0) { + // `$limit: 0` — a count-only window. + if (metaOp === null) return { type: 'noop' } + return { type: 'update', data: rows, metaOp } + } + if (!cmp) { + if (skip > 0 || full) return { type: 'refetch' } + // No order knowledge: membership is certain (underfilled first page = the + // complete result set), position is approximate — append. + return { + type: 'update', + data: [...rows, item], + metaOp, + } + } + if (rows.length === 0) { + if (skip > 0) return { type: 'refetch' } + return { type: 'update', data: [item], metaOp } + } + const i = findInsertIndex(rows, item, cmp) + if (i === 0 && skip > 0) return { type: 'refetch' } // sorts before the page + if (i === rows.length) { + if (!full) { + // Underfilled window = the final page: past-the-end still belongs here. + return { + type: 'update', + data: [...rows, item], + metaOp, + } + } + if (cmp(item, rows[rows.length - 1]!) === 0) { + return { type: 'refetch' } // tied with the boundary row + } + // Strictly past a full window: the total changes, the visible rows don't. + if (metaOp === null) return { type: 'noop' } + return { type: 'update', data: rows, metaOp } + } + const data = [...rows.slice(0, i), item, ...rows.slice(i)] + if (limit !== undefined && data.length > limit) { + data.pop() + } + return { + type: 'update', + data, + metaOp, + } + } + + if (!matches) { + // A visible row leaves. On a full window the replacement row is unknown. + if (full) return { type: 'refetch' } + return { + type: 'update', + data: rows.filter(row => keyOf(row) !== itemId), + metaOp: 'removed', + } + } + + // Visible and still matching: update in place unless its sort position moved. + const index = rows.findIndex(row => keyOf(row) === itemId) + if (index === -1) return { type: 'refetch' } // index/data disagree — reconcile + if (!cmp || cmp(rows[index]!, item) === 0) { + // Sort keys unchanged (or order unknown — keep the position rather than guess). + return { + type: 'update', + data: rows.map(row => (keyOf(row) === itemId ? item : row)), + metaOp: null, + } + } + // The row moved: re-place it within the contiguous run. + const without = rows.filter(row => keyOf(row) !== itemId) + if (without.length === 0) { + return full || skip > 0 ? { type: 'refetch' } : { type: 'update', data: [item], metaOp: null } + } + const i = findInsertIndex(without, item, cmp) + if (i === 0 && skip > 0) return { type: 'refetch' } // may move before the page + if (i === without.length && full) { + // May move past the window while an unseen row takes its place. + return { type: 'refetch' } + } + return { + type: 'update', + data: [...without.slice(0, i), item, ...without.slice(i)], + metaOp: null, + } +} diff --git a/lib/core/windowMaintenance.ts b/lib/core/windowMaintenance.ts index cae5347..3464bfa 100644 --- a/lib/core/windowMaintenance.ts +++ b/lib/core/windowMaintenance.ts @@ -1,16 +1,8 @@ import { servedLimit } from '../adapters/queryPage.js' +import { maintainWindow } from './windowDecision.js' import { sameValue } from './valueEquality.js' import { commitQuery } from './queryResults.js' -/** - * Window maintenance and realtime event application — the pure algebra the query - * store orchestrates. Event application uses service state, queries, queued events, - * and their compiled policies without a store instance. - * - * The heart of the module is `mergeEventIntoWindow`, which decides whether a - * realtime event's effect on a server-window query ($limit/$skip/$sort) is - * *provable* from local state — merging locally when it is, and reporting - * "refetch" when it is not. The soundness argument lives on the function. - */ +/** Apply entity changes and window decisions to queries before publishing effects. */ import { queryPolicy } from './queryMaintenance.js' import { isServerMaintained } from './queryClassification.js' @@ -20,7 +12,6 @@ import { type EntityKey, type ItemId, queryOfParams, - type EventType, type ProcessedCacheEvent, type Query, type QueryState, @@ -64,25 +55,6 @@ export function isCompleteSetQuery(query: Query) ) } -/** First index whose row sorts strictly after the item — ties insert after their equals. */ -export function findInsertIndex( - rows: unknown[], - item: unknown, - cmp: (a: unknown, b: unknown) => number, -): number { - let lo = 0 - let hi = rows.length - while (lo < hi) { - const mid = (lo + hi) >> 1 - if (cmp(item, rows[mid]) < 0) { - hi = mid - } else { - lo = mid + 1 - } - } - return lo -} - export function groupEventsByService( events: readonly TEvent[], ): Record { @@ -331,20 +303,37 @@ function applyMergeEventToQuery( if (query.maintenance.classification !== 'server-window' || query.desc.method !== 'find') { return 'reconcile' } - const result = mergeEventIntoWindow({ - query, - type, - item, - previousItem, - itemId, - hasItem: service.itemQueryIndex.get(itemId)?.has(queryId) ?? false, - getId, - }) - if (result.action === 'refetch') { + if (query.state.status !== 'success' || !Array.isArray(query.state.data)) return 'ignored' + const result = maintainWindow( + { + rows: query.rows.data, + skip: query.maintenance.skip, + limit: + query.maintenance.limit === undefined + ? undefined + : servedLimit(query.state.meta, query.maintenance.limit), + }, + { + type, + item, + previousItem, + itemId, + hasItem: service.itemQueryIndex.get(itemId)?.has(queryId) ?? false, + }, + { + matches: query.maintenance.matches, + compare: query.maintenance.compare, + keyOf: row => { + const id = getId(row) + return id === undefined ? undefined : entityKey(id) + }, + }, + ) + if (result.type === 'refetch') { if (result.replaceVisible) applyVisibleEventEffect(context, queryId, event, 'replace') return 'reconcile' } - if (result.action === 'noop' || query.state.status !== 'success') return 'ignored' + if (result.type === 'noop') return 'ignored' commitQuery(service, { ...query, @@ -619,200 +608,3 @@ export function replayFetchedQueryFromEvents({ ) } } - -type WindowMergeResult = - | { action: 'noop' } - /** `replaceVisible`: a visible row still shows its new values until the refetch lands. */ - | { action: 'refetch'; replaceVisible?: boolean } - | { - action: 'merge' - data: unknown[] - metaOp: 'added' | 'removed' | null - } - -/** - * Try to merge one realtime event into a server-window find without a refetch. - * - * The soundness argument: the visible rows are a contiguous run of the server - * result (positions `$skip .. $skip + rows.length`), and the window's predicate is - * locally evaluable (anything non-local classifies server-authoritative). So an - * event's effect on the window is provable whenever the item's position relative - * to the run's boundaries is known: - * - * - a patch to a visible row that keeps its membership and sort position updates - * in place — no order knowledge needed when the sort keys didn't change; - * - an underfilled window (`rows.length < $limit`) is the final page, so past-the- - * end inserts and removals of visible rows resolve like local-exact; - * - an item sorting strictly inside the run belongs there — insert it and evict - * the overflow row (which slides back in on the next fetch of a later window); - * - membership changes provably beyond the window only adjust the meta total. - * - * Everything unprovable returns `refetch` — the previous behavior for every event. - * Order is judged by `$sort` when present, else the configured `defaultSort` (the - * backend's implicit order). With neither, visible patches keep their position and - * appends to an underfilled first page go to the end — membership stays exact, - * order is approximate until the next fetch, which is the accepted trade. - * Boundary ties refetch: the server's tiebreak decides membership there. - */ -function mergeEventIntoWindow({ - query, - type, - item, - previousItem, - itemId, - hasItem, - getId, -}: { - query: Query - type: EventType - item: unknown - previousItem: unknown | null - itemId: EntityKey - hasItem: boolean - getId: (item: unknown) => ItemId | undefined -}): WindowMergeResult { - const state = query.state - if (state.status !== 'success' || !Array.isArray(state.data)) return { action: 'noop' } - - const { skip, compare: cmp } = query.maintenance - // A page the server capped below the requested $limit is full at the cap. - const limit = - query.maintenance.limit === undefined - ? undefined - : servedLimit(state.meta, query.maintenance.limit) - const rows = query.rows.data - const full = limit !== undefined && rows.length >= limit - const matches = type !== 'removed' && query.maintenance.matches(item) - const last = rows.length > 0 ? rows[rows.length - 1] : undefined - // Is an invisible member provably past the window? At skip 0 everything before - // the window is visible, so invisible ⇒ beyond; at an offset we need the - // comparator to prove it sorts after the last visible row. - const beyondWindow = (x: unknown) => - skip === 0 ? true : cmp !== undefined && last !== undefined && cmp(x, last) > 0 - // Prior result-set membership: judged by the cached previous entity when there is - // one; a removed event carries the full removed record, which serves the same - // purpose when the row was never cached (it lived beyond the window). - const wasMember = - previousItem != null - ? query.maintenance.matches(previousItem) - : type === 'removed' && query.maintenance.matches(item) - // Membership local state can't decide is unprovable by definition. - if (matches === 'unknown' || wasMember === 'unknown') { - return { action: 'refetch', replaceVisible: hasItem && matches !== false } - } - - if (!hasItem) { - if (!matches) { - // Invisible before and after — only the result-set total can be affected. - if (!wasMember) return { action: 'noop' } - if (beyondWindow(previousItem ?? item)) { - return { - action: 'merge', - data: rows, - metaOp: 'removed', - } - } - // Left the result set from before the window — the page shifts. - return { action: 'refetch' } - } - - // The item belongs to the result set now. A created item is certainly new; a - // cached non-matching previous certainly entered; an uncached patch at an - // offset window may have come from anywhere, including an earlier page. - const metaOp = - type === 'created' || (previousItem != null && !wasMember) ? ('added' as const) : null - if (skip > 0 && previousItem == null && type !== 'created') { - return { action: 'refetch' } - } - if (wasMember && !beyondWindow(previousItem)) { - // It was in the result set before the window start — its move shifts the page. - return { action: 'refetch' } - } - if (full && rows.length === 0) { - // `$limit: 0` — a count-only window. - if (metaOp === null) return { action: 'noop' } - return { action: 'merge', data: rows, metaOp } - } - if (!cmp) { - if (skip > 0 || full) return { action: 'refetch' } - // No order knowledge: membership is certain (underfilled first page = the - // complete result set), position is approximate — append. - return { - action: 'merge', - data: [...rows, item], - metaOp, - } - } - if (rows.length === 0) { - if (skip > 0) return { action: 'refetch' } - return { action: 'merge', data: [item], metaOp } - } - const i = findInsertIndex(rows, item, cmp) - if (i === 0 && skip > 0) return { action: 'refetch' } // sorts before the page - if (i === rows.length) { - if (!full) { - // Underfilled window = the final page: past-the-end still belongs here. - return { - action: 'merge', - data: [...rows, item], - metaOp, - } - } - if (cmp(item, rows[rows.length - 1]!) === 0) { - return { action: 'refetch' } // tied with the boundary row - } - // Strictly past a full window: the total changes, the visible rows don't. - if (metaOp === null) return { action: 'noop' } - return { action: 'merge', data: rows, metaOp } - } - const data = [...rows.slice(0, i), item, ...rows.slice(i)] - if (limit !== undefined && data.length > limit) { - data.pop() - } - return { - action: 'merge', - data, - metaOp, - } - } - - if (!matches) { - // A visible row leaves. On a full window the replacement row is unknown. - if (full) return { action: 'refetch' } - return { - action: 'merge', - data: rows.filter(row => !itemHasKey(row, itemId, getId)), - metaOp: 'removed', - } - } - - // Visible and still matching: update in place unless its sort position moved. - const index = rows.findIndex(row => itemHasKey(row, itemId, getId)) - if (index === -1) return { action: 'refetch' } // index/data disagree — reconcile - if (!cmp || cmp(rows[index]!, item) === 0) { - // Sort keys unchanged (or order unknown — keep the position rather than guess). - return { - action: 'merge', - data: rows.map(row => (itemHasKey(row, itemId, getId) ? item : row)), - metaOp: null, - } - } - // The row moved: re-place it within the contiguous run. - const without = rows.filter(row => !itemHasKey(row, itemId, getId)) - if (without.length === 0) { - return full || skip > 0 - ? { action: 'refetch' } - : { action: 'merge', data: [item], metaOp: null } - } - const i = findInsertIndex(without, item, cmp) - if (i === 0 && skip > 0) return { action: 'refetch' } // may move before the page - if (i === without.length && full) { - // May move past the window while an unseen row takes its place. - return { action: 'refetch' } - } - return { - action: 'merge', - data: [...without.slice(0, i), item, ...without.slice(i)], - metaOp: null, - } -} diff --git a/test/figbird-instance.test.ts b/test/figbird-instance.test.ts index 02e1d1d..4c32ce7 100644 --- a/test/figbird-instance.test.ts +++ b/test/figbird-instance.test.ts @@ -579,3 +579,25 @@ test('a fetch that never settles does not suspend the entity cache bound', async releaseHung() releaseNarrow() }) + +test('inspection reuses frozen compiled classification reasons', t => { + const figbird = new Figbird({ + schema, + adapter: new FeathersAdapter( + mockFeathers({ notes: { data: { 1: { id: 1, content: 'hello' } } } }), + ), + }) + t.teardown(() => figbird.dispose()) + const ref = figbird.queryDesc( + { serviceName: 'notes', method: 'find', params: { query: { $limit: 1 } } }, + { realtime: 'merge' }, + ) + const unsubscribe = ref.subscribe(() => {}) + t.teardown(unsubscribe) + + const reasons = figbird.inspect()[0]?.classificationReasons + t.deepEqual(reasons, [{ code: 'window-filter', detail: '$limit' }]) + t.is(figbird.inspect()[0]?.classificationReasons, reasons) + t.true(Object.isFrozen(reasons)) + for (const reason of reasons ?? []) t.true(Object.isFrozen(reason)) +}) diff --git a/test/query-maintenance.test.ts b/test/query-maintenance.test.ts index bc34cf7..95fa401 100644 --- a/test/query-maintenance.test.ts +++ b/test/query-maintenance.test.ts @@ -1,4 +1,5 @@ import test from 'ava' +import { explainQueryNode } from '../lib/core/queryClassification.js' import { compileQueryMaintenance, queryPolicy } from '../lib/core/queryMaintenance.js' import type { QueryConfig, QueryDescriptor } from '../lib/core/queryTypes.js' import { compareValues } from '../lib/core/sort.js' @@ -79,6 +80,15 @@ test('compiled policies preserve the original rules in all 1,024 combinations', t.deepEqual( queryPolicy(maintenance), { + classificationReasons: + method === 'find' + ? explainQueryNode(query, { + server, + allPages, + localOperators: new Set(), + snapshot: realtime === 'disabled', + }).reasons + : [], responseMode, rowSource: ownsValues ? 'values' : 'entities', mergeEvents: diff --git a/test/window-decision.test.ts b/test/window-decision.test.ts new file mode 100644 index 0000000..905a876 --- /dev/null +++ b/test/window-decision.test.ts @@ -0,0 +1,126 @@ +import test from 'ava' +import { buildComparator, compareValues } from '../lib/core/sort.js' +import { + maintainWindow, + type WindowChange, + type WindowContext, + type WindowDecision, + type WindowState, +} from '../lib/core/windowDecision.js' + +const one = Object.freeze({ id: 1, rank: 1, active: true }) +const two = Object.freeze({ id: 2, rank: 2, active: true }) +const three = Object.freeze({ id: 3, rank: 3, active: true }) +const four = Object.freeze({ id: 4, rank: 4, active: true }) +const patched = Object.freeze({ ...one, text: 'changed' }) +const tied = Object.freeze({ ...three, rank: 2 }) +const moved = Object.freeze({ ...three, rank: 1 }) +const later = Object.freeze({ ...four, rank: 5 }) +const unknown = Object.freeze({ ...one, active: 'unknown' }) + +const firstPage: WindowState = { rows: Object.freeze([one, two]), skip: 0, limit: 2 } +const offsetPage: WindowState = { rows: Object.freeze([three, four]), skip: 2, limit: 2 } +const context: WindowContext = { + compare: buildComparator({ rank: 1 }, compareValues), + matches: item => { + const active = + typeof item === 'object' && item !== null && 'active' in item ? item.active : undefined + return active === 'unknown' ? 'unknown' : active === true + }, + keyOf: item => { + const id = typeof item === 'object' && item !== null && 'id' in item ? item.id : undefined + return typeof id === 'number' ? String(id) : undefined + }, +} + +const cases: { + name: string + state: WindowState + change: WindowChange + context?: WindowContext + expected: WindowDecision +}[] = [ + { + name: 'patches a visible row in place when sort keys stay unchanged', + state: firstPage, + change: { type: 'patched', item: patched, previousItem: one, itemId: '1', hasItem: true }, + expected: { type: 'update', data: [patched, two], metaOp: null }, + }, + { + name: 'inserts into an underfilled window in sort order', + state: { ...firstPage, rows: Object.freeze([one, three]), limit: 3 }, + change: { type: 'created', item: two, previousItem: null, itemId: '2', hasItem: false }, + expected: { type: 'update', data: [one, two, three], metaOp: 'added' }, + }, + { + name: 'refetches an insert tied with a full window boundary', + state: firstPage, + change: { type: 'created', item: tied, previousItem: null, itemId: '3', hasItem: false }, + expected: { type: 'refetch' }, + }, + { + name: 'refetches a removal from a full window for the unseen replacement', + state: firstPage, + change: { type: 'removed', item: one, previousItem: one, itemId: '1', hasItem: true }, + expected: { type: 'refetch' }, + }, + { + name: 'removes a visible row from an underfilled final page', + state: { ...firstPage, limit: 3 }, + change: { type: 'removed', item: one, previousItem: one, itemId: '1', hasItem: true }, + expected: { type: 'update', data: [two], metaOp: 'removed' }, + }, + { + name: 'refetches an insert before an offset window', + state: offsetPage, + change: { type: 'created', item: one, previousItem: null, itemId: '1', hasItem: false }, + expected: { type: 'refetch' }, + }, + { + name: 'refetches a removal before an offset window', + state: offsetPage, + change: { type: 'removed', item: one, previousItem: one, itemId: '1', hasItem: false }, + expected: { type: 'refetch' }, + }, + { + name: 'refetches a visible row moving before an offset window', + state: offsetPage, + change: { type: 'patched', item: moved, previousItem: three, itemId: '3', hasItem: true }, + expected: { type: 'refetch' }, + }, + { + name: 'ignores a known member moving strictly beyond a full window', + state: firstPage, + change: { type: 'patched', item: later, previousItem: four, itemId: '4', hasItem: false }, + expected: { type: 'noop' }, + }, + { + name: 'updates only the total for an insert strictly beyond a full window', + state: firstPage, + change: { type: 'created', item: three, previousItem: null, itemId: '3', hasItem: false }, + expected: { type: 'update', data: firstPage.rows, metaOp: 'added' }, + }, + { + name: 'refetches unknown membership while allowing the visible value to update', + state: firstPage, + change: { type: 'patched', item: unknown, previousItem: one, itemId: '1', hasItem: true }, + expected: { type: 'refetch', replaceVisible: true }, + }, + { + name: 'appends to an underfilled first page without an ordering comparator', + state: { ...firstPage, limit: 3 }, + change: { type: 'created', item: three, previousItem: null, itemId: '3', hasItem: false }, + context: { ...context, compare: undefined }, + expected: { type: 'update', data: [one, two, three], metaOp: 'added' }, + }, +] + +for (const fixture of cases) { + test(`maintainWindow ${fixture.name}`, t => { + const decision = maintainWindow(fixture.state, fixture.change, fixture.context ?? context) + t.deepEqual(decision, fixture.expected) + if (fixture.expected.type === 'update' && fixture.expected.data === fixture.state.rows) { + t.is(decision.type === 'update' && decision.data, fixture.state.rows) + } + }) +} diff --git a/test/window-maintenance.test.ts b/test/window-maintenance.test.ts index 4edd6d2..3252669 100644 --- a/test/window-maintenance.test.ts +++ b/test/window-maintenance.test.ts @@ -8,7 +8,7 @@ import { mockFeathers } from './helpers' /** * Window maintenance: server-window finds merge realtime events locally when the * event's effect on the window is provable, and fall back to a refetch for the - * rest. See mergeEventIntoWindow in lib/core/windowMaintenance.ts for the + * rest. See maintainWindow in lib/core/windowDecision.ts for the * soundness argument these tests exercise branch by branch. */