fieldlog 0.15.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,306 @@
1
+ // tombstone.ts — soft-delete + gc-guard + partial legal-hold over the append-only log.
2
+ //
3
+ // Soft-delete is a compensating event (`tombstone.hide`), never a rewrite:
4
+ // the target line stays in the log (auditable, syncable, replayable) and the
5
+ // read-model hides it by convention. GC-guard (`guardSeal`) keeps `truncate`
6
+ // honest: a seal that would split a hide/target pair or sweep a held event
7
+ // clamps down instead of deleting. Legal-hold is partial (per event id, not
8
+ // the whole log) and local (stored in `_meta`, like the snapshot seal —
9
+ // set it per replica, it does not sync).
10
+ //
11
+ // This module only uses the public EventStore/Kernel surface; it changes no
12
+ // existing file and adds no schema migration.
13
+ import { clampSealToStored } from './retain.js';
14
+ import type { LogEvent } from './log.js';
15
+ import type { EventStore } from './store.js';
16
+ import type { AppendArgs } from './kernel.js';
17
+
18
+ /** Compensating event: hide `payload.hides` (an event id) from visible reads. */
19
+ export const TOMBSTONE_HIDE = 'tombstone.hide';
20
+ /** Compensating event: lift the hide on `payload.shows` (an event id). */
21
+ export const TOMBSTONE_SHOW = 'tombstone.show';
22
+
23
+ /** `_meta` key prefix for local legal-holds; the suffix is the held event id. */
24
+ const HOLD_PREFIX = 'tombstone.hold.';
25
+
26
+ /** Minimal kernel surface this module needs (append + read). */
27
+ export interface Hider {
28
+ append(args: AppendArgs): Promise<LogEvent>;
29
+ query<T = Record<string, unknown>>(sql: string, params?: unknown): Promise<T[]>;
30
+ }
31
+
32
+ export interface TombstoneOp {
33
+ seq: number;
34
+ op: 'hide' | 'show';
35
+ target: string;
36
+ }
37
+
38
+ export interface Hold {
39
+ id: string;
40
+ reason: string;
41
+ /** Live log seq, or null when the event is not in this replica's store. */
42
+ seq: number | null;
43
+ /** True when this hold forced the seal down. */
44
+ blocks: boolean;
45
+ }
46
+
47
+ /** A tombstone/target split: the target seq plus the tombstone-op seq the
48
+ * seal would strand on the other side of the sweep boundary. `hide` keeps
49
+ * its name for existing callers; it holds the op seq for a hide or a show.
50
+ */
51
+ export interface SplitPair {
52
+ target: number;
53
+ hide: number;
54
+ }
55
+
56
+ export interface GuardReport {
57
+ /** Seal the caller may safely sweep (0 = sweep nothing). */
58
+ effective: number;
59
+ /** Every hold on this replica and whether it blocked the sweep. */
60
+ held: Hold[];
61
+ /** Tombstone/target pairs the seal would have split (clamped below both). */
62
+ pairs: SplitPair[];
63
+ }
64
+
65
+ /** Tombstone ops in seq order; malformed bodies are skipped, never fatal. */
66
+ export function listTombstones(store: EventStore): TombstoneOp[] {
67
+ const rows = store.query<{ seq: number; type: string; body: string }>(
68
+ `SELECT seq, type, body FROM records WHERE type = '${TOMBSTONE_HIDE}' OR type = '${TOMBSTONE_SHOW}' ORDER BY seq`,
69
+ );
70
+ const ops: TombstoneOp[] = [];
71
+ for (const r of rows) {
72
+ let body: Record<string, unknown> = {};
73
+ try {
74
+ body = JSON.parse(r.body) as Record<string, unknown>;
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (r.type === TOMBSTONE_HIDE && typeof body['hides'] === 'string') {
79
+ ops.push({ seq: r.seq, op: 'hide', target: body['hides'] as string });
80
+ } else if (r.type === TOMBSTONE_SHOW && typeof body['shows'] === 'string') {
81
+ ops.push({ seq: r.seq, op: 'show', target: body['shows'] as string });
82
+ }
83
+ }
84
+ return ops;
85
+ }
86
+
87
+ /** Ids hidden right now: hides add, shows lift, folded in seq order. */
88
+ export function hiddenIds(store: EventStore): Set<string> {
89
+ const hidden = new Set<string>();
90
+ for (const op of listTombstones(store)) {
91
+ if (op.op === 'hide') hidden.add(op.target);
92
+ else hidden.delete(op.target);
93
+ }
94
+ return hidden;
95
+ }
96
+
97
+ export function isHidden(store: EventStore, id: string): boolean {
98
+ return hiddenIds(store).has(id);
99
+ }
100
+
101
+ /** Module mutex: serializes check-then-append in hide/show so concurrent
102
+ * callers need no outer lock. Cooperative (same process), like the kernel
103
+ * append/truncate chain. */
104
+ let chain: Promise<void> = Promise.resolve();
105
+ function runAtomic<T>(fn: () => Promise<T>): Promise<T> {
106
+ const next = chain.then(fn);
107
+ chain = next.then(
108
+ () => undefined,
109
+ () => undefined,
110
+ );
111
+ return next;
112
+ }
113
+
114
+ async function storedIds(k: Hider): Promise<Set<string>> {
115
+ const rows = await k.query<{ id: string }>(`SELECT id FROM _events`);
116
+ return new Set(rows.map((r) => r.id));
117
+ }
118
+
119
+ /** Tombstone fold over the kernel's view: ids hidden right now. */
120
+ async function hiddenIdsOf(k: Hider): Promise<Set<string>> {
121
+ const rows = await k.query<{ type: string; payload: unknown }>(
122
+ `SELECT type, payload FROM _events WHERE type IN ('${TOMBSTONE_HIDE}', '${TOMBSTONE_SHOW}') ORDER BY seq`,
123
+ );
124
+ const hidden = new Set<string>();
125
+ for (const r of rows) {
126
+ const p =
127
+ typeof r.payload === 'string'
128
+ ? (JSON.parse(r.payload) as Record<string, unknown>)
129
+ : (r.payload as Record<string, unknown>);
130
+ if (r.type === TOMBSTONE_HIDE && typeof p?.hides === 'string') hidden.add(p.hides);
131
+ else if (r.type === TOMBSTONE_SHOW && typeof p?.shows === 'string') hidden.delete(p.shows);
132
+ }
133
+ return hidden;
134
+ }
135
+
136
+ /** Fail-loud when `k` and `store` are not the same replica. Compares the
137
+ * exposed store identity when available, then requires both views to agree
138
+ * on whether the target is stored. */
139
+ async function assertPairedStore(k: Hider, store: EventStore, targetId: string): Promise<void> {
140
+ if (k !== null && typeof k === 'object' && 'store' in k) {
141
+ const inner: unknown = k.store;
142
+ if (inner !== undefined && inner !== store) {
143
+ throw new Error(`ERR_STORE_MISMATCH: tombstone.show needs k and store on the same replica`);
144
+ }
145
+ }
146
+ const kHas = (await storedIds(k)).has(targetId);
147
+ const sHas = store.hasId(targetId);
148
+ if (kHas !== sHas) {
149
+ throw new Error(`ERR_STORE_MISMATCH: tombstone.show needs k and store on the same replica`);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Soft-delete: append a `tombstone.hide` compensating event. The target line
155
+ * stays in the log; readers via `isHidden`/`hiddenIds` exclude it. Throws
156
+ * `ERR_UNKNOWN_TARGET` before appending when the target is not stored, so a
157
+ * typo never leaves a poison line behind.
158
+ *
159
+ * Atomic: the stored-target check and the append run under a module mutex,
160
+ * so concurrent `hide()` calls need no outer lock. A second concurrent hide
161
+ * of an already-hidden id is an exactly-once no-op — it returns the existing
162
+ * hide event instead of appending a duplicate.
163
+ */
164
+ export async function hide(
165
+ k: Hider,
166
+ targetId: string,
167
+ opts?: { actor?: string; reason?: string },
168
+ ): Promise<LogEvent> {
169
+ return runAtomic(async () => {
170
+ if (!(await storedIds(k)).has(targetId)) {
171
+ throw new Error(`ERR_UNKNOWN_TARGET: tombstone.hide needs a stored event id (got ${targetId})`);
172
+ }
173
+ if ((await hiddenIdsOf(k)).has(targetId)) {
174
+ const rows = await k.query<{ id: string; type: string; payload: unknown }>(
175
+ `SELECT id, type, payload FROM _events WHERE type = '${TOMBSTONE_HIDE}' ORDER BY seq`,
176
+ );
177
+ for (const r of rows) {
178
+ const p =
179
+ typeof r.payload === 'string'
180
+ ? (JSON.parse(r.payload) as Record<string, unknown>)
181
+ : (r.payload as Record<string, unknown>);
182
+ if (p?.hides === targetId) {
183
+ const found = await k.query<Record<string, unknown>>(
184
+ `SELECT * FROM _events WHERE id = '${r.id.replace(/'/g, "''")}'`,
185
+ );
186
+ const row = found[0];
187
+ if (row) {
188
+ const raw = row['payload'];
189
+ return { ...(row as unknown as LogEvent), payload: typeof raw === 'string' ? JSON.parse(raw) : raw };
190
+ }
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ return k.append({
196
+ type: TOMBSTONE_HIDE,
197
+ payload: { hides: targetId, ...(opts?.reason ? { reason: opts.reason } : {}) },
198
+ ...(opts?.actor ? { actor: opts.actor } : {}),
199
+ });
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Lift a soft-delete. Throws `ERR_NOT_HIDDEN` when the id is not hidden, so
205
+ * a stray show never leaves a poison line behind. Throws `ERR_STORE_MISMATCH`
206
+ * when `k` and `store` are not the same replica. Atomic with `hide()` under
207
+ * the module mutex, so no outer lock is needed.
208
+ */
209
+ export async function show(
210
+ k: Hider,
211
+ store: EventStore,
212
+ targetId: string,
213
+ opts?: { actor?: string },
214
+ ): Promise<LogEvent> {
215
+ return runAtomic(async () => {
216
+ if (!isHidden(store, targetId)) {
217
+ throw new Error(`ERR_NOT_HIDDEN: tombstone.show needs a hidden event id (got ${targetId})`);
218
+ }
219
+ await assertPairedStore(k, store, targetId);
220
+ return k.append({
221
+ type: TOMBSTONE_SHOW,
222
+ payload: { shows: targetId },
223
+ ...(opts?.actor ? { actor: opts.actor } : {}),
224
+ });
225
+ });
226
+ }
227
+
228
+ /** Local legal-hold on one event id. Throws `ERR_UNKNOWN_TARGET` when absent. */
229
+ export function hold(store: EventStore, id: string, reason: string): void {
230
+ if (!store.getEventById(id)) {
231
+ throw new Error(`ERR_UNKNOWN_TARGET: hold needs a stored event id (got ${id})`);
232
+ }
233
+ store.setMeta(HOLD_PREFIX + id, reason);
234
+ }
235
+
236
+ export function release(store: EventStore, id: string): void {
237
+ store.exec(`DELETE FROM _meta WHERE k = '${(HOLD_PREFIX + id).replace(/'/g, "''")}'`);
238
+ }
239
+
240
+ export function isHeld(store: EventStore, id: string): boolean {
241
+ return store.getMeta(HOLD_PREFIX + id) !== null;
242
+ }
243
+
244
+ /** Every hold on this replica with its live seq (null when not stored here). */
245
+ export function holds(store: EventStore): Array<{ id: string; reason: string; seq: number | null }> {
246
+ const rows = store.query<{ k: string; v: string }>(
247
+ `SELECT k, v FROM _meta WHERE k LIKE '${HOLD_PREFIX}%'`,
248
+ );
249
+ return rows.map((r) => ({
250
+ id: r.k.slice(HOLD_PREFIX.length),
251
+ reason: r.v,
252
+ seq: store.getEventById(r.k.slice(HOLD_PREFIX.length))?.seq ?? null,
253
+ }));
254
+ }
255
+
256
+ /**
257
+ * GC-guard: clamp a truncate seal so it never (a) removes unacked/unapplied
258
+ * data (via `clampSealToStored`), (b) sweeps a legally-held event, or
259
+ * (c) splits a tombstone/target pair across the sweep boundary (a swept
260
+ * target whose hide or show survives — or vice versa — would resurrect or
261
+ * orphan on replay). Returns the safe seal plus exactly what forced it down,
262
+ * so the caller can report held-vs-swept honestly instead of claiming deletion.
263
+ */
264
+ export function guardSeal(
265
+ store: EventStore,
266
+ logSeqs: number[],
267
+ sealed: number,
268
+ ackSeq: number,
269
+ ): GuardReport {
270
+ let effective = clampSealToStored(store, logSeqs, sealed, ackSeq);
271
+ const held: Hold[] = [];
272
+ for (const h of holds(store)) {
273
+ if (h.seq !== null && h.seq <= effective) {
274
+ effective = h.seq - 1;
275
+ held.push({ ...h, blocks: true });
276
+ } else {
277
+ held.push({ ...h, blocks: false });
278
+ }
279
+ }
280
+ // Tombstone/target pairs sweep atomically: fixpoint, because clamping for
281
+ // one pair can expose a split in another. Hides and shows both count — a
282
+ // swept target whose show survives (or vice versa) would resurrect or
283
+ // orphan on replay, same as a split hide.
284
+ const pairs: SplitPair[] = [];
285
+ for (;;) {
286
+ let split: SplitPair | null = null;
287
+ for (const op of listTombstones(store)) {
288
+ const targetSeq = store.getEventById(op.target)?.seq;
289
+ if (targetSeq === undefined || targetSeq === null) continue; // pair already gone
290
+ const tIn = targetSeq <= effective;
291
+ const hIn = op.seq <= effective;
292
+ if (tIn !== hIn) {
293
+ split = { target: targetSeq, hide: op.seq };
294
+ break;
295
+ }
296
+ }
297
+ if (!split) break;
298
+ pairs.push(split);
299
+ effective = Math.min(split.target, split.hide) - 1;
300
+ if (effective <= 0) {
301
+ effective = 0;
302
+ break;
303
+ }
304
+ }
305
+ return { effective, held, pairs };
306
+ }