@voltro/plugin-row-history 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ import { ColumnBuilder } from '@voltro/database';
2
+ import { DataStore } from '@voltro/database';
3
+ import { Effect } from 'effect';
4
+ import { FieldDefinitions } from '@voltro/database';
5
+ import { Table } from '@voltro/database';
6
+ import { VoltroPlugin } from '@voltro/protocol';
7
+
8
+ /** Adapt the framework DataStore to the pure HistoryStore. */
9
+ export declare const dataStoreHistoryStore: (store: DataStore) => HistoryStore;
10
+
11
+ /** Field-level delta between two row snapshots (key-order-insensitive). */
12
+ export declare const diffSnapshots: (from: Record<string, unknown> | null, to: Record<string, unknown> | null) => Record<string, FieldChange>;
13
+
14
+ /** Diff two version numbers out of a row's (already tenant-scoped) timeline.
15
+ * `null` when either version is absent from `rows` — not recorded, pruned,
16
+ * or hidden from the caller's tenant. */
17
+ export declare const diffVersionRows: (rows: ReadonlyArray<VersionRow>, fromVersion: number, toVersion: number) => VersionDiff | null;
18
+
19
+ /** Field-level delta between two versions of a row, tenant-scoped like
20
+ * `rowHistory`. `null` when either version is absent from the caller's
21
+ * visible timeline (never recorded, pruned, or another tenant's row). */
22
+ export declare const diffVersions: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, fromVersion: number, toVersion: number) => Promise<VersionDiff | null>;
23
+
24
+ /** Effect-native `diffVersions` (see {@link rowHistoryEffect}). */
25
+ export declare const diffVersionsEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, fromVersion: number, toVersion: number) => Effect.Effect<VersionDiff | null>;
26
+
27
+ /** One field's before/after in a version diff. */
28
+ export declare interface FieldChange {
29
+ readonly from: unknown;
30
+ readonly to: unknown;
31
+ }
32
+
33
+ /**
34
+ * Every row change made by ONE actor, newest first — the `bySubject` index's
35
+ * caller. `subjectId` is the CALLER (see the correlation bridge), not the row's
36
+ * `audit()` stamp, so this covers writes through the boot store that the stamp
37
+ * never named.
38
+ *
39
+ * `limit` is required and defaults to 100 on purpose: an actor's history is
40
+ * unbounded, and an entry point that returns all of it by default is one that
41
+ * gets called once in production and never again.
42
+ */
43
+ export declare const historyBySubject: (store: DataStore, subjectId: string, tenantId: string | null | undefined, limit?: number) => Promise<ReadonlyArray<VersionRow>>;
44
+
45
+ /** Effect-native `historyBySubject` (see {@link rowHistoryEffect}). */
46
+ export declare const historyBySubjectEffect: (store: DataStore, subjectId: string, tenantId: string | null | undefined, limit?: number) => Effect.Effect<ReadonlyArray<VersionRow>>;
47
+
48
+ /**
49
+ * Every row change made by ONE call — the audit join, entered from the trace.
50
+ *
51
+ * This is what the `byTrace` index is for, and without it the index had no
52
+ * caller: `rowHistory` requires you to already know which row you are asking
53
+ * about, which is exactly the wrong way round during an incident. Pair it with
54
+ * the `_voltro_audit_log` row carrying the same `traceId` to get "who called,
55
+ * what they were refused, and what they changed" in two reads.
56
+ *
57
+ * Tenant-scoped like `rowHistory`. Pass the caller's `tenantId`; `undefined`
58
+ * skips the filter and is for system/admin paths only.
59
+ */
60
+ export declare const historyByTrace: (store: DataStore, traceId: string, tenantId: string | null | undefined) => Promise<ReadonlyArray<VersionRow>>;
61
+
62
+ /** Effect-native `historyByTrace` (see {@link rowHistoryEffect}). */
63
+ export declare const historyByTraceEffect: (store: DataStore, traceId: string, tenantId: string | null | undefined) => Effect.Effect<ReadonlyArray<VersionRow>>;
64
+
65
+ /** The store surface versioning needs (adapts the framework DataStore).
66
+ * `versionsOf`'s `tenantId` filter: pass the caller's tenant on the READ path
67
+ * so a row's timeline is visible only to its own tenant (a `null`-tenant /
68
+ * untenanted-source row stays visible to all). `maxVersion` is the write-path
69
+ * read — it returns ONLY the highest version number recorded for a (table,
70
+ * rowId), so a new version costs a `MAX(version)` aggregate instead of
71
+ * materialising the whole timeline on every write (it sees every version,
72
+ * unfiltered — a rowId is globally unique so all its versions share a tenant).
73
+ * `prune` drops every version of a (table, rowId) BELOW `keepFrom` — the
74
+ * per-row max-versions cap's delete path. */
75
+ export declare interface HistoryStore {
76
+ readonly versionsOf: (tableName: string, rowId: string, tenantId?: string | null) => Promise<ReadonlyArray<VersionRow>>;
77
+ readonly maxVersion: (tableName: string, rowId: string) => Promise<number>;
78
+ readonly append: (row: VersionRow) => Promise<void>;
79
+ readonly prune: (tableName: string, rowId: string, keepFrom: number) => Promise<void>;
80
+ }
81
+
82
+ export declare const historyTable: Table<"_voltro_row_history", FieldDefinitions<{
83
+ readonly id: ColumnBuilder<string, "id", boolean>;
84
+ readonly tableName: ColumnBuilder<string, "text", boolean>;
85
+ readonly rowId: ColumnBuilder<string, "text", boolean>;
86
+ readonly version: ColumnBuilder<number, "integer", boolean>;
87
+ readonly op: ColumnBuilder<string, "text", boolean>;
88
+ readonly data: ColumnBuilder<unknown, "json", boolean>;
89
+ readonly changedBy: ColumnBuilder<string | null, "text", boolean>;
90
+ /**
91
+ * WHO changed it, SNAPSHOTTED — the counterpart to `data` above.
92
+ *
93
+ * The inconsistency this closes lives inside ONE row: `data` is a full-row
94
+ * snapshot, deliberately, so it survives what happens to the source; while
95
+ * `changedBy` is a reference that does not. One record, two philosophies —
96
+ * the row's state preserved forever, its author only until someone exercises
97
+ * a right to be forgotten, which `@voltro/plugin-governance`'s own
98
+ * `governance.erase` exists to grant.
99
+ *
100
+ * `{ id, type, displayName, email }` as of the change. Written by the audit
101
+ * plugin's resolver when both are installed; `null` otherwise, which is
102
+ * honest — a fabricated name would be the thing this column prevents.
103
+ */
104
+ readonly actor: ColumnBuilder<unknown, "json", boolean>;
105
+ /** The app's own scoping dimension — mirrors `_voltro_audit_log.scope`. A
106
+ * per-team trail needs it on BOTH tables, or half the view filters. */
107
+ readonly scope: ColumnBuilder<unknown, "json", boolean>;
108
+ readonly changedAt: ColumnBuilder<Date, "timestamp", true>;
109
+ readonly tenantId: ColumnBuilder<string | null, "text", boolean>;
110
+ readonly traceId: ColumnBuilder<string | null, "text", boolean>;
111
+ readonly subjectId: ColumnBuilder<string | null, "text", boolean>;
112
+ readonly procedure: ColumnBuilder<string | null, "text", boolean>;
113
+ }>, true, "byRow" | "byRowHistoryTrace" | "byRowHistorySubject">;
114
+
115
+ /** In-memory history store (tests + dev). */
116
+ export declare const memoryHistoryStore: () => HistoryStore & {
117
+ all: () => ReadonlyArray<VersionRow>;
118
+ };
119
+
120
+ /** Next version number given the rows already recorded for a (table, rowId). */
121
+ export declare const nextVersionNumber: (existing: ReadonlyArray<VersionRow>) => number;
122
+
123
+ /** Record one change event as a new version. Pure orchestration over the store. */
124
+ export declare const recordChange: (store: HistoryStore, event: {
125
+ tableName: string;
126
+ rowId: string;
127
+ op: RowOp;
128
+ data: Record<string, unknown> | null;
129
+ changedBy: string | null;
130
+ now: number;
131
+ tenantId: string | null;
132
+ traceId?: string;
133
+ subjectId?: string | null;
134
+ procedure?: string;
135
+ }, options?: RecordChangeOptions) => Promise<VersionRow>;
136
+
137
+ export declare interface RecordChangeOptions {
138
+ /** Bounded retry budget for the read-MAX-then-insert version race (default 3).
139
+ * Concurrent same-row writes on two replicas can compute the same version;
140
+ * the derived unique PK makes the loser's INSERT fail — a retry re-reads
141
+ * MAX(version) and re-appends instead of dropping the history record. */
142
+ readonly attempts?: number;
143
+ /** Keep at most this many versions per row: after a successful append,
144
+ * versions older than `newest - maxVersions` are pruned. Unset → no cap. */
145
+ readonly maxVersions?: number;
146
+ }
147
+
148
+ /** Write the row's as-of snapshot back to the LIVE row (`store.update` by pk),
149
+ * tenant-scoped like `rowAsOf` — a caller only restores what its tenant can
150
+ * see. Returns the post-image, or `null` (and writes NOTHING) when the row
151
+ * had no visible state at `at` (absent, deleted then, or cross-tenant). The
152
+ * restore itself flows through the store, so the tap records it as a NEW
153
+ * version — history stays append-only, a restore never rewrites the past. */
154
+ export declare const restoreAsOf: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Promise<Record<string, unknown> | null>;
155
+
156
+ /** Effect-native `restoreAsOf` (see {@link rowHistoryEffect}). */
157
+ export declare const restoreAsOfEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Effect.Effect<Record<string, unknown> | null>;
158
+
159
+ /** The row's value as of a past instant (`null` if absent/deleted then),
160
+ * tenant-scoped like `rowHistory`. */
161
+ export declare const rowAsOf: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Promise<Record<string, unknown> | null>;
162
+
163
+ /** Effect-native `rowAsOf` (see {@link rowHistoryEffect}). */
164
+ export declare const rowAsOfEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null, at: Date | number) => Effect.Effect<Record<string, unknown> | null>;
165
+
166
+ /** Full version history for one row, oldest → newest, SCOPED to the caller's
167
+ * tenant (pass `ctx.request.subject.tenantId`). A row's timeline is visible
168
+ * only to its own tenant; `null`-tenant (untenanted-source) rows are visible
169
+ * to all. Anonymous caller (`null`) sees only untenanted history. */
170
+ export declare const rowHistory: (store: DataStore, tableName: string, rowId: string, tenantId: string | null) => Promise<ReadonlyArray<VersionRow>>;
171
+
172
+ /** Effect-native `rowHistory` for handlers written in `Effect.gen` — same
173
+ * tenant scoping, no `Effect.tryPromise` hand-wrap at the call site. */
174
+ export declare const rowHistoryEffect: (store: DataStore, tableName: string, rowId: string, tenantId: string | null) => Effect.Effect<ReadonlyArray<VersionRow>>;
175
+
176
+ export declare const rowHistoryPlugin: (options: RowHistoryPluginOptions) => VoltroPlugin;
177
+
178
+ export declare interface RowHistoryPluginOptions {
179
+ /**
180
+ * Tables to version IN ADDITION to the default set — normally a PLUGIN's
181
+ * table, since those are excluded by default.
182
+ *
183
+ * Passed as table VALUES, not names. That is the whole point: the previous
184
+ * shape took `tables: string[]`, nothing cross-checked the strings, and a
185
+ * misspelled table silently recorded nothing forever. A value cannot be
186
+ * misspelled — `tsc` catches it at the call site.
187
+ */
188
+ readonly include?: ReadonlyArray<{
189
+ readonly tableName: string;
190
+ }>;
191
+ /**
192
+ * Tables to leave OUT of the default set. Same reasoning: values, not names.
193
+ *
194
+ * Reach for it on append-only or high-write tables of your own, where a full
195
+ * row snapshot per write buys nothing — the table already IS the history.
196
+ */
197
+ readonly exclude?: ReadonlyArray<{
198
+ readonly tableName: string;
199
+ }>;
200
+ /**
201
+ * Namespace for this plugin's surface. Default `row-history`.
202
+ *
203
+ * Set it when your app already publishes under that name — an exact tag
204
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
205
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
206
+ * installations within it.
207
+ */
208
+ readonly alias?: string;
209
+ /**
210
+ * Discriminator for a SECOND installation of this plugin, when one app runs
211
+ * two (`@voltro/plugin-row-history#analytics`). Not a rename — for that use
212
+ * `alias`.
213
+ */
214
+ readonly name?: string;
215
+ /** Keep at most this many versions per row (>= 1): after each recorded
216
+ * change, versions older than the newest N are pruned. Complements the
217
+ * time-based retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) — the sweep
218
+ * bounds AGE, this bounds per-row COUNT. Unset → time-TTL only. */
219
+ readonly maxVersionsPerRow?: number;
220
+ /**
221
+ * WHEN the history row is written.
222
+ *
223
+ * - `'post-commit'` (default) — off the change tap, after the domain write
224
+ * has committed. Two separate writes.
225
+ * - `'in-transaction'` — inside the SAME transaction as the domain write.
226
+ *
227
+ * **Why the default is not `'in-transaction'`.** Post-commit can lose an
228
+ * entry: between COMMIT and the forked write there is a window, and a process
229
+ * that dies inside it leaves the change permanent and the trail silent.
230
+ * In-transaction closes that — at the price of making this table a hard
231
+ * dependency of every write path it covers. A failed history insert then
232
+ * fails the user's mutation, and every covered write holds its locks longer.
233
+ * Post-commit loses at worst ONE ENTRY; in-transaction can, at worst, stop
234
+ * writes to the covered tables altogether. For a compliance trail the second
235
+ * trade is right; for the undo / time-travel use this plugin also serves, it
236
+ * is not. Two populations, opposite correct defaults — so the default is the
237
+ * one whose failure stays local.
238
+ *
239
+ * **What `'in-transaction'` actually promises**: if the change committed, the
240
+ * entry is there. That is only obtainable by being willing to REFUSE: when
241
+ * the history insert fails, either the mutation fails with it, or the error
242
+ * is swallowed and the change commits without its entry — which is
243
+ * post-commit's hole with the cost already paid. There is no third option,
244
+ * so a rare, explained rejection is the shape of the guarantee, not a bug.
245
+ *
246
+ * **Two honest limits**, in both timings:
247
+ * - `store.raw()` produces no change event (the framework does not parse
248
+ * hand-written SQL), so raw writes are absent from the trail. Enabling
249
+ * `'in-transaction'` does not make coverage total.
250
+ * - A write made OUTSIDE a transaction (a bare `store.updateMany`, not a
251
+ * handler's) is recorded immediately after on the same connection, not
252
+ * atomically. Framework mutations are auto-transactional, so handler
253
+ * writes do get the guarantee.
254
+ */
255
+ /**
256
+ * Derive the app's own scoping dimension from the changed ROW.
257
+ *
258
+ * From the row rather than from a request context, because that is what this
259
+ * plugin has — and because a per-team dimension on a versioned table lives on
260
+ * the row itself. `(row) => ({ teamId: row.teamId })`.
261
+ *
262
+ * Mirrors `auditPlugin`'s option so a per-team trail can filter BOTH tables;
263
+ * without it on this one, half of every audit view is unfiltered.
264
+ */
265
+ readonly resolveScope?: (row: Record<string, unknown>) => unknown;
266
+ readonly timing?: 'post-commit' | 'in-transaction';
267
+ }
268
+
269
+ export declare type RowOp = 'insert' | 'update' | 'delete';
270
+
271
+ /** The row's value AS OF a timestamp — the latest version at/ before `at`,
272
+ * or `null` if the row didn't exist yet (or was deleted at that point). */
273
+ export declare const selectAsOf: (rows: ReadonlyArray<VersionRow>, at: number) => VersionRow | null;
274
+
275
+ /** History for one row, oldest → newest. */
276
+ export declare const sortHistory: (rows: ReadonlyArray<VersionRow>) => ReadonlyArray<VersionRow>;
277
+
278
+ /** Delta between two versions of a row. `changed` maps each field whose value
279
+ * differs to its before/after pair (fields absent on one side read as `null`). */
280
+ export declare interface VersionDiff {
281
+ readonly fromVersion: number;
282
+ readonly toVersion: number;
283
+ readonly changed: Record<string, FieldChange>;
284
+ }
285
+
286
+ export declare interface VersionRow {
287
+ readonly tableName: string;
288
+ readonly rowId: string;
289
+ readonly version: number;
290
+ readonly op: RowOp;
291
+ /** Full row snapshot AFTER the change (the pre-delete state for a delete). */
292
+ readonly data: Record<string, unknown> | null;
293
+ readonly changedBy: string | null;
294
+ /** Snapshot of who changed it — see `actor` on the history table. */
295
+ readonly actor?: unknown;
296
+ /** The app's own scoping dimension — see `scope` on the history table. */
297
+ readonly scope?: unknown;
298
+ /** epoch ms */
299
+ readonly changedAt: number;
300
+ /** The source row's tenant (from the change event), or `null` for an
301
+ * untenanted source table. Reads are scoped to it so one tenant can't read
302
+ * another's value timeline. */
303
+ readonly tenantId: string | null;
304
+ /** The rpc tag of the call that caused this version
305
+ * (`teams.removeSubTeamMember`), or `null` for a write with no procedure
306
+ * behind it (seed, startup, migration, schedule).
307
+ *
308
+ * `traceId` says which CALL; this says which call it WAS. A row diff carries
309
+ * no intent — the same delete on a join table is a member removal, a cascade
310
+ * or an expiry — so without the tag a history UI can show what changed and
311
+ * never what happened. */
312
+ readonly procedure?: string | null;
313
+ /** The trace the write happened under (`ChangeEvent.traceId`) — the join key
314
+ * to the audit sink's row for the SAME call. Absent for a write with no
315
+ * request behind it (a seed, a schedule) or one injected from a replica. */
316
+ readonly traceId?: string | null;
317
+ /** The CALLER (`ChangeEvent.subjectId`), which is not the same claim as
318
+ * `changedBy`: that falls back to the row's own `audit()` stamp, which is
319
+ * null for every write through the boot store. */
320
+ readonly subjectId?: string | null;
321
+ }
322
+
323
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,255 @@
1
+ import { Effect as e } from "effect";
2
+ import { allRegisteredTables as t, and as n, derivedRowId as r, eq as i, getTable as a, id as o, integer as s, isFrameworkOwnedLiveTable as c, isNull as l, json as u, lt as d, max as f, or as p, queryFor as m, registerRetention as h, registerWriteRecorder as g, resolveActorSnapshot as _, retentionTtlMsFromEnv as v, serverOnlyColumns as y, table as b, text as x, timestamp as S } from "@voltro/database";
3
+ import { definePlugin as C, pluginInstanceName as w, tsMs as T } from "@voltro/protocol";
4
+ //#region src/history.ts
5
+ var E = (e) => e.reduce((e, t) => Math.max(e, t.version), 0) + 1, D = (e, t) => {
6
+ let n = null;
7
+ for (let r of e) r.changedAt <= t && (n === null || r.version > n.version) && (n = r);
8
+ return n === null || n.op === "delete" ? null : n;
9
+ }, O = (e) => [...e].sort((e, t) => e.version - t.version), k = (e, t) => t === void 0 || e === null || e === t, A = () => {
10
+ let e = [];
11
+ return {
12
+ versionsOf: async (t, n, r) => e.filter((e) => e.tableName === t && e.rowId === n && k(e.tenantId, r)),
13
+ maxVersion: async (t, n) => e.reduce((e, r) => r.tableName === t && r.rowId === n ? Math.max(e, r.version) : e, 0),
14
+ append: async (t) => {
15
+ e.push(t);
16
+ },
17
+ prune: async (t, n, r) => {
18
+ for (let i = e.length - 1; i >= 0; i--) {
19
+ let a = e[i];
20
+ a.tableName === t && a.rowId === n && a.version < r && e.splice(i, 1);
21
+ }
22
+ },
23
+ all: () => [...e]
24
+ };
25
+ }, j = async (e, t, n) => {
26
+ let r = Math.max(1, n?.attempts ?? 3), i;
27
+ for (let a = 0; a < r; a++) {
28
+ let r = {
29
+ tableName: t.tableName,
30
+ rowId: t.rowId,
31
+ version: await e.maxVersion(t.tableName, t.rowId) + 1,
32
+ op: t.op,
33
+ data: t.data,
34
+ changedBy: t.changedBy,
35
+ actor: t.actor ?? null,
36
+ scope: t.scope ?? null,
37
+ changedAt: t.now,
38
+ tenantId: t.tenantId,
39
+ ...t.traceId === void 0 ? {} : { traceId: t.traceId },
40
+ ...t.subjectId === void 0 ? {} : { subjectId: t.subjectId },
41
+ ...t.procedure === void 0 ? {} : { procedure: t.procedure }
42
+ };
43
+ try {
44
+ await e.append(r);
45
+ } catch (e) {
46
+ i = e;
47
+ continue;
48
+ }
49
+ return n?.maxVersions !== void 0 && r.version > n.maxVersions && await e.prune(r.tableName, r.rowId, r.version - n.maxVersions + 1), r;
50
+ }
51
+ throw i;
52
+ }, M = (e, t) => {
53
+ if (Object.is(e, t)) return !0;
54
+ if (typeof e != "object" || typeof t != "object" || e === null || t === null || Array.isArray(e) !== Array.isArray(t)) return !1;
55
+ let n = Object.keys(e), r = Object.keys(t);
56
+ return n.length === r.length && n.every((n) => Object.hasOwn(t, n) && M(e[n], t[n]));
57
+ }, N = (e, t) => {
58
+ let n = {};
59
+ for (let r of /* @__PURE__ */ new Set([...Object.keys(e ?? {}), ...Object.keys(t ?? {})])) {
60
+ let i = e?.[r] ?? null, a = t?.[r] ?? null;
61
+ M(i, a) || (n[r] = {
62
+ from: i,
63
+ to: a
64
+ });
65
+ }
66
+ return n;
67
+ }, P = (e, t, n) => {
68
+ let r = e.find((e) => e.version === t), i = e.find((e) => e.version === n);
69
+ return r === void 0 || i === void 0 ? null : {
70
+ fromVersion: t,
71
+ toVersion: n,
72
+ changed: N(r.data, i.data)
73
+ };
74
+ }, F = "_voltro_row_history", I = b(F, {
75
+ id: o({ prefix: "rowver" }),
76
+ tableName: x(),
77
+ rowId: x(),
78
+ version: s(),
79
+ op: x(),
80
+ data: u().nullable(),
81
+ changedBy: x().nullable(),
82
+ actor: u().nullable(),
83
+ scope: u().nullable(),
84
+ changedAt: S().default("now"),
85
+ tenantId: x().nullable(),
86
+ traceId: x().nullable(),
87
+ subjectId: x().nullable(),
88
+ procedure: x().nullable()
89
+ }).index("byRow", [
90
+ "tableName",
91
+ "rowId",
92
+ "version"
93
+ ]).index("byRowHistoryTrace", ["traceId"]).index("byRowHistorySubject", ["subjectId", "changedAt"]), L = (e) => e?.tenantId ?? null, R = (e, t, r) => {
94
+ let a = n(i("tableName", e), i("rowId", t));
95
+ return r === void 0 ? a : r === null ? n(a, l("tenantId")) : n(a, p(l("tenantId"), i("tenantId", r)));
96
+ }, z = (e) => {
97
+ if (e !== void 0) return e === null ? l("tenantId") : p(l("tenantId"), i("tenantId", e));
98
+ }, B = (e) => ({
99
+ tableName: String(e.tableName),
100
+ rowId: String(e.rowId),
101
+ version: Number(e.version),
102
+ op: String(e.op),
103
+ data: e.data ?? null,
104
+ changedBy: e.changedBy ?? null,
105
+ changedAt: T(e.changedAt),
106
+ tenantId: e.tenantId ?? null,
107
+ traceId: e.traceId ?? null,
108
+ subjectId: e.subjectId ?? null,
109
+ procedure: e.procedure ?? null
110
+ }), V = (e) => ({
111
+ versionsOf: async (t, n, r) => (await e.query(m(I).where(R(t, n, r)).descriptor)).map(B),
112
+ maxVersion: async (t, r) => {
113
+ let a = await e.query(m(I).where(n(i("tableName", t), i("rowId", r))).aggregate({ max: f("version") }).descriptor);
114
+ return Number(a[0]?.max ?? 0);
115
+ },
116
+ append: async (t) => {
117
+ let { changedAt: n, ...i } = t;
118
+ await e.insert(F, {
119
+ ...i,
120
+ id: r("rowver", t.tableName, t.rowId, t.version),
121
+ changedAt: new Date(n),
122
+ traceId: t.traceId ?? null,
123
+ subjectId: t.subjectId ?? null
124
+ });
125
+ },
126
+ prune: async (t, r, a) => {
127
+ await e.deleteMany(F, { where: n(i("tableName", t), i("rowId", r), d("version", a)) });
128
+ }
129
+ }), H = (e) => e?.updatedBy ?? e?.createdBy ?? e?.deletedBy ?? null, U = (e, t) => {
130
+ if (t === null) return null;
131
+ let n = a(e);
132
+ if (n === void 0) return t;
133
+ let r = y(n);
134
+ if (r.length === 0) return t;
135
+ let i = {}, o = [];
136
+ for (let [e, n] of Object.entries(t)) {
137
+ if (r.includes(e)) {
138
+ o.push(e);
139
+ continue;
140
+ }
141
+ i[e] = n;
142
+ }
143
+ return o.length > 0 && (i._omitted = o), i;
144
+ }, W = (e) => {
145
+ let t = e.new ?? e.old;
146
+ return t && t.id != null ? String(t.id) : null;
147
+ }, G = "@voltro/plugin-row-history", K = (n) => {
148
+ if (n.maxVersionsPerRow !== void 0 && (!Number.isInteger(n.maxVersionsPerRow) || n.maxVersionsPerRow < 1)) throw Error(`rowHistoryPlugin: maxVersionsPerRow must be an integer >= 1, got ${String(n.maxVersionsPerRow)}`);
149
+ let i = new Set((n.include ?? []).map((e) => e.tableName)), a = new Set((n.exclude ?? []).map((e) => e.tableName)), o = [...i].filter((e) => a.has(e));
150
+ if (o.length > 0) throw Error(`rowHistoryPlugin: ${o.join(", ")} appears in BOTH include and exclude. One of the two is a mistake and only you know which — resolving it here would pick silently.`);
151
+ let s, l = () => {
152
+ if (s !== void 0) return s;
153
+ let e = /* @__PURE__ */ new Set();
154
+ for (let n of t()) {
155
+ let t = n.tableName;
156
+ t !== F && (c(t) && !i.has(t) || a.has(t) || e.add(t));
157
+ }
158
+ for (let t of i) e.add(t);
159
+ return s = e, e;
160
+ }, u = { has: (e) => l().has(e) }, d = w({
161
+ base: G,
162
+ alias: n.alias,
163
+ instance: n.name
164
+ }), f, p, m = v(process.env.VOLTRO_ROW_HISTORY_TTL_HOURS, 8760), y = Math.round(m / 864e5);
165
+ return h({
166
+ source: "plugin",
167
+ table: F,
168
+ timeColumn: "changedAt",
169
+ ttlMs: m
170
+ }), C({
171
+ name: d,
172
+ baseName: G,
173
+ description: "Row history + time-travel — value snapshots of every change, with as-of queries.",
174
+ permissions: ["store:write", "store:changes:read"],
175
+ extendSchema: { tables: [I] },
176
+ bindDataStore: (e) => {
177
+ if (f = V(e), p = e, n.timing === "in-transaction") {
178
+ if (e.raw === void 0) throw Error("rowHistoryPlugin({ timing: 'in-transaction' }) needs a SQL store — the in-memory store has no transaction to enlist in, and no durability for the guarantee to mean anything. Use timing: 'post-commit' for memory-backed dev, or set DB_DIALECT to a SQL dialect.");
179
+ g(F, {
180
+ tables: l(),
181
+ ownTables: [F],
182
+ recorder: async (e, t) => {
183
+ let n = t.next ?? t.prev, i = n && n.id != null ? String(n.id) : null;
184
+ if (i === null) return;
185
+ let a = await e.maxOf(F, "version", {
186
+ tableName: t.table,
187
+ rowId: i
188
+ }), o = a === null ? 1 : a + 1;
189
+ await e.append(F, {
190
+ id: r("rowver", t.table, i, o),
191
+ tableName: t.table,
192
+ rowId: i,
193
+ version: o,
194
+ op: t.op,
195
+ data: U(t.table, n),
196
+ changedBy: t.subjectId ?? H(n ?? null),
197
+ changedAt: /* @__PURE__ */ new Date(),
198
+ tenantId: L(n ?? null),
199
+ traceId: t.traceId ?? null,
200
+ subjectId: t.subjectId ?? null,
201
+ procedure: t.procedure ?? null
202
+ });
203
+ }
204
+ });
205
+ }
206
+ },
207
+ onChangeEvent: (t) => e.gen(function* () {
208
+ if (n.timing === "in-transaction" || !f || !u.has(t.table)) return;
209
+ let r = W(t);
210
+ if (r === null) return;
211
+ let i = p === void 0 ? void 0 : yield* e.tryPromise(() => _(p, t.subjectId ?? H(t.new ?? t.old), "user")).pipe(e.orElseSucceed(() => void 0));
212
+ yield* e.tryPromise(() => j(f, {
213
+ tableName: t.table,
214
+ rowId: r,
215
+ op: t.op,
216
+ data: t.oversized === "tombstone" ? null : U(t.table, t.new ?? t.old),
217
+ changedBy: t.subjectId === void 0 ? H(t.new ?? t.old) : t.subjectId,
218
+ ...t.traceId === void 0 ? {} : { traceId: t.traceId },
219
+ ...t.subjectId === void 0 ? {} : { subjectId: t.subjectId },
220
+ ...t.procedure === void 0 ? {} : { procedure: t.procedure },
221
+ ...i === void 0 ? {} : { actor: i },
222
+ ...n.resolveScope === void 0 ? {} : { scope: (() => {
223
+ try {
224
+ return n.resolveScope(t.new ?? t.old ?? {});
225
+ } catch {
226
+ return null;
227
+ }
228
+ })() },
229
+ now: Date.now(),
230
+ tenantId: L(t.new ?? t.old)
231
+ }, n.maxVersionsPerRow === void 0 ? void 0 : { maxVersions: n.maxVersionsPerRow }));
232
+ }),
233
+ onActivate: (t) => e.sync(() => {
234
+ t.logger.info("row-history active", {
235
+ tables: l().size,
236
+ historyTable: F,
237
+ retentionDays: y,
238
+ ...n.maxVersionsPerRow === void 0 ? {} : { maxVersionsPerRow: n.maxVersionsPerRow }
239
+ });
240
+ })
241
+ });
242
+ }, q = async (e, t, n, r) => O(await V(e).versionsOf(t, n, r)), J = async (e, t, n, r, i) => D(await V(e).versionsOf(t, n, r), i instanceof Date ? i.getTime() : i)?.data ?? null, Y = async (e, t, n, r, i, a) => P(await V(e).versionsOf(t, n, r), i, a), X = async (e, t, n, r, i) => {
243
+ let a = await J(e, t, n, r, i);
244
+ if (a === null) return null;
245
+ let { id: o, ...s } = a;
246
+ return await e.update(t, n, s);
247
+ }, Z = async (e, t, r) => {
248
+ let a = z(r), o = a === void 0 ? i("traceId", t) : n(i("traceId", t), a);
249
+ return O((await e.query(m(I).where(o).descriptor)).map(B));
250
+ }, Q = async (e, t, r, a = 100) => {
251
+ let o = z(r), s = o === void 0 ? i("subjectId", t) : n(i("subjectId", t), o);
252
+ return (await e.query(m(I).where(s).orderBy("changedAt", "desc").limit(a).descriptor)).map(B);
253
+ }, $ = (t, n, r) => e.promise(() => Z(t, n, r)), ee = (t, n, r, i = 100) => e.promise(() => Q(t, n, r, i)), te = (t, n, r, i) => e.promise(() => q(t, n, r, i)), ne = (t, n, r, i, a) => e.promise(() => J(t, n, r, i, a)), re = (t, n, r, i, a, o) => e.promise(() => Y(t, n, r, i, a, o)), ie = (t, n, r, i, a) => e.promise(() => X(t, n, r, i, a));
254
+ //#endregion
255
+ export { V as dataStoreHistoryStore, N as diffSnapshots, P as diffVersionRows, Y as diffVersions, re as diffVersionsEffect, Q as historyBySubject, ee as historyBySubjectEffect, Z as historyByTrace, $ as historyByTraceEffect, I as historyTable, A as memoryHistoryStore, E as nextVersionNumber, j as recordChange, X as restoreAsOf, ie as restoreAsOfEffect, J as rowAsOf, ne as rowAsOfEffect, q as rowHistory, te as rowHistoryEffect, K as rowHistoryPlugin, D as selectAsOf, O as sortHistory };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@voltro/plugin-row-history",
3
+ "version": "0.52.0",
4
+ "description": "Row history + time-travel. audit() records who/when; row history records what-changed-to-what — a full value snapshot of every row on every insert/update/delete, with as-of queries.",
5
+ "keywords": [
6
+ "voltro",
7
+ "typescript",
8
+ "framework"
9
+ ],
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "homepage": "https://voltro.dev",
12
+ "bugs": {
13
+ "email": "support@voltro.dev"
14
+ },
15
+ "author": {
16
+ "name": "Voltro UG",
17
+ "url": "https://voltro.dev"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "sideEffects": false,
32
+ "engines": {
33
+ "node": ">=24.0.0"
34
+ },
35
+ "dependencies": {
36
+ "@voltro/database": "0.52.0",
37
+ "@voltro/protocol": "0.52.0"
38
+ },
39
+ "peerDependencies": {
40
+ "effect": "^3.22.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }