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
16 changes: 7 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 2 additions & 12 deletions lib/core/figbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<ClassificationReason>[]
realtimeStrategy?: 'merge' | 'refetch' | 'manual'
/** True when this entry was materialized with `skip: true`. */
skipped?: boolean
Expand Down
53 changes: 1 addition & 52 deletions lib/core/queryClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,6 @@ export interface ClassifyOptions {
localOperators?: ReadonlySet<string> | 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. */
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -232,43 +211,13 @@ 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<string>): 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
* queries, which have no find classification.
*/
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<string, unknown> | 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;
Expand Down
21 changes: 15 additions & 6 deletions lib/core/queryMaintenance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
classifyStoredQuery,
isProjectionQuery,
explainQueryNode,
type ClassificationReason,
isServerMaintained,
usesFetchOwnedRows,
type StoredQueryClass,
Expand All @@ -27,6 +27,7 @@ export interface QueryMaintenance {

/** Immutable execution rules kept out of subscriber-visible query state. */
interface QueryPolicy {
classificationReasons: readonly Readonly<ClassificationReason>[]
responseMode: FetchResponseMode
/** Preferred row ownership; missing IDs still force a query to retain its own values. */
rowSource: 'entities' | 'values'
Expand Down Expand Up @@ -82,12 +83,17 @@ export function compileQueryMaintenance({
matcher: (query: Record<string, unknown> | 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'
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions lib/core/queryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
applyEventsToService,
applyVisibleEventToQuery,
createServiceState,
findInsertIndex,
isCompleteSetQuery,
groupEventsByService,
replayFetchedQueryFromEvents,
Expand Down Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down
53 changes: 38 additions & 15 deletions lib/core/relationalQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ export class RelationalQueryRef<
#name: string | undefined
#rootOverride: RelationalRootOverride | null
#relationPlans: RelationPlan[]
#pendingRelationSyncs = new Map<RelationPlan[], unknown[]>()
#syncingRelations = false

constructor(
host: RelationalQueryHost<TParams, TMeta, TQuery>,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading