/** * Chainable, immutable query builder. * * Each builder operation returns a NEW Query — the underlying plan is never * mutated. This makes plans safe to share, cache, and serialize. */ import type { QueryField } from '../types.js'; import type { Clause, Operator } from './predicate.js'; import type { CollectionIndexes } from '../../with-lookup/indexing/eager-indexes.js'; import type { JoinContext, JoinLeg, JoinStrategy } from './join.js'; import type { LiveQuery } from './live.js'; import type { AggregateSpec, AggregateResult, Aggregation } from '../../with-lookup/aggregate/aggregation.js'; import type { ReducerBuilder } from '../../with-lookup/aggregate/reducers.js'; import type { GroupedQuery, GroupedQueryN } from '../../with-lookup/aggregate/groupby.js'; import { type AggregateStrategy } from '../../with-lookup/aggregate/strategy.js'; import type { ViaPipeline } from '../via/pipeline.js'; export interface OrderBy { readonly field: string; readonly direction: 'asc' | 'desc'; /** * Sort key for a `dictKey`/`staticDict` field: `'value'` (default) * sorts by the stored code; `'label'` sorts by the code's resolved label at * the query locale (`toArray({ locale })`, or a `staticDict` `displayLocale`). * Falls back to the code when no label resolves. */ readonly by?: 'value' | 'label'; } /** * A complete query plan: zero-or-more clauses, optional ordering, pagination, * and optional joins. * * Plans are JSON-serializable as long as no FilterClause is present and no * join leg carries a manual `strategy` override (JoinLeg itself is plain * data, so it serializes cleanly). * * Plans are intentionally NOT parametric on T — see `predicate.ts` FilterClause * for the variance reasoning. The public `Query` API attaches the type tag. */ export interface QueryPlan { readonly clauses: readonly Clause[]; readonly orderBy: readonly OrderBy[]; readonly limit: number | undefined; readonly offset: number; /** * Zero-or-more join legs to apply after where/orderBy/limit/offset. * Each leg attaches a resolved right-side record (or null) under its * alias. See `query/join.ts` for the full semantics. */ readonly joins: readonly JoinLeg[]; } /** Default row ceiling for cross-join expansion. Matches JoinTooLargeError's ceiling. */ export declare const DEFAULT_CROSS_JOIN_MAX_ROWS = 50000; /** * Source of records that a query executes against. * * The interface is non-parametric to keep variance friendly: callers cast * their typed source (e.g. `QuerySource`) into this opaque shape. * * `getIndexes` and `lookupById` are optional fast-path hooks. When both are * present and a where clause matches an indexed field, the executor uses * the index to skip a linear scan. Sources without these methods (or with * `getIndexes` returning `null`) always fall back to a linear scan. */ export interface QuerySource { /** Snapshot of all current records. The query never mutates this array. */ snapshot(): readonly T[]; /** Subscribe to mutations; returns an unsubscribe function. */ subscribe?(cb: () => void): () => void; /** Index store for the indexed-fast-path. Optional. */ getIndexes?(): CollectionIndexes | null; /** O(1) record lookup by id, used to materialize index hits. */ lookupById?(id: string): T | undefined; /** * The backing collection's compiled Via pipeline (money now; more Via * features later), used to rewrite `where()` operands, decode results, * order, and rewrite aggregate reducers for covered fields. */ via?: ViaPipeline; /** * Id-paired snapshot for `Query._idArray()` (the `retrieve({within})` * id projection). Optional: only collection-backed queries supply it. */ snapshotEntries?(): readonly { id: string; record: T; }[]; } /** * The chainable builder. All methods return a new Query — the original * remains unchanged. Terminal methods (`toArray`, `first`, `count`, * `subscribe`) execute the plan against the source. * * Type parameter T flows through the public API for ergonomics, but the * internal storage uses `unknown` so Collection stays covariant. * * The optional `joinContext` is attached when the Query is constructed * via `Collection.query()` (Collection passes in a context built from * the Vault's join resolver). A Query constructed via `new Query` * directly — e.g. from tests with a plain-object source — has no * joinContext, and calling `.join()` on it throws with an actionable * error. See `query/join.ts` for the full design. */ /** * Declared deterministic predicate. Carries the consumer's * stable `hash` (for function-body identity), the function itself, * and is keyed by name when registered on a `Query` via * `_withPredicates()`. */ export interface DeclaredPredicate { hash: string; fn: (record: unknown, ctx?: unknown) => boolean; } export declare class Query { private readonly source; private readonly plan; private readonly joinContext; private readonly aggregateStrategy; private readonly predicates; constructor(source: QuerySource, plan?: QueryPlan, joinContext?: JoinContext, aggregateStrategy?: AggregateStrategy, predicates?: ReadonlyMap); /** * @internal — accessor for the materialized-view dependency * analyzer. Not part of the public API; consumers should use the * builder methods, not inspect the plan directly. */ _plan(): QueryPlan; /** * @internal — accessor for the materialized-view dependency * analyzer. Returns the join resolution context (or `undefined` for * queries constructed without a Collection backing). */ _joinContext(): JoinContext | undefined; /** * @internal — clone this Query with a declared-predicate map * attached. Used by the materialized-view registry to enable * `.wherePredicate(name, ctx?)` for the MV's query callback. * Consumers don't call this directly. */ _withPredicates(predicates: ReadonlyMap): Query; /** * @internal — the ids of records matching this query's plan, * recovered by reference identity: `executePlanWithSource` returns the * ORIGINAL snapshot record references (money-decode and joins are applied * later, in `toArray`), so each matched record is found in the id-paired * `snapshotEntries()` map. Used by `collection.retrieve({ within })`. * Throws if the source is not collection-backed (no `snapshotEntries`). */ _idArray(): string[]; /** * Filter by a registered deterministic predicate. Requires * the Query to have been augmented with a predicates map (typically * via the materialized-view registry — bare Queries constructed * outside an MV throw on `.wherePredicate()`). * * `ctx` is an optional opaque value passed verbatim to the predicate * function. Both `predicateHash` (from the registration) and a * canonical-JSON hash of `ctx` fold into the MV's `queryHash`, so * either changing forces refresh on next visit. */ wherePredicate(name: string, ctx?: unknown): Query; /** * Add a field comparison. Multiple where() calls are AND-combined. * * A declared money field compares in MAJOR units: the operand * (`10000`, `'10000.00'`, or `{ amount, currency }` in multi mode) is * quantized into stored scaled-int space at build time and evaluated * BigInt-exact per record. A malformed operand or a string operator * (`contains`/`startsWith`) throws here, at the call site. * * Consults the Via pipeline's posture before building a clause (#629 * Task 8): a field whose posture is `queryable: 'none'` (e.g. a * `blobFields` slot) throws `FieldNotQueryableError` here, at the call * site. Every other posture is unaffected — the existing per-binding * `buildClause` machinery runs exactly as before. */ where(field: QueryField, op: Operator, value: unknown): Query; /** * Logical OR group. Pass a callback that builds a sub-query. * Each clause inside the callback is OR-combined; the group itself * joins the parent plan with AND. */ or(builder: (q: Query) => Query): Query; /** * Logical AND group. Same shape as `or()` but every clause inside the group * must match. Useful for explicit grouping inside a larger OR. */ and(builder: (q: Query) => Query): Query; /** Escape hatch: add an arbitrary predicate function. Not serializable. */ filter(fn: (record: T) => boolean): Query; /** * Sort by a field. Subsequent calls are tie-breakers. Pass * `{ by: 'label' }` to sort a `dictKey`/`staticDict` field by its resolved * label at the query locale instead of the stored code. * * Consults the Via pipeline's posture (#629 Task 8): a field whose posture * is `queryable: 'none'` throws `FieldNotQueryableError` here, at the call * site — same gate as `where()`. */ orderBy(field: QueryField, direction?: 'asc' | 'desc', opts?: { by?: 'value' | 'label'; }): Query; /** Cap the result size. */ limit(n: number): Query; /** Skip the first N matching records (after ordering). */ offset(n: number): Query; /** * Resolve a `ref()`-declared foreign key and attach the right-side * record under `opts.as`. — eager, single-FK, intra- * vault joins. * * ```ts * const rows = invoices.query() * .where('status', '==', 'open') * .join('clientId', { as: 'client' }) * .toArray() * // → [{ id, amount, client: { id, name, ... } }, ...] * ``` * * Preconditions: * - The Query must have a `joinContext` (constructed via * `Collection.query()`, not `new Query`). * - `field` must have a matching `refs: { [field]: ref('') }` * declaration on the left collection. * - The target collection must be reachable via the vault * (either currently open or openable on demand). * * Strategy: * - Nested-loop against `lookupById` when the target source * provides it (the common path for Collection targets). * - Hash join otherwise, or when `{ strategy: 'hash' }` is * explicitly passed for test purposes. * * Ref-mode semantics on dangling refs (left record has a non-null * FK value pointing at a right-side id that doesn't exist): * - `strict` → throws `DanglingReferenceError` with the full * field / target / refId context. * - `warn` → attaches `null` and emits a one-shot warning per * unique dangling pair. * - `cascade` → attaches `null` silently. Cascade is a * delete-time mode; dangling refs visible at read time are * either mid-flight cascades or pre-existing orphans, not a * DSL-level error. * * A left-side record whose FK field is `null` / `undefined` is NOT * a dangling ref — it's "no reference at all", always allowed * regardless of mode. * * The return type widens `T` with `Record`. The `R` * parameter is optional — supply it explicitly for type-checked * access to the joined fields: * * ```ts * invoices.query().join<'client', Client>('clientId', { as: 'client' }) * // ^^^^^^^^^^^^^^^^^^^ alias literal + right-side type * ``` * * Without the generic, the joined field is typed as `unknown`, which * still works but requires a cast to access its properties. * * Joins stay intra-vault by construction — cross-vault * correlation goes through `Noydb.queryAcross`, not * `.join()`. */ join(field: QueryField, opts: { as: As; strategy?: JoinStrategy; maxRows?: number; }): Query, S, Q, M>; /** * Cartesian-product cross-join against `target` collection. Each result row * carries the original `T` fields plus `result[as]` populated from every * right-side row (or the filtered subset when `on:` is supplied). * * **Order matters:** `.where().crossJoin()` filters BEFORE expanding (cheaper); * `.crossJoin().where('alias.field', ...)` filters AFTER (required when the * where clause references the aliased fields). * * **Cost ceiling:** `CrossJoinTooLargeError` fires before allocation when * `leftRows × rightRows` (or the cumulative lateral count) exceeds the limit. * Default: 50,000 rows. Override per-clause with `{ maxRows: N }`. * * **`on:` shapes:** * - `on: (left) => TTarget[]` — subset form (most efficient) * - `on: (left) => (right) => boolean` — predicate form * - `on: { predicate: 'name' }` — MV-safe, hash-tracked form * (requires the Query to have been augmented via `_withPredicates`) * * Requires a JoinContext (constructed via `collection.query()`). */ crossJoin(target: string, opts: { as: As; on?: ((left: T) => unknown[] | ((right: TTarget) => boolean)) | { readonly predicate: string; }; maxRows?: number; }): Query; /** * Execute the plan and return the matching records. When the plan * carries any join legs, they are applied after `where` / `orderBy` * / `limit` / `offset` narrow the left set. See the `.join()` doc * for the ordering rationale. * * `opts.locale` resolves JOINED right-side i18n fields at the * `join` layer to that locale; without it, the owning collection's default * locale applies, and a locale-less query leaves joined i18n fields raw. * (Left/base i18n fields are resolved by `get`/`list`, not here.) */ toArray(opts?: { locale?: string; }): T[]; /** * Decode this source's Via-covered fields on read (e.g. money: stored * scaled-int → canonical decimal), so `query().toArray()` agrees with * `get()`/`sum()` on the value. No-op when the source's Via pipeline * declares no result decode. * * The query layer carries no locale context, so money decodes with * `'raw'` — canonical decimal, WITHOUT fabricating locale-formatted * `Formatted` / `Number` virtuals. Producing a * guessed-locale string here would reintroduce a "two read paths * disagree" failure on the virtual field (e.g. it-IT via `get()` vs * en-US here). Consumers who need formatted money read through * `get()`/`list()` with a locale. */ private decodeVia; /** Return the first matching record, or null. Joins are applied. `opts.locale` resolves joined i18n fields. */ first(opts?: { locale?: string; }): T | null; /** * Return the number of matching records (after where/filter, * before limit). **Joins are NOT applied** — count() reports the * left-side cardinality, because joins in are projection-only * (they attach an aliased field; they never filter). Running joins * here just to discard the aliases would be wasteful, and in strict * mode it could throw `DanglingReferenceError` for a call whose * intent is purely to count. */ count(): number; /** * Reduce the matching records through a named set of reducers. * the aggregation terminal. * * ```ts * const { total, n, avgAmount } = invoices.query() * .where('status', '==', 'open') * .aggregate({ * total: sum('amount'), * n: count(), * avgAmount: avg('amount'), * }) * .run() * ``` * * Returns an `Aggregation` wrapper with two terminals: * - `.run(): R` — synchronous one-shot reduction * - `.live(): LiveAggregation` — reactive primitive that * re-runs the reduction whenever the source notifies of a * change. Always call `live.stop()` when finished. * * The reducer spec is bound here once and reused by both * terminals — this is why `.aggregate()` returns a wrapper instead * of being a direct terminal. Consumers who only need the static * value read `.run()`; consumers wiring a reactive UI read * `.live()`. * * Joins are intentionally NOT applied to aggregations in — * the same logic as `.count()`. Joins in are projection-only * (they attach an aliased field and never filter), so running * them just to throw the aliases away would be wasteful. If you * need a reducer that reads a joined field, open an issue — * aggregations-across-joins is explicitly out of scope for v1. * * Every reducer factory accepts an optional `{ seed }` parameter * that is plumbed through the protocol but unused by the * executor — that's constraint #2. When partition-aware * aggregation lands, the seed will carry running state across * partition boundaries without an API break. * * KNOWN GAP (sealed fields, bare-spec form): `where`/`orderBy`/`groupBy` * refuse a `sensitive` field at compile time, but a reducer over a sensitive * field in the BARE-SPEC form (e.g. `aggregate({ x: sum('ssn') })`) is NOT * refused — the reducer factories (`sum`/`min`/`max`/…) are standalone * `(field: string)` functions with no collection-type context. This form is * preserved as-is for backward compatibility. * * The BUILDER form closes this gap: `aggregate(b => ({ x: b.sum('field') }))` * types the builder's field parameter as `QueryField`, refusing any * `sensitive` field at compile time. Use the builder form for new code that * aggregates over a collection with sensitive fields. * */ aggregate(spec: Spec): Aggregation>; aggregate(build: (b: ReducerBuilder) => Spec): Aggregation>; /** * Partition matching records into buckets keyed by a field, then * terminate with `.aggregate(spec)` to compute per-bucket * reducers.. * * ```ts * const byClient = invoices.query() * .where('status', '==', 'open') * .groupBy('clientId') * .aggregate({ total: sum('amount'), n: count() }) * .run() * // → [ { clientId: 'c1', total: 5250, n: 3 }, … ] * ``` * * Result rows carry the group key value under the grouping field * name plus every reducer output from the spec. Buckets are * emitted in first-seen order — consumers who want a specific * ordering should `.sort()` downstream. * * **Cardinality caps:** a one-shot warning fires at 10_000 * distinct groups; `GroupCardinalityError` throws at 100_000. * Grouping on a high-uniqueness field like `id` or `createdAt` is * almost always a query mistake — the error message names the * field and observed cardinality and suggests narrowing with * `.where()` first. * * **Null / undefined keys:** records with a missing or explicitly * `null` group field get their own buckets. `Map`-based * partitioning distinguishes `undefined` from `null`, so the two * cases do NOT merge. Consumers who want them merged should * coalesce upstream with `.filter()`. * * **Joins are not applied** — same rationale as `.count()` and * `.aggregate()`. Joined fields in are projection-only, so * running a join inside a grouping pipeline would be wasteful and * could trigger `DanglingReferenceError` in strict mode for a * call whose intent is purely to bucket-and-reduce. Grouping by * a joined field is explicitly out of scope for — file an * issue if a real consumer needs it. * * **Filter clauses (`.filter(fn)`):** grouped queries still * support filter clauses in the underlying plan — they run in * the same candidate/filter pipeline that `.aggregate()` uses. * The performance caveat is the same: filter clauses cost O(N) * per record and can't be index-accelerated. */ groupBy>(field: F): GroupedQuery; groupBy, QueryField, ...QueryField[]]>(...fields: F): GroupedQueryN; /** * Re-run the query whenever the source notifies of changes. * Returns an unsubscribe function. The callback receives the latest result. * Throws if the source does not support subscriptions. * * **For joined queries, prefer `.live()`** — `subscribe()` * only re-fires on LEFT-side changes, so joined data can be * stale if the right side mutates between emissions. `.live()` * merges change streams from every join target. */ subscribe(cb: (result: T[]) => void): () => void; /** * Reactive terminal — returns a `LiveQuery` that re-runs the * query and updates its `value` whenever any source feeding it * mutates.. * * For non-joined queries, `.live()` is a convenience over the * existing `.subscribe()` callback shape: a hand-rolled reactive * primitive with `value` / `error` fields and a `subscribe(cb)` * notification channel. Frame-agnostic — Vue / React / Solid * adapters wrap it in their own primitive. * * For joined queries, `.live()` additionally subscribes to every * join target's change stream. Mutations on a right-side * collection (insert / update / delete of a client referenced by * an invoice) re-fire the live query and re-evaluate every * dependent left row. Right-side targets are deduped by * collection name, so a chain that joins the same target twice * (e.g. billing client + shipping client → both 'clients') only * subscribes once. * * **Ref-mode behavior on right-side disappearance** — matches the * eager `.toArray()` contract from : * - `strict` → re-run throws `DanglingReferenceError`. The * LiveQuery catches the throw, stores it in `live.error`, and * notifies listeners (the throw does NOT propagate out of * the source's change handler — that would tear down the * emitter). Consumers check `live.error` after each * notification and render an error state in the UI. * - `warn` → joined value flips to `null`; the existing * warn-channel deduplication keeps repeated re-runs from * spamming the console. * - `cascade` → no special handling needed; the cascade- * delete mechanism propagates the right-side delete into the * left collection on the next tick, and the live query * naturally re-fires with the orphaned left rows gone. * * Always call `live.stop()` when finished — it tears down every * upstream subscription. The Vue layer's `onUnmounted` hook * should call `stop()` automatically; raw consumers must do it * themselves. * * **Limitations:** * - No granular delta updates — the whole query re-runs on * every change. * - No microtask batching — bursty changes produce one re-run * per change. * - No re-planning under live mutations — the planner picks * once at subscription time and reuses the same plan. * - Streaming live joins are deferred. */ live(): LiveQuery; /** * Return the plan as a JSON-friendly object. FilterClause entries are * stripped (their `fn` cannot be serialized) and replaced with * { type: 'filter', fn: '[function]' } so devtools can still see them. */ toPlan(): unknown; } /** * Execute a plan against a snapshot of records. * Pure function — same input, same output, no side effects. * * Records are typed as `unknown` because plans are non-parametric; callers * cast the return type at the API surface (see `Query.toArray()`). */ export declare function executePlan(records: readonly unknown[], plan: QueryPlan): unknown[];