import { ColumnBuilder } from '@voltro/database'; import { DataStore } from '@voltro/database'; import { Effect } from 'effect'; import { FieldDefinitions } from '@voltro/database'; import { Table } from '@voltro/database'; import { VoltroPlugin } from '@voltro/protocol'; /** Adapt the framework DataStore to the pure HistoryStore. */ export declare const dataStoreHistoryStore: (store: DataStore) => HistoryStore; /** Field-level delta between two row snapshots (key-order-insensitive). */ export declare const diffSnapshots: (from: Record | null, to: Record | null) => Record; /** Diff two version numbers out of a row's (already tenant-scoped) timeline. * `null` when either version is absent from `rows` — not recorded, pruned, * or hidden from the caller's tenant. */ export declare const diffVersionRows: (rows: ReadonlyArray, fromVersion: number, toVersion: number) => VersionDiff | null; /** Field-level delta between two versions of a row, tenant-scoped like * `rowHistory`. `null` when either version is absent from the caller's * visible timeline (never recorded, pruned, or another tenant's row). */ export declare const diffVersions: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, fromVersion: number, toVersion: number) => Promise; /** Effect-native `diffVersions` (see {@link rowHistoryEffect}). */ export declare const diffVersionsEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, fromVersion: number, toVersion: number) => Effect.Effect; /** One field's before/after in a version diff. */ export declare interface FieldChange { readonly from: unknown; readonly to: unknown; } /** * Every row change made by ONE actor, newest first — the `bySubject` index's * caller. `subjectId` is the CALLER (see the correlation bridge), not the row's * `audit()` stamp, so this covers writes through the boot store that the stamp * never named. * * `limit` is required and defaults to 100 on purpose: an actor's history is * unbounded, and an entry point that returns all of it by default is one that * gets called once in production and never again. */ export declare const historyBySubject: (store: DataStore, subjectId: string, tenantId: string | null | undefined, limit?: number) => Promise>; /** Effect-native `historyBySubject` (see {@link rowHistoryEffect}). */ export declare const historyBySubjectEffect: (store: DataStore, subjectId: string, tenantId: string | null | undefined, limit?: number) => Effect.Effect>; /** * Every row change made by ONE call — the audit join, entered from the trace. * * This is what the `byTrace` index is for, and without it the index had no * caller: `rowHistory` requires you to already know which row you are asking * about, which is exactly the wrong way round during an incident. Pair it with * the `_voltro_audit_log` row carrying the same `traceId` to get "who called, * what they were refused, and what they changed" in two reads. * * Tenant-scoped like `rowHistory`. Pass the caller's `tenantId`; `undefined` * skips the filter and is for system/admin paths only. */ export declare const historyByTrace: (store: DataStore, traceId: string, tenantId: string | null | undefined) => Promise>; /** Effect-native `historyByTrace` (see {@link rowHistoryEffect}). */ export declare const historyByTraceEffect: (store: DataStore, traceId: string, tenantId: string | null | undefined) => Effect.Effect>; /** The store surface versioning needs (adapts the framework DataStore). * `versionsOf`'s `tenantId` filter: pass the caller's tenant on the READ path * so a row's timeline is visible only to its own tenant (a `null`-tenant / * untenanted-source row stays visible to all). `maxVersion` is the write-path * read — it returns ONLY the highest version number recorded for a (table, * rowId), so a new version costs a `MAX(version)` aggregate instead of * materialising the whole timeline on every write (it sees every version, * unfiltered — a rowId is globally unique so all its versions share a tenant). * `prune` drops every version of a (table, rowId) BELOW `keepFrom` — the * per-row max-versions cap's delete path. */ export declare interface HistoryStore { readonly versionsOf: (tableName: string, rowId: string, tenantId?: string | null) => Promise>; readonly maxVersion: (tableName: string, rowId: string) => Promise; readonly append: (row: VersionRow) => Promise; readonly prune: (tableName: string, rowId: string, keepFrom: number) => Promise; } export declare const historyTable: Table<"_voltro_row_history", FieldDefinitions<{ readonly id: ColumnBuilder; readonly tableName: ColumnBuilder; readonly rowId: ColumnBuilder; readonly version: ColumnBuilder; readonly op: ColumnBuilder; readonly data: ColumnBuilder; readonly changedBy: ColumnBuilder; /** * WHO changed it, SNAPSHOTTED — the counterpart to `data` above. * * The inconsistency this closes lives inside ONE row: `data` is a full-row * snapshot, deliberately, so it survives what happens to the source; while * `changedBy` is a reference that does not. One record, two philosophies — * the row's state preserved forever, its author only until someone exercises * a right to be forgotten, which `@voltro/plugin-governance`'s own * `governance.erase` exists to grant. * * `{ id, type, displayName, email }` as of the change. Written by the audit * plugin's resolver when both are installed; `null` otherwise, which is * honest — a fabricated name would be the thing this column prevents. */ readonly actor: ColumnBuilder; /** The app's own scoping dimension — mirrors `_voltro_audit_log.scope`. A * per-team trail needs it on BOTH tables, or half the view filters. */ readonly scope: ColumnBuilder; readonly changedAt: ColumnBuilder; readonly tenantId: ColumnBuilder; readonly traceId: ColumnBuilder; readonly subjectId: ColumnBuilder; readonly procedure: ColumnBuilder; }>, true, "byRow" | "byRowHistoryTrace" | "byRowHistorySubject">; /** In-memory history store (tests + dev). */ export declare const memoryHistoryStore: () => HistoryStore & { all: () => ReadonlyArray; }; /** Next version number given the rows already recorded for a (table, rowId). */ export declare const nextVersionNumber: (existing: ReadonlyArray) => number; /** Record one change event as a new version. Pure orchestration over the store. */ export declare const recordChange: (store: HistoryStore, event: { tableName: string; rowId: string; op: RowOp; data: Record | null; changedBy: string | null; now: number; tenantId: string | null; traceId?: string; subjectId?: string | null; procedure?: string; }, options?: RecordChangeOptions) => Promise; export declare interface RecordChangeOptions { /** Bounded retry budget for the read-MAX-then-insert version race (default 3). * Concurrent same-row writes on two replicas can compute the same version; * the derived unique PK makes the loser's INSERT fail — a retry re-reads * MAX(version) and re-appends instead of dropping the history record. */ readonly attempts?: number; /** Keep at most this many versions per row: after a successful append, * versions older than `newest - maxVersions` are pruned. Unset → no cap. */ readonly maxVersions?: number; } /** Write the row's as-of snapshot back to the LIVE row (`store.update` by pk), * tenant-scoped like `rowAsOf` — a caller only restores what its tenant can * see. Returns the post-image, or `null` (and writes NOTHING) when the row * had no visible state at `at` (absent, deleted then, or cross-tenant). The * restore itself flows through the store, so the tap records it as a NEW * version — history stays append-only, a restore never rewrites the past. */ export declare const restoreAsOf: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Promise | null>; /** Effect-native `restoreAsOf` (see {@link rowHistoryEffect}). */ export declare const restoreAsOfEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Effect.Effect | null>; /** The row's value as of a past instant (`null` if absent/deleted then), * tenant-scoped like `rowHistory`. */ export declare const rowAsOf: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Promise | null>; /** Effect-native `rowAsOf` (see {@link rowHistoryEffect}). */ export declare const rowAsOfEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Effect.Effect | null>; /** Full version history for one row, oldest → newest, SCOPED to the caller's * tenant (pass `ctx.request.subject.tenantId`). A row's timeline is visible * only to its own tenant; `null`-tenant (untenanted-source) rows are visible * to all. Anonymous caller (`null`) sees only untenanted history. */ export declare const rowHistory: (store: DataStore, tableName: string, rowId: string, tenantId: string | null) => Promise>; /** Effect-native `rowHistory` for handlers written in `Effect.gen` — same * tenant scoping, no `Effect.tryPromise` hand-wrap at the call site. */ export declare const rowHistoryEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null) => Effect.Effect>; export declare type RowOp = 'insert' | 'update' | 'delete'; /** The row's value AS OF a timestamp — the latest version at/ before `at`, * or `null` if the row didn't exist yet (or was deleted at that point). */ export declare const selectAsOf: (rows: ReadonlyArray, at: number) => VersionRow | null; /** History for one row, oldest → newest. */ export declare const sortHistory: (rows: ReadonlyArray) => ReadonlyArray; /** Delta between two versions of a row. `changed` maps each field whose value * differs to its before/after pair (fields absent on one side read as `null`). */ export declare interface VersionDiff { readonly fromVersion: number; readonly toVersion: number; readonly changed: Record; } export declare const versioningPlugin: (options: VersioningPluginOptions) => VoltroPlugin; export declare interface VersioningPluginOptions { /** * Tables to version IN ADDITION to the default set — normally a PLUGIN's * table, since those are excluded by default. * * Passed as table VALUES, not names. That is the whole point: the previous * shape took `tables: string[]`, nothing cross-checked the strings, and a * misspelled table silently recorded nothing forever. A value cannot be * misspelled — `tsc` catches it at the call site. */ readonly include?: ReadonlyArray<{ readonly tableName: string; }>; /** * Tables to leave OUT of the default set. Same reasoning: values, not names. * * Reach for it on append-only or high-write tables of your own, where a full * row snapshot per write buys nothing — the table already IS the history. */ readonly exclude?: ReadonlyArray<{ readonly tableName: string; }>; /** * Namespace for this plugin's surface. Default `versioning`. * * Set it when your app already publishes under that name — an exact tag * collision is fatal at codegen, and this is the way out. Orthogonal to * `name` below: `alias` REPLACES the namespace, `name` distinguishes two * installations within it. */ readonly alias?: string; /** * Discriminator for a SECOND installation of this plugin, when one app runs * two (`@voltro/plugin-versioning#analytics`). Not a rename — for that use * `alias`. */ readonly name?: string; /** Keep at most this many versions per row (>= 1): after each recorded * change, versions older than the newest N are pruned. Complements the * time-based retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) — the sweep * bounds AGE, this bounds per-row COUNT. Unset → time-TTL only. */ readonly maxVersionsPerRow?: number; /** * WHEN the history row is written. * * - `'post-commit'` (default) — off the change tap, after the domain write * has committed. Two separate writes. * - `'in-transaction'` — inside the SAME transaction as the domain write. * * **Why the default is not `'in-transaction'`.** Post-commit can lose an * entry: between COMMIT and the forked write there is a window, and a process * that dies inside it leaves the change permanent and the trail silent. * In-transaction closes that — at the price of making this table a hard * dependency of every write path it covers. A failed history insert then * fails the user's mutation, and every covered write holds its locks longer. * Post-commit loses at worst ONE ENTRY; in-transaction can, at worst, stop * writes to the covered tables altogether. For a compliance trail the second * trade is right; for the undo / time-travel use this plugin also serves, it * is not. Two populations, opposite correct defaults — so the default is the * one whose failure stays local. * * **What `'in-transaction'` actually promises**: if the change committed, the * entry is there. That is only obtainable by being willing to REFUSE: when * the history insert fails, either the mutation fails with it, or the error * is swallowed and the change commits without its entry — which is * post-commit's hole with the cost already paid. There is no third option, * so a rare, explained rejection is the shape of the guarantee, not a bug. * * **Two honest limits**, in both timings: * - `store.raw()` produces no change event (the framework does not parse * hand-written SQL), so raw writes are absent from the trail. Enabling * `'in-transaction'` does not make coverage total. * - A write made OUTSIDE a transaction (a bare `store.updateMany`, not a * handler's) is recorded immediately after on the same connection, not * atomically. Framework mutations are auto-transactional, so handler * writes do get the guarantee. */ /** * Derive the app's own scoping dimension from the changed ROW. * * From the row rather than from a request context, because that is what this * plugin has — and because a per-team dimension on a versioned table lives on * the row itself. `(row) => ({ teamId: row.teamId })`. * * Mirrors `auditPlugin`'s option so a per-team trail can filter BOTH tables; * without it on this one, half of every audit view is unfiltered. */ readonly resolveScope?: (row: Record) => unknown; readonly timing?: 'post-commit' | 'in-transaction'; } export declare interface VersionRow { readonly tableName: string; readonly rowId: string; readonly version: number; readonly op: RowOp; /** Full row snapshot AFTER the change (the pre-delete state for a delete). */ readonly data: Record | null; readonly changedBy: string | null; /** Snapshot of who changed it — see `actor` on the history table. */ readonly actor?: unknown; /** The app's own scoping dimension — see `scope` on the history table. */ readonly scope?: unknown; /** epoch ms */ readonly changedAt: number; /** The source row's tenant (from the change event), or `null` for an * untenanted source table. Reads are scoped to it so one tenant can't read * another's value timeline. */ readonly tenantId: string | null; /** The rpc tag of the call that caused this version * (`teams.removeSubTeamMember`), or `null` for a write with no procedure * behind it (seed, startup, migration, schedule). * * `traceId` says which CALL; this says which call it WAS. A row diff carries * no intent — the same delete on a join table is a member removal, a cascade * or an expiry — so without the tag a history UI can show what changed and * never what happened. */ readonly procedure?: string | null; /** The trace the write happened under (`ChangeEvent.traceId`) — the join key * to the audit sink's row for the SAME call. Absent for a write with no * request behind it (a seed, a schedule) or one injected from a replica. */ readonly traceId?: string | null; /** The CALLER (`ChangeEvent.subjectId`), which is not the same claim as * `changedBy`: that falls back to the row's own `audit()` stamp, which is * null for every write through the boot store. */ readonly subjectId?: string | null; } export { }