/** * Vault-side refs / managed-links enforcement facade. * * Holds the foreign-key enforcement entry points the collection write path * reaches through the `refEnforcer` facade closure (`enforceRefsOnPut` / * `enforceRefsOnDelete`), the managed-link `onDelete` policy * (`enforceLinksOnDelete`), the `joinResolver` half (`resolveRef` / * `resolveSource`), the `checkIntegrity()` reporter, and the cascade-cycle * breaker set (`cascadeInProgress`). The ref/link registries stay * vault-resident (they are populated by `vault.collection()` / `vault.link()` * and read by the backup path) and arrive by reference through * {@link VaultLinksDeps}; every other `Vault` dependency arrives via the deps * interface. * * Internal service — reached through `vault.enforceRefsOnPut(...)` etc. */ import { type RefDescriptor, type RefViolation, type RefRegistry } from '../../kernel/refs.js'; import { type LinkSpec, type LinkSetHandle } from './names.js'; import type { JoinableSource } from '../../kernel/query/index.js'; import type { Collection } from '../../kernel/collection.js'; import type { NoydbStore } from '../../kernel/types.js'; import type { TxContext } from '../../with-commit/tx/transaction.js'; import type { ViaGraph } from '../../kernel/via/graph.js'; import type { NoydbEventEmitter } from '../../kernel/events.js'; /** Everything the moving refs/links methods touched on the vault's `this.*`. */ export interface VaultLinksDeps { /** Per-vault foreign-key registry (vault-resident; read by reference). */ readonly refRegistry: RefRegistry; /** Registered link specs, keyed by link name (vault-resident; read by reference). */ readonly linkRegistry: ReadonlyMap; /** The ciphertext store. */ readonly adapter: NoydbStore; /** Vault namespace name. */ readonly vault: string; /** Collection accessor (bound `vault.collection`). */ collection(name: string): Collection; /** Declared link-set accessor (bound `vault.links`). */ links(name: string): LinkSetHandle; /** Cache lookup for an already-opened collection (used by `resolveSource`). */ getCachedCollection(name: string): Collection | undefined; /** The active transaction context, or null outside a tx. */ getActiveTxContext(): TxContext | null; /** The vault's event emitter — the `lookup:propagation-residue` channel (#654). */ readonly emitter: NoydbEventEmitter; } export declare class VaultLinks { private readonly deps; /** * Set of collection record-ids currently being deleted as part of a cascade. * Populated on entry to `enforceRefsOnDelete` and drained on exit. Used to * break mutual-cascade cycles: deleting A → cascade to B → cascade back to A * would otherwise recurse forever, so we short-circuit when we see an * already-in-progress delete on the same (collection, id) pair. */ private readonly cascadeInProgress; constructor(deps: VaultLinksDeps); /** * Enforce strict outbound refs on a `put()`. Called by Collection * just before it writes to the adapter. For every strict ref * declared on the collection, check that the target id exists in * the target collection; throw `RefIntegrityError` if not. * * `warn` and `cascade` modes don't affect put semantics — they're * enforced at delete time or via `checkIntegrity()`. */ enforceRefsOnPut(collectionName: string, record: unknown): Promise; /** * Enforce inbound ref modes on a `delete()`. Called by Collection * just before it deletes from the adapter. Walks every inbound * ref that targets this (collection, id) and: * * - `strict`: throws if any referencing records exist * - `cascade`: deletes every referencing record * - `warn`: no-op (checkIntegrity picks it up) * * Cascade cycles are broken via `cascadeInProgress` — re-entering * for the same (collection, id) returns immediately so two * mutually-cascading collections don't recurse forever. */ enforceRefsOnDelete(graph: ViaGraph, collectionName: string, id: string): Promise; /** * Find the records in `collection` whose `field` currently equals `key` — the SAME * list()+filter machinery `enforceRefsOnDelete`'s classic ref-restrict/cascade loop above * already uses (bounded by the referencing collection's size, never by the whole vault or by * "which collections reference this" — that part is the graph's O(1) `_out` reverse index). */ private findLookupReferencingRecords; /** * Resolve the VALUE a referencing field actually stores for the row being deleted (`rawKey`, * the backing row's PUT-id) under a given edge's `keyField` — a referencing field always stores * `row[keyField]`, never the PUT-id when the two differ (matrix tier's `lookup(dim, {key})`; * mirrors Task 3's `checkLookupMembership` review fix, Important 1). `keyField === 'id'` * (reserved/static tiers, and the matrix default) needs no row read: `rawKey` IS the value. * Reads the backing row AT MOST ONCE per distinct non-`'id'` `keyField`, even across multiple * edges — `backingCollection` is only `.get()`-able for the matrix tier, which is exactly the * only tier that can ever have `keyField !== 'id'`. */ private resolveLookupCompareKey; /** * Phase 1 (#650 Task 5, #648) — restrict check, never mutates. Throws * `DictKeyInUseError(dimension, key, usedBy, count)` naming the FIRST referencing collection * still holding a matching record, for every `onDelete:'restrict'` edge the graph's O(1) * `referencingEdgesOf(backingCollection)` reverse lookup finds pointing at this dimension. A * dimension with no declared lookup-referencing edges is a no-op — undeclared refs keep * dangling (today's behavior, unaffected). Called by `Vault.forget()` BEFORE any shred (the * spec §4 "restrict refuses before any shred" design decision) — cascade/nullify propagation * for the SAME edges happens separately, after the shred, via `forgetDerivedFanout`. * * Also resolves (and RETURNS) the live compare-key for EVERY referencing edge, restrict AND * cascade/nullify alike (#650 Task 5 review, Important fix): `forgetDerivedFanout`'s cascade/ * nullify propagation runs AFTER `_writeTombstone`, when a non-`'id'` `keyField`'s value can no * longer be read off the (now-shredded) row — resolving it HERE, at the one point every * referencing edge's backing row is still guaranteed live, eliminates that post-shred * dependency. * * #654: a `restrict` edge whose compare-key CANNOT be resolved (the backing row is missing the * `keyField` or holds a non-scalar value — corruption-class rarity) THROWS * `RestrictRefUnresolvableError` naming the unresolvable edge, fail-closed — whether a * referencer exists can't be proven, so the delete/forget is refused rather than silently * allowed through. Non-restrict edges keep `continue`ing on an unresolvable compare-key (their * `undefined` still lands in the returned map so `forgetDerivedFanout`'s cascade/nullify twin * can report it as residue instead of silently skipping — unchanged from before #654). */ checkLookupRefsRestrict(graph: ViaGraph, dimension: string, backingCollection: string, key: string): Promise>; /** * Phase 2 (#650 Task 5, #648) — apply `cascade`/`nullify` propagation for this dimension's * non-restrict referencing edges (restrict edges are skipped — phase 1 above already refused * or the caller already checked). `cascade` deletes each matching record through its ordinary * `collection.delete()` (an origin-tagged, fanout-visible delete — participates in sync/history/ * the graph dispatch wave exactly like any other delete); `nullify` clears the referencing field * via an ordinary `collection.put()`. Returns the counts so callers (forget's `ForgetFanoutStats`) * can report the propagation additively. * * #654: an edge whose compare-key can't be resolved is no longer a bare silent `continue` — it * pushes a `backing:key:collection.field` entry onto `residue` (mirroring * `applyLookupRefsFanout`'s forget-path residue format, `kernel/via/dispatch.ts`) so the caller * (`enforceLookupRefsOnDelete` below) can surface it instead of dropping it on the floor. */ applyLookupRefsPropagation(graph: ViaGraph, backingCollection: string, key: string): Promise<{ cascaded: number; nullified: number; residue: string[]; }>; /** * Convenience: the full restrict-then-propagate sequence for an ORDINARY (non-forget) delete of * a lookup-referenced row — `checkLookupRefsRestrict` then `applyLookupRefsPropagation`. Used by * `enforceRefsOnDelete` above (matrix-tier: the backing row IS an ordinary `Collection` record) * and by `LookupHandle.delete()`'s injected `checkReferencesOnDelete` callback (reserved tier). * * #654: when `applyLookupRefsPropagation` reports residue, it is emitted here as a structured * `lookup:propagation-residue` event — the delete/put return shape both callers consume stays * `void`/unrelated, so an event is the additive channel that keeps the skip from ever being * silent (mirrors the forget path's `ForgetResult.lookupReferencesResidue` channel). */ enforceLookupRefsOnDelete(graph: ViaGraph, dimension: string, backingCollection: string, key: string): Promise<{ cascaded: number; nullified: number; residue: string[]; }>; /** * @internal — apply link `onDelete` policy when an endpoint record is * deleted. `'strict'` throws (blocks the delete), `'cascade'` * removes the touching link rows (tx-atomic when a transaction is active), * `'warn'` leaves orphans for `checkIntegrity()`. */ private enforceLinksOnDelete; /** * Look up the `RefDescriptor` the left collection declared for a * given field name. Returns `null` when the field has no ref * declaration — the Query builder turns that into an actionable * error at plan time (before any records are touched). * * Implements the `joinResolver.resolveRef` half of the structural * interface that `Collection.query()` consumes. See * `query/join.ts` for the full design. */ resolveRef(leftCollection: string, field: string): RefDescriptor | null; /** * Resolve a right-side join source by target collection name. * Returns `null` for unknown collections so the Query executor can * surface an actionable error naming the missing target. * * Implements the `joinResolver.resolveSource` half of the * structural interface. The returned JoinableSource is a thin * wrapper that reads the target collection's in-memory cache via * `list()` / `get()` synchronously — the cache is populated by an * earlier `ensureHydrated()` call through the target's query/list * path. If the target has not been opened yet in this session the * join will see an empty snapshot; consumers who hit this can * open the target collection explicitly before running the query. * * Only same-vault targets are resolvable — cross-vault * joins are explicitly forbidden by the architecture`). */ resolveSource(collectionName: string): JoinableSource | null; /** * Walk every collection that has declared refs, load its records, * and report any reference whose target id is missing. Modes are * reported alongside each violation so the caller can distinguish * "this is a warning the user asked for" from "this should never * have happened" (strict violations produced by out-of-band * writes). * * Returns `{ violations: [...] }` instead of throwing — the whole * point of `checkIntegrity()` is to surface a list for display * or repair, not to fail noisily. */ checkIntegrity(): Promise<{ violations: RefViolation[]; }>; }