/** * Query DSL `.groupBy()` —. * * Chains after `.where()` / `.filter()` / `.or()` / `.and()` on a * Query and before a reducer spec, so consumers can compute * per-bucket aggregates without folding in userland: * * ```ts * const byClient = invoices.query() * .where('status', '==', 'open') * .groupBy('clientId') * .aggregate({ total: sum('amount'), n: count() }) * .run() * // → [ { clientId: 'c1', total: 5250, n: 3 }, … ] * ``` * * Execution pipeline: * * 1. Run the query's where/filter clauses (same candidate / * filter pipeline as `.aggregate()` directly on Query). * 2. Partition the matching records into buckets keyed by * `readPath(record, field)`. JS `Map` preserves insertion * order, so the first-seen key for a bucket determines its * position in the result array — consumers who want a * specific ordering should `.sort()` downstream. * 3. Enforce cardinality: warn once per field at 10% of the cap * (10_000 buckets), throw `GroupCardinalityError` at 100% of * the cap (100_000 buckets). * 4. For each bucket, build a per-group reducer state and * step every record in the bucket through it. * 5. Emit one result row per bucket, shaped as * `{ [field]: key, ...reduced }`. * * **Null / undefined keys:** `Map` distinguishes `null` from * `undefined`, so records with a missing group field get their own * bucket, and records with an explicit `null` value get a separate * bucket from that. Consumers who want them merged can coalesce * upstream with `.filter()`. * * **Live mode:** `.groupBy().aggregate().live()` re-runs the full * grouping pipeline on every source change. Per-bucket incremental * delta maintenance is a future optimization — the reducer * protocol's `remove()` hook admits it, but ships naive * re-grouping for simplicity. * * **Type-level stable-key narrowing:** when * `dictKey` lands, `groupBy()` will narrow the group key * type to the stable dictionary key rather than the resolved locale * label. That prevents grouping by the locale-resolved label, * which would produce different buckets per reader. types the * key as `unknown` at the result shape; the dictKey narrowing * layers on top without an API break. * * Partition-awareness seam: when partitioned collections land, * per-partition grouping will need to merge sub-results across * partitions. The reducer protocol's `{ seed }` parameter * (already plumbed through in `reducers.ts`) is the mechanism — * groupBy doesn't need its own seam for the moment, because it * delegates to the reducer protocol for all per-bucket state. */ import type { AggregateSpec, AggregateResult, AggregationUpstream, LiveAggregation } from './aggregation.js'; import type { ReducerBuilder } from './reducers.js'; import type { MoneyDescriptor } from '../../via/money/descriptor.js'; import type { ViaPipeline } from '../../kernel/via/pipeline.js'; import { type I18nTextDescriptor } from '../../via/i18n/core.js'; /** * Cardinality thresholds for `.groupBy()`. The warn threshold gives * consumers a heads-up before the hard error; the cap is a fixed * constant in (not overridable). A `{ maxGroups }` override * can be added later without a break if a real consumer asks. */ export declare const GROUPBY_WARN_CARDINALITY = 10000; export declare const GROUPBY_MAX_CARDINALITY = 100000; /** * Test-only: clear the per-field cardinality warning dedup between * tests. Production code never calls this — matching the * `resetJoinWarnings` pattern in `join.ts`. */ export declare function resetGroupByWarnings(): void; /** * Result row shape for a grouped aggregation. Each row carries the * group key value under the grouping field name plus every reducer * output from the spec. * * types the group key as `unknown` at the result shape — the * runtime read via `readPath` can return any value, and narrowing * to a specific type would require the caller to assert at the * call site. `dictKey` narrowing layers on top of this by * adding an overload that constrains `F` when the grouping field * is a `dictKey`. */ export type GroupedRow = { [K in F]: unknown; } & R; /** * Multi-key variant — result-row shape for variadic * `.groupBy(...fields)`. Every grouped field name appears on the row * (typed as `unknown` for the same reason as `GroupedRow`), plus the * reducer outputs from the spec. */ export type GroupedRowN = { [K in F[number]]: unknown; } & R; /** * Shared base class for the chainable grouped-query wrappers. Holds * the constructor + protected fields that both single-key * `GroupedQuery` and variadic `GroupedQueryN` need; each * subclass only overrides `aggregate()` with its own result-row * generic. * * Not exported — implementation detail. Adding `.having()` / * `.live()` / `.orderByGroup()` etc. in the future lands here once * and both subclasses pick it up automatically. * * @internal */ declare abstract class GroupedQueryBase { protected readonly executeRecords: () => readonly unknown[]; protected readonly upstreams: readonly AggregationUpstream[]; /** * Optional dict label resolver attached by the query builder when * the grouping field is a dictKey. Variadic groupings always pass * `undefined` — `Label` projection has no meaningful shape * for composite keys. */ protected readonly dictLabelResolver?: ((key: string, locale: string, fallback?: string | readonly string[]) => Promise) | undefined; /** * The backing collection's compiled Via pipeline — used to rewrite * `sum`/`min`/`max` over Via-covered fields (e.g. money) into exact * BigInt reducers when `.aggregate(spec)` is terminated. */ protected readonly via?: ViaPipeline | undefined; /** * Field set this grouped query buckets on. Stored in declaration * order — the same order is preserved on every result row by * `groupAndReduce`. For the single-field constructor, this is * `[field]`. */ protected readonly fields: readonly string[]; constructor(executeRecords: () => readonly unknown[], fieldOrFields: string | readonly string[], upstreams: readonly AggregationUpstream[], /** * Optional dict label resolver attached by the query builder when * the grouping field is a dictKey. Variadic groupings always pass * `undefined` — `Label` projection has no meaningful shape * for composite keys. */ dictLabelResolver?: ((key: string, locale: string, fallback?: string | readonly string[]) => Promise) | undefined, /** * The backing collection's compiled Via pipeline — used to rewrite * `sum`/`min`/`max` over Via-covered fields (e.g. money) into exact * BigInt reducers when `.aggregate(spec)` is terminated. */ via?: ViaPipeline | undefined); /** Apply Via-aware reducer rewriting (e.g. money) when the source declares one. */ protected wrapSpec(spec: Spec): Spec; } /** * Chainable wrapper returned by `Query.groupBy(field)`. Terminates * with `.aggregate(spec)` which returns a `GroupedAggregation`. * * Kept minimal — the only operation on a grouped query is * aggregation. Ordering, limiting, and further filtering belong on * the underlying `Query` before `.groupBy()` is called; applying * them post-group would be a different operation (`having` / * `groupOrderBy`), out of scope for. */ export declare class GroupedQuery extends GroupedQueryBase { /** * Build a grouped aggregation. Returns a `GroupedAggregation` * with `.run()`, `.runAsync()`, and `.live()` terminals — same shape * as the non-grouped `.aggregate()` wrapper, just with an array * result (one row per bucket) instead of a single reduced object. * * The builder overload `aggregate(b => spec)` types `b` as * `ReducerBuilder`, so field-taking reducers (`sum`, `avg`, * `min`, `max`) refuse any field listed in the collection's * `sensitive` option at compile time, and `sum`/`min`/`max` over a * declared `moneyFields` (`M`) member return a `MoneyString`. The * bare-spec overload is preserved for backward compatibility. */ aggregate(spec: Spec): GroupedAggregation>>; aggregate(build: (b: ReducerBuilder) => Spec): GroupedAggregation>>; } /** * Variadic-keyed sibling of `GroupedQuery`. Constructed by the * multi-arg `Query.groupBy(...fields)` overload. The runtime shape is * identical — only the type-level result-row narrowing differs. */ export declare class GroupedQueryN extends GroupedQueryBase { aggregate(spec: Spec): GroupedAggregation>>; aggregate(build: (b: ReducerBuilder) => Spec): GroupedAggregation>>; } /** * Execute the group-and-reduce pipeline. Pure function over a * record array and a spec — shared by `GroupedAggregation.run()` * and the live-mode refresh path. Exported for tests and for any * future `scan().groupBy().aggregate()` reuse. * * Enforces the cardinality cap incrementally during the partition * loop, so a runaway grouping throws at the moment the 100_001st * bucket would be created — the consumer doesn't have to wait for * the full partition to materialize before the error fires. */ export declare function groupAndReduce(records: readonly unknown[], fieldOrFields: string | readonly string[], spec: AggregateSpec, moneyFields?: Record): R[]; /** * Grouped aggregation wrapper — the `.groupBy(field).aggregate(spec)` * terminal. Shape mirrors `Aggregation` from aggregate.ts: two * terminals (`.run()` and `.live()`), spec bound at construction * time, upstreams collected for live mode. * * The generic `R` is the per-row result shape (i.e. a single * grouped row), and the terminals return `R[]` — one row per * bucket. */ export declare class GroupedAggregation { private readonly executeRecords; private readonly spec; private readonly upstreams; /** * Optional dict label resolver for `Label` projection *. Present when the grouping field is a dictKey. */ private readonly dictLabelResolver?; private readonly fields; constructor(executeRecords: () => readonly unknown[], fields: string | readonly string[], spec: AggregateSpec, upstreams: readonly AggregationUpstream[], /** * Optional dict label resolver for `Label` projection *. Present when the grouping field is a dictKey. */ dictLabelResolver?: ((key: string, locale: string, fallback?: string | readonly string[]) => Promise) | undefined); /** * Execute the query, group, reduce, and return an array of rows. * * `opts` (query-form MV grouping): when a `locale` + `i18nFields` are * given, the declared group-key `i18nText` fields are resolved to that locale * at the `mv` layer BEFORE bucketing — so an i18n group key is a stable string * instead of a raw `{locale}` map. The MV executor passes the MV's * `i18nLocale`/`i18nFields`; ordinary `.run()` callers pass nothing and are * unaffected. */ run(opts?: { locale?: string; i18nFields?: Record; }): R[]; /** * Execute the query, group, reduce, and resolve `Label` for * each result row when the grouping field is a `dictKey` and a * `locale` is provided. Returns `R[]` synchronously when * no locale is specified (identical to `.run()`). * * The `Label` field is appended to each row. Rows whose group * key has no dictionary entry get `Label: undefined`. * * Dict-label resolution is single-field only — multi-key groupings * do not produce a `Label`. The resolver is only attached * by the builder when `fields.length === 1`. */ runAsync(opts?: { locale?: string; fallback?: string | readonly string[]; }): Promise; /** * Build a reactive `LiveAggregation` that re-runs the full * group-and-reduce pipeline whenever any upstream source notifies * of a change. Same error-isolation and idempotent-stop contract * as `Aggregation.live()` — the implementation delegates to the * same `LiveAggregationImpl` class by threading a fresh * recompute closure through the existing constructor. * * uses naive full re-run on every change. Incremental * per-bucket maintenance (apply `step` on inserted records, * `remove` on deleted records, route by bucket key) is a future * optimization — the reducer protocol admits it, but wiring * delta-aware source subscriptions is a separate PR. * * Always call `live.stop()` when finished. */ live(): LiveAggregation; } export {};