/** * Query DSL `.join()` — eager, single-FK, intra-vault joins. * * resolves a ref()-declared foreign key into an attached * right-side record under an alias, using one of two planner paths * selected automatically: * * - **nested-loop** — right-side source exposes `lookupById`, so * each left row costs O(1). This is the common path for joins * against a Collection, which backs `lookupById` with a Map * lookup. * - **hash** — right-side has only `snapshot()`. Build a * `Map` once, probe per left row. Same asymptotic * cost for our collections, but the path exists as a fallback * for custom QuerySource implementations and as an explicit * test-only override via `{ strategy: 'hash' }`. * * Scope: * * - Equi-joins on declared `ref()` fields only. Joins on * undeclared fields throw at plan time with an actionable error * naming the field and collection. * - Same-vault only. Cross-vault correlation goes * through `queryAcross`; this is an architectural * invariant, not a limitation we plan to lift. * - Hard row ceiling via `JoinTooLargeError` — default 50k per * side, override via `{ maxRows }`. Warns at 80% of the ceiling * on the existing warn channel. * - Three ref-mode behaviors on dangling refs: * strict → `DanglingReferenceError`, * warn → attach `null` with a one-shot warning, * cascade → attach `null` silently (cascade is a delete-time * mode; any dangling refs still present at read time are * mid-flight cascades or orphans from earlier, not a DSL error). * * Partition-awareness seam: * * Every `JoinLeg` carries a `partitionScope` field that is always * `'all'` in. The executor never reads this field. * partition-aware joins will start populating it from `where()` * predicates on the partition key without changing the planner's * external shape — this is the whole reason it exists now. * * Joins stay OUT of the ledger: reads don't touch `_ledger/`, * including joined reads. */ import type { RefDescriptor, RefMode } from '../refs.js'; /** Planner strategy for a single join leg. Auto-selected unless overridden. */ export type JoinStrategy = 'hash' | 'nested'; /** Default per-side row ceiling before `.join()` throws `JoinTooLargeError`. */ export declare const DEFAULT_JOIN_MAX_ROWS = 50000; /** * Internal representation of a single join leg in the query plan. * * This is the primary place where constraint #1 is honored: * every leg carries a `partitionScope` field that is always `'all'` * in and is never read by the executor. partition-aware * joins will start populating it from `where()` predicates on the * partition key without changing the planner's external shape. */ export interface JoinLeg { /** Field on the left-side record holding the foreign key value. */ readonly field: string; /** Alias key under which the joined right-side record attaches. */ readonly as: string; /** Target collection name, resolved from the `ref()` declaration. */ readonly target: string; /** Ref mode controlling behavior on dangling refs at read time. */ readonly mode: RefMode; /** Manual planner strategy override. `undefined` → auto-select. */ readonly strategy: JoinStrategy | undefined; /** Per-side row ceiling override. `undefined` → DEFAULT_JOIN_MAX_ROWS. */ readonly maxRows: number | undefined; /** * Partition scope for future partition-aware joins. Always `'all'` * today — the executor never reads this field. Future versions will * populate it from `where()` predicates without breaking the * planner's external shape. Do not remove even though it looks * unused today — that's the whole point of having it. */ readonly partitionScope: 'all' | readonly string[]; /** * When `true`, this is a dictionary join. The executor * resolves the left-field value against the dict snapshot and * attaches `{ ...labels, key }` rather than a right-side record. * `target` holds the dictionary name (not a collection name). */ readonly isDictJoin?: true; } /** * Minimal shape of a joinable right-side record source. * * Collections implement this structurally via their `QuerySource`; * sources without `lookupById` force the hash-join fallback. Kept as * a thin interface so tests can wire up plain-object sources without * pulling in the full Collection class. * * The optional `subscribe` is used by `Query.live()` to merge * right-side change streams into the live re-run trigger. Sources * that omit `subscribe` still work for live joins — they just * don't drive re-fires when their right side mutates. Collection * implements `subscribe` by hooking into the existing per- * vault event emitter. */ export interface JoinableSource { snapshot(): readonly unknown[]; lookupById?(id: string): unknown; /** * Default locale a label-resolving query falls back to when the query * itself is locale-less. Set by a `staticDict()`-backed source from its * `displayLocale` so `{ by: 'label' }` resolves under a locale-less read. * Plain `_dict_*`-backed sources omit it. */ readonly displayLocale?: string; /** * Sync present-for-join dressing (#626 retirement, #650 Task 6) — when * present and the query carries a locale, each joined right-side record * is passed through this hook (built by the right-side `Collection` from * its own i18n-text + lookup-label bindings) BEFORE it is attached under * the leg's alias — so a joined `i18nText` field resolves to a string * (not a raw `{ locale }` map) and a joined lookup field gains its * `Label`. Locale-less queries leave joined fields raw * (consistent with a locale-less read). Replaces the old `i18nFields` * data field — the join executor no longer resolves i18n locale itself, * it just calls this hook. */ readonly presentForJoin?: (record: unknown, locale: string) => unknown; /** * Subscribe to mutations on this source. The callback fires * AFTER the underlying record set has been updated. Returns an * unsubscribe function. Optional — sources without this method * cannot trigger live-join re-fires from their side. */ subscribe?(cb: () => void): () => void; } /** * Join resolution context attached to a `Query` when it's constructed * from a `Collection`. Holds everything the `.join()` method needs to * translate a field name into a target collection + ref mode, and * everything the executor needs to read the right side. * * Kept as a structural interface so `Vault` can implement it * without `Query` needing to import `Vault` (circular-import * avoid). The Collection wires this up in its `query()` method using * the `joinResolver` back-reference the Vault passes in. */ export interface JoinContext { /** Name of the left-side (owning) collection. */ readonly leftCollection: string; /** * The owning collection's default locale. Used to resolve joined * i18n fields at the `join` layer when a terminal call doesn't pass an * explicit locale — so `openVault({ locale })` flows to joins like it does * to `get`/`list`. A per-call `toArray({ locale })` overrides it. */ readonly defaultLocale?: string; /** Look up a `RefDescriptor` by field name on the left collection. */ resolveRef(field: string): RefDescriptor | null; /** Resolve a right-side source by target collection name. */ resolveSource(collectionName: string): JoinableSource | null; /** * Resolve a dictKey join source. Returns a `JoinableSource` * whose snapshot exposes `{ key, ...labels }` records, keyed by the * stable dictionary key. `null` when the field is not a dictKey. * * The source is built from the compartment's in-memory dictionary * snapshot — same data as `DictionaryHandle.list()`, O(1) per lookup. */ resolveDictSource?(field: string): JoinableSource | null; } /** * Apply every join leg in the plan against a base set of left-side * rows. Called by the query executor after `where` / `orderBy` / * `offset` / `limit` have narrowed the left set. * * Each leg attaches a `leg.as` field to every row. Returns a new * array of plain objects — the original left rows are not mutated * (structural sharing is fine for the inner fields, but the * top-level object is a fresh clone so consumers can further mutate * safely). * * **Ordering:** joins run AFTER orderBy / limit / offset in v1. * This keeps the planner simple and means queries like "top 10 * invoices with client" sort and paginate the left side first, then * join. Sorting *by* a joined field is out of scope for — users * can post-sort the result array in userland or wait for * (multi-FK chaining) which can be layered on top. * * **Multi-FK chaining:** each leg's `maxRows` is enforced * against the current left-row count independently. Because * joins are equi-joins on the target's primary key (one-to-one or * one-to-null), the left row count is constant across legs — no * cartesian blowup. The per-leg left-side check is still necessary * so that a later leg with a tighter ceiling correctly fires on a * query like `.join('a', { maxRows: 100_000 }).join('b', { maxRows: 50 })`, * which should throw on the second leg if the left set exceeds 50. */ export declare function applyJoins(rows: readonly unknown[], joins: readonly JoinLeg[], context: JoinContext, locale?: string): unknown[]; /** * Test-only: reset the join warning deduplication state between * tests. Production code never calls this — the dedup state is * intentionally process-scoped so a noisy query doesn't spam the * console once per component render. */ export declare function resetJoinWarnings(): void;