@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.
- package/CHANGELOG.md +9758 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +347 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.js +255 -0
- package/package.json +45 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|