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.
- package/CHANGELOG.md +109 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/fieldlog.js +18 -0
- package/bin/fieldlog.ts +145 -0
- package/package.json +35 -0
- package/src/auth.ts +297 -0
- package/src/cas.ts +357 -0
- package/src/deltasync.ts +306 -0
- package/src/hashchain.ts +106 -0
- package/src/index.ts +41 -0
- package/src/kernel.ts +333 -0
- package/src/log.ts +344 -0
- package/src/quota.ts +122 -0
- package/src/relay.ts +1027 -0
- package/src/retain.ts +267 -0
- package/src/revokelog.ts +291 -0
- package/src/store.ts +706 -0
- package/src/sync.ts +828 -0
- package/src/tombstone.ts +306 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
// store.ts — SQLite read-model: apply/replay/materialize over the log.
|
|
2
|
+
// ledger.db is plain SQLite (opens in DBeaver). Bun runtime: bun:sqlite.
|
|
3
|
+
import { Database } from 'bun:sqlite';
|
|
4
|
+
import { hashFor, type LogEvent } from './log.js';
|
|
5
|
+
|
|
6
|
+
export type SqlParams = Record<string, unknown> | unknown[];
|
|
7
|
+
|
|
8
|
+
type Db = {
|
|
9
|
+
exec(sql: string): void;
|
|
10
|
+
prepare(sql: string): { all(...p: unknown[]): unknown[]; run(...p: unknown[]): unknown };
|
|
11
|
+
close(): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** bun:sqlite wants prefixed named params ($id); accept bare keys too. */
|
|
15
|
+
function named(params: Record<string, unknown>): Record<string, unknown> {
|
|
16
|
+
const out: Record<string, unknown> = {};
|
|
17
|
+
for (const [k, v] of Object.entries(params)) {
|
|
18
|
+
out[k.startsWith('$') || k.startsWith(':') || k.startsWith('@') ? k : `$${k}`] = v;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function openDb(path: string): Db {
|
|
24
|
+
const db = new Database(path);
|
|
25
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
26
|
+
return {
|
|
27
|
+
exec: (sql: string) => {
|
|
28
|
+
db.exec(sql);
|
|
29
|
+
},
|
|
30
|
+
prepare: (sql: string) => {
|
|
31
|
+
const s = db.prepare(sql) as unknown as {
|
|
32
|
+
all(...p: unknown[]): unknown[];
|
|
33
|
+
run(...p: unknown[]): void;
|
|
34
|
+
};
|
|
35
|
+
const shape = (p: unknown[]) =>
|
|
36
|
+
p.length === 1 && typeof p[0] === 'object' && p[0] !== null && !Array.isArray(p[0])
|
|
37
|
+
? [named(p[0] as Record<string, unknown>)]
|
|
38
|
+
: p;
|
|
39
|
+
return {
|
|
40
|
+
all: (...p: unknown[]) => s.all(...shape(p)),
|
|
41
|
+
run: (...p: unknown[]) => {
|
|
42
|
+
s.run(...shape(p));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
close: () => db.close(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Entry honesty: an offline entry is a promise, never resolved until acked.
|
|
51
|
+
export const EntryState = {
|
|
52
|
+
DRAFT: 'DRAFT',
|
|
53
|
+
RECORDED: 'RECORDED', // recorded locally, NOT resolved
|
|
54
|
+
RESOLVED_ONLINE: 'RESOLVED_ONLINE', // only via resolve/sync ack
|
|
55
|
+
FAILED: 'FAILED',
|
|
56
|
+
EXPIRED: 'EXPIRED',
|
|
57
|
+
} as const;
|
|
58
|
+
export type EntryState = (typeof EntryState)[keyof typeof EntryState];
|
|
59
|
+
|
|
60
|
+
const ENTRY_APPEND_STATES: Record<string, true> = { [EntryState.DRAFT]: true, [EntryState.RECORDED]: true };
|
|
61
|
+
const TERMINAL_STATES: Record<string, true> = {
|
|
62
|
+
[EntryState.RESOLVED_ONLINE]: true,
|
|
63
|
+
[EntryState.FAILED]: true,
|
|
64
|
+
[EntryState.EXPIRED]: true,
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Fail-fast input validation. Kernel runs this BEFORE touching the log so a
|
|
68
|
+
* rejected append leaves no poison line behind (replay stays total).
|
|
69
|
+
*/
|
|
70
|
+
export function checkAppend(type: string, payload: Record<string, unknown>): void {
|
|
71
|
+
const p = payload ?? {};
|
|
72
|
+
if (type === 'entry') {
|
|
73
|
+
const value = Number(p['value']);
|
|
74
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
75
|
+
throw new Error(`entry rejected: value must be a positive integer (got ${String(p['value'])})`);
|
|
76
|
+
}
|
|
77
|
+
const state = String(p['state'] ?? EntryState.RECORDED);
|
|
78
|
+
if (!ENTRY_APPEND_STATES[state]) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`entry rejected: state '${state}' cannot be recorded offline (use DRAFT or RECORDED; resolution needs online ack)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} else if (type === 'tally.add') {
|
|
84
|
+
if (!String(p['item']) || !Number.isFinite(Number(p['qty']))) throw new Error('tally.add needs {item, qty}');
|
|
85
|
+
} else if (type === 'tally.remove') {
|
|
86
|
+
if (!String(p['item']) || !Number.isFinite(Number(p['qty'])) || Number(p['qty']) <= 0) {
|
|
87
|
+
throw new Error('tally.remove needs {item, qty>0}');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function run(db: Db, sql: string, ...params: unknown[]): void {
|
|
93
|
+
void db.prepare(sql).run(...params);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function all<T>(db: Db, sql: string, ...params: unknown[]): T[] {
|
|
97
|
+
return db.prepare(sql).all(...params) as T[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SCHEMA = `
|
|
101
|
+
CREATE TABLE IF NOT EXISTS _events(
|
|
102
|
+
seq INTEGER PRIMARY KEY,
|
|
103
|
+
id TEXT UNIQUE NOT NULL,
|
|
104
|
+
type TEXT NOT NULL,
|
|
105
|
+
actor TEXT,
|
|
106
|
+
device_id TEXT NOT NULL,
|
|
107
|
+
ts_device INTEGER NOT NULL,
|
|
108
|
+
server_time INTEGER,
|
|
109
|
+
payload TEXT NOT NULL,
|
|
110
|
+
hash TEXT NOT NULL,
|
|
111
|
+
prev_hash TEXT NOT NULL
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS _swept_ids(id TEXT PRIMARY KEY, seq INTEGER NOT NULL DEFAULT 0);
|
|
114
|
+
CREATE TABLE IF NOT EXISTS _meta(k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
|
115
|
+
CREATE TABLE IF NOT EXISTS entries(
|
|
116
|
+
seq INTEGER PRIMARY KEY,
|
|
117
|
+
event_id TEXT UNIQUE NOT NULL,
|
|
118
|
+
value INTEGER NOT NULL,
|
|
119
|
+
actor TEXT,
|
|
120
|
+
state TEXT NOT NULL,
|
|
121
|
+
voided INTEGER NOT NULL DEFAULT 0
|
|
122
|
+
);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS tally(
|
|
124
|
+
item TEXT PRIMARY KEY,
|
|
125
|
+
qty INTEGER NOT NULL DEFAULT 0
|
|
126
|
+
);
|
|
127
|
+
CREATE TABLE IF NOT EXISTS tally_moves(
|
|
128
|
+
seq INTEGER PRIMARY KEY,
|
|
129
|
+
event_id TEXT UNIQUE NOT NULL,
|
|
130
|
+
item TEXT NOT NULL,
|
|
131
|
+
qty INTEGER NOT NULL,
|
|
132
|
+
voided INTEGER NOT NULL DEFAULT 0
|
|
133
|
+
);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS conflicts(
|
|
135
|
+
id TEXT PRIMARY KEY,
|
|
136
|
+
kind TEXT NOT NULL,
|
|
137
|
+
detail TEXT NOT NULL,
|
|
138
|
+
event_ids TEXT NOT NULL,
|
|
139
|
+
status TEXT NOT NULL DEFAULT 'open'
|
|
140
|
+
);
|
|
141
|
+
CREATE TABLE IF NOT EXISTS records(
|
|
142
|
+
seq INTEGER PRIMARY KEY,
|
|
143
|
+
event_id TEXT UNIQUE NOT NULL,
|
|
144
|
+
type TEXT NOT NULL,
|
|
145
|
+
body TEXT NOT NULL
|
|
146
|
+
);
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
export interface ReplayResult {
|
|
150
|
+
/** Events durably applied by this call (idempotent replays excluded). */
|
|
151
|
+
applied: number;
|
|
152
|
+
/** Poison events rolled back alone so the tail behind them still applied. */
|
|
153
|
+
skipped: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface EventStore {
|
|
157
|
+
apply(ev: LogEvent): void;
|
|
158
|
+
/** Catch-up apply of events missing locally; never deletes (truncate-safe). */
|
|
159
|
+
replay(events: LogEvent[]): ReplayResult;
|
|
160
|
+
/**
|
|
161
|
+
* Delete read-model rows for seqs the log no longer carries (quarantined),
|
|
162
|
+
* forgiving the swept prefix below `forgiveBelow`. Returns excised count.
|
|
163
|
+
*/
|
|
164
|
+
exciseMissing(kept: number[], forgiveBelow: number): number;
|
|
165
|
+
/**
|
|
166
|
+
* Compact the swept-UUID reuse-guard table: drop guards with seq below
|
|
167
|
+
* `beforeSeq` (default: lowest retained `_events` seq). Guards below the
|
|
168
|
+
* retained floor can never meet a live seq again — seqs are never reused
|
|
169
|
+
* across sweeps — so they only grow the table. Returns pruned count.
|
|
170
|
+
*/
|
|
171
|
+
pruneSweptIds?(beforeSeq?: number): number;
|
|
172
|
+
query<T = Record<string, unknown>>(sql: string, params?: SqlParams): T[];
|
|
173
|
+
/** Raw DDL/admin (VACUUM INTO for snapshots). No placeholder support. */
|
|
174
|
+
exec(sql: string): void;
|
|
175
|
+
getEventById(id: string): LogEvent | null;
|
|
176
|
+
hasId(id: string): boolean;
|
|
177
|
+
getMeta(k: string): string | null;
|
|
178
|
+
setMeta(k: string, v: string): void;
|
|
179
|
+
close(): void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function openStore(path: string): EventStore {
|
|
183
|
+
const db = openDb(path);
|
|
184
|
+
db.exec(SCHEMA);
|
|
185
|
+
// Migration: guards learned their excised seq so pruneSweptIds can compact
|
|
186
|
+
// below the retained floor. Legacy rows keep seq 0 (unknown — never pruned).
|
|
187
|
+
try {
|
|
188
|
+
const cols = all<{ name: string }>(db, `PRAGMA table_info(_swept_ids)`);
|
|
189
|
+
if (!cols.some((c) => c.name === 'seq') && cols.length > 0) {
|
|
190
|
+
run(db, `ALTER TABLE _swept_ids ADD COLUMN seq INTEGER NOT NULL DEFAULT 0`);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
/* fresh schema above already carries seq; read path must not throw */
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Fail-closed tamper gate: a well-formed line with an edited payload must never merge silently. */
|
|
197
|
+
function assertUntampered(ev: LogEvent): void {
|
|
198
|
+
// Only enforce on real chain hashes (64-hex); synthetic fixtures that never
|
|
199
|
+
// went through the log carry placeholder hashes and keep legacy behavior.
|
|
200
|
+
if (typeof ev.hash !== 'string' || !/^[0-9a-f]{64}$/.test(ev.hash)) return;
|
|
201
|
+
const { hash, signature: _s, countersignatures: _c, ...core } = ev;
|
|
202
|
+
void _s;
|
|
203
|
+
void _c;
|
|
204
|
+
if (hashFor(core as Omit<LogEvent, 'hash'>) !== hash) {
|
|
205
|
+
throw new Error(`tamper rejected: hash mismatch for event ${ev.id} (seq ${ev.seq})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function insertRaw(ev: LogEvent): boolean {
|
|
209
|
+
// Fail-loud on swept-UUID reuse: exciseMissing retires the _events row, so
|
|
210
|
+
// a re-appended UUID would otherwise insert as a fresh row (or no-op when
|
|
211
|
+
// the row was kept) and split log vs store silently. Reject explicitly.
|
|
212
|
+
if (all(db, `SELECT 1 FROM _swept_ids WHERE id = ? LIMIT 1`, ev.id).length > 0) {
|
|
213
|
+
throw new Error(`swept id reused: ${ev.id} was excised and must not be re-appended`);
|
|
214
|
+
}
|
|
215
|
+
// Idempotent by UUID: replays / pulled duplicates are no-ops. But a seq
|
|
216
|
+
try {
|
|
217
|
+
run(
|
|
218
|
+
db,
|
|
219
|
+
`INSERT INTO _events(seq,id,type,actor,device_id,ts_device,server_time,payload,hash,prev_hash)
|
|
220
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
221
|
+
ev.seq,
|
|
222
|
+
ev.id,
|
|
223
|
+
ev.type,
|
|
224
|
+
ev.actor ?? null,
|
|
225
|
+
ev.device_id,
|
|
226
|
+
ev.ts_device,
|
|
227
|
+
ev.server_time ?? null,
|
|
228
|
+
JSON.stringify(ev.payload),
|
|
229
|
+
ev.hash,
|
|
230
|
+
ev.prev_hash,
|
|
231
|
+
);
|
|
232
|
+
return true;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const msg = String((err as Error)?.message ?? err);
|
|
235
|
+
if (!msg.includes('UNIQUE') && !msg.includes('PRIMARY')) throw err;
|
|
236
|
+
if (msg.includes('_events.id')) return false; // idempotent replay by UUID
|
|
237
|
+
// Exact re-inserts can trip the seq check first: still a replay when
|
|
238
|
+
// THIS id is already stored. A fresh id on a taken seq is corruption.
|
|
239
|
+
const known = all(db, `SELECT 1 FROM _events WHERE id = ? LIMIT 1`, ev.id).length > 0;
|
|
240
|
+
if (known) return false;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function addConflict(kind: string, detail: string, eventIds: string[]): void {
|
|
245
|
+
const id = `cf-${eventIds.join('-')}-${kind}`;
|
|
246
|
+
try {
|
|
247
|
+
run(
|
|
248
|
+
db,
|
|
249
|
+
`INSERT INTO conflicts(id,kind,detail,event_ids,status) VALUES(?,?,?,?, 'open')`,
|
|
250
|
+
id,
|
|
251
|
+
kind,
|
|
252
|
+
detail,
|
|
253
|
+
JSON.stringify(eventIds),
|
|
254
|
+
);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// Same conflict re-applied during replay — keep the first row. Anything
|
|
257
|
+
// else (disk full, IO, locking) must surface, never swallow.
|
|
258
|
+
const msg = String((err as Error)?.message ?? err);
|
|
259
|
+
if (msg.includes('UNIQUE') || msg.includes('PRIMARY')) return;
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Reorder resurrection: an undo/transition that arrived before its target
|
|
265
|
+
// parks in records (undo) or conflicts (unknown-entry). When the target
|
|
266
|
+
// lands later, re-resolve here so voided/state converge regardless of order.
|
|
267
|
+
function resolvePendingUndos(target: string): void {
|
|
268
|
+
const rows = all<{ event_id: string; body: string }>(
|
|
269
|
+
db,
|
|
270
|
+
`SELECT event_id, body FROM records WHERE type = 'undo.compensate'`,
|
|
271
|
+
);
|
|
272
|
+
for (const r of rows) {
|
|
273
|
+
let reverses = '';
|
|
274
|
+
try {
|
|
275
|
+
reverses = String((JSON.parse(r.body) as Record<string, unknown>)['reverses'] ?? '');
|
|
276
|
+
} catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (reverses !== target) continue;
|
|
280
|
+
const b = all<{ seq: number }>(db, `SELECT seq FROM entries WHERE event_id = ?`, target);
|
|
281
|
+
if (b.length) {
|
|
282
|
+
run(db, `UPDATE entries SET voided = 1 WHERE event_id = ?`, target);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const m = all<{ item: string; qty: number; voided: number }>(
|
|
286
|
+
db,
|
|
287
|
+
`SELECT item, qty, voided FROM tally_moves WHERE event_id = ?`,
|
|
288
|
+
target,
|
|
289
|
+
);
|
|
290
|
+
if (m.length && !m[0].voided) {
|
|
291
|
+
run(db, `UPDATE tally_moves SET voided = 1 WHERE event_id = ?`, target);
|
|
292
|
+
run(
|
|
293
|
+
db,
|
|
294
|
+
`INSERT INTO tally(item,qty) VALUES(?,?)
|
|
295
|
+
ON CONFLICT(item) DO UPDATE SET qty = tally.qty + excluded.qty`,
|
|
296
|
+
m[0].item,
|
|
297
|
+
-m[0].qty,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function resolvePendingEntries(target: string): void {
|
|
304
|
+
const open = all<{ id: string; event_ids: string }>(
|
|
305
|
+
db,
|
|
306
|
+
`SELECT id, event_ids FROM conflicts WHERE kind = 'unknown-entry' AND status = 'open'`,
|
|
307
|
+
);
|
|
308
|
+
const cands: Array<{ id: string; type: string; seq: number; conflictId: string }> = [];
|
|
309
|
+
for (const c of open) {
|
|
310
|
+
let ids: string[] = [];
|
|
311
|
+
try {
|
|
312
|
+
ids = JSON.parse(c.event_ids) as string[];
|
|
313
|
+
} catch {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
for (const tid of ids) {
|
|
317
|
+
const er = all<{ type: string; seq: number; payload: string }>(
|
|
318
|
+
db,
|
|
319
|
+
`SELECT type, seq, payload FROM _events WHERE id = ?`,
|
|
320
|
+
tid,
|
|
321
|
+
);
|
|
322
|
+
if (!er.length) continue;
|
|
323
|
+
let pp: Record<string, unknown> = {};
|
|
324
|
+
try {
|
|
325
|
+
pp = JSON.parse(er[0].payload) as Record<string, unknown>;
|
|
326
|
+
} catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (String(pp['event_id'] ?? pp['reverses'] ?? '') !== target) continue;
|
|
330
|
+
cands.push({ id: tid, type: er[0].type, seq: er[0].seq, conflictId: c.id });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
cands.sort((a, b) => a.seq - b.seq);
|
|
334
|
+
for (const t of cands) {
|
|
335
|
+
const rows = all<{ state: string }>(db, `SELECT state FROM entries WHERE event_id = ?`, target);
|
|
336
|
+
if (!rows.length) continue;
|
|
337
|
+
if (TERMINAL_STATES[rows[0].state]) {
|
|
338
|
+
// Target landed terminal already: morph into a double-resolve for humans.
|
|
339
|
+
run(db, `UPDATE conflicts SET kind = 'double-resolve', detail = ?, event_ids = ? WHERE id = ?`,
|
|
340
|
+
`${t.type} on already-terminal entry ${target} (${rows[0].state})`,
|
|
341
|
+
JSON.stringify([target, t.id]),
|
|
342
|
+
t.conflictId);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const next =
|
|
346
|
+
t.type === 'entry.resolved'
|
|
347
|
+
? EntryState.RESOLVED_ONLINE
|
|
348
|
+
: t.type === 'entry.failed'
|
|
349
|
+
? EntryState.FAILED
|
|
350
|
+
: EntryState.EXPIRED;
|
|
351
|
+
run(db, `UPDATE entries SET state = ? WHERE event_id = ?`, next, target);
|
|
352
|
+
run(db, `UPDATE conflicts SET status = 'resolved' WHERE id = ?`, t.conflictId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function route(ev: LogEvent): void {
|
|
357
|
+
const p = ev.payload as Record<string, unknown>;
|
|
358
|
+
switch (ev.type) {
|
|
359
|
+
case 'entry': {
|
|
360
|
+
checkAppend(ev.type, p);
|
|
361
|
+
const value = Number(p['value']);
|
|
362
|
+
const state = String(p['state'] ?? EntryState.RECORDED);
|
|
363
|
+
run(
|
|
364
|
+
db,
|
|
365
|
+
`INSERT INTO entries(seq,event_id,value,actor,state,voided) VALUES(?,?,?,?,?,0)`,
|
|
366
|
+
ev.seq,
|
|
367
|
+
ev.id,
|
|
368
|
+
value,
|
|
369
|
+
(p['actor'] as string) ?? (ev.actor as string) ?? null,
|
|
370
|
+
state,
|
|
371
|
+
);
|
|
372
|
+
// Target landed after its undo/transition parked: resurrect them now.
|
|
373
|
+
resolvePendingUndos(ev.id);
|
|
374
|
+
resolvePendingEntries(ev.id);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
case 'entry.resolved':
|
|
378
|
+
case 'entry.failed':
|
|
379
|
+
case 'entry.expired': {
|
|
380
|
+
const target = String(p['event_id'] ?? p['reverses'] ?? '');
|
|
381
|
+
const rows = all<{ state: string; voided: number }>(
|
|
382
|
+
db,
|
|
383
|
+
`SELECT state, voided FROM entries WHERE event_id = ?`,
|
|
384
|
+
target,
|
|
385
|
+
);
|
|
386
|
+
if (rows.length === 0) {
|
|
387
|
+
addConflict('unknown-entry', `transition ${ev.type} references unknown entry ${target}`, [ev.id]);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (TERMINAL_STATES[rows[0].state]) {
|
|
391
|
+
// Double-resolve / resolve-after-fail: human must reconcile, never LWW.
|
|
392
|
+
addConflict(
|
|
393
|
+
'double-resolve',
|
|
394
|
+
`${ev.type} on already-terminal entry ${target} (${rows[0].state})`,
|
|
395
|
+
[target, ev.id],
|
|
396
|
+
);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
const next =
|
|
400
|
+
ev.type === 'entry.resolved'
|
|
401
|
+
? EntryState.RESOLVED_ONLINE
|
|
402
|
+
: ev.type === 'entry.failed'
|
|
403
|
+
? EntryState.FAILED
|
|
404
|
+
: EntryState.EXPIRED;
|
|
405
|
+
run(db, `UPDATE entries SET state = ? WHERE event_id = ?`, next, target);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'tally.add': {
|
|
409
|
+
checkAppend(ev.type, p);
|
|
410
|
+
const item = String(p['item']);
|
|
411
|
+
const qty = Number(p['qty']);
|
|
412
|
+
run(
|
|
413
|
+
db,
|
|
414
|
+
`INSERT INTO tally(item,qty) VALUES(?,?)
|
|
415
|
+
ON CONFLICT(item) DO UPDATE SET qty = tally.qty + excluded.qty`,
|
|
416
|
+
item,
|
|
417
|
+
qty,
|
|
418
|
+
);
|
|
419
|
+
run(db, `INSERT INTO tally_moves(seq,event_id,item,qty,voided) VALUES(?,?,?, ?,0)`, ev.seq, ev.id, item, qty);
|
|
420
|
+
resolvePendingUndos(ev.id);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case 'tally.remove': {
|
|
424
|
+
checkAppend(ev.type, p);
|
|
425
|
+
const item = String(p['item']);
|
|
426
|
+
const qty = Number(p['qty']);
|
|
427
|
+
const rows = all<{ qty: number }>(db, `SELECT qty FROM tally WHERE item = ?`, item);
|
|
428
|
+
const onHand = rows.length ? rows[0].qty : 0;
|
|
429
|
+
if (qty > onHand) {
|
|
430
|
+
// Contended tally: explicit conflict row, move parked as voided. Never silent LWW.
|
|
431
|
+
run(db, `INSERT INTO tally_moves(seq,event_id,item,qty,voided) VALUES(?,?,?,? ,1)`, ev.seq, ev.id, item, -qty);
|
|
432
|
+
addConflict(
|
|
433
|
+
'underflow',
|
|
434
|
+
`remove ${qty}×${item} with ${onHand} on hand (event ${ev.id})`,
|
|
435
|
+
[ev.id],
|
|
436
|
+
);
|
|
437
|
+
} else {
|
|
438
|
+
run(db, `UPDATE tally SET qty = qty - ? WHERE item = ?`, qty, item);
|
|
439
|
+
run(db, `INSERT INTO tally_moves(seq,event_id,item,qty,voided) VALUES(?,?,?, ?,0)`, ev.seq, ev.id, item, -qty);
|
|
440
|
+
}
|
|
441
|
+
resolvePendingUndos(ev.id);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'undo.compensate': {
|
|
445
|
+
const target = String(p['reverses'] ?? '');
|
|
446
|
+
const b = all<{ seq: number }>(db, `SELECT seq FROM entries WHERE event_id = ?`, target);
|
|
447
|
+
if (b.length) {
|
|
448
|
+
run(db, `UPDATE entries SET voided = 1 WHERE event_id = ?`, target);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
const m = all<{ item: string; qty: number; voided: number }>(
|
|
452
|
+
db,
|
|
453
|
+
`SELECT item, qty, voided FROM tally_moves WHERE event_id = ?`,
|
|
454
|
+
target,
|
|
455
|
+
);
|
|
456
|
+
if (m.length && !m[0].voided) {
|
|
457
|
+
run(db, `UPDATE tally_moves SET voided = 1 WHERE event_id = ?`, target);
|
|
458
|
+
// Reverse the physical effect: moves store signed qty, so subtract it back.
|
|
459
|
+
run(
|
|
460
|
+
db,
|
|
461
|
+
`INSERT INTO tally(item,qty) VALUES(?,?)
|
|
462
|
+
ON CONFLICT(item) DO UPDATE SET qty = tally.qty + excluded.qty`,
|
|
463
|
+
m[0].item,
|
|
464
|
+
-m[0].qty,
|
|
465
|
+
);
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
run(db, `INSERT INTO records(seq,event_id,type,body) VALUES(?,?,?,?)`, ev.seq, ev.id, ev.type, JSON.stringify(p));
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
default: {
|
|
472
|
+
run(db, `INSERT INTO records(seq,event_id,type,body) VALUES(?,?,?,?)`, ev.seq, ev.id, ev.type, JSON.stringify(p));
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Forensic quarantine for a corrupt _events row, mirroring sync.ts: the
|
|
480
|
+
* _events row stays (reopen replay stays a no-op by UUID) while the domain
|
|
481
|
+
* views stop serving it. Schema matches ensureQuarantine in sync.ts.
|
|
482
|
+
* Best-effort on a read path: never throws.
|
|
483
|
+
*/
|
|
484
|
+
function quarantineCorruptRow(r: { id: string; seq: number }): void {
|
|
485
|
+
try {
|
|
486
|
+
db.exec('BEGIN IMMEDIATE');
|
|
487
|
+
try {
|
|
488
|
+
run(
|
|
489
|
+
db,
|
|
490
|
+
`CREATE TABLE IF NOT EXISTS _quarantine(event_id TEXT PRIMARY KEY, reason TEXT NOT NULL, ts INTEGER NOT NULL, event TEXT NOT NULL)`,
|
|
491
|
+
);
|
|
492
|
+
const raw = all<Record<string, unknown>>(db, `SELECT * FROM _events WHERE id = ?`, r.id);
|
|
493
|
+
run(
|
|
494
|
+
db,
|
|
495
|
+
`INSERT OR IGNORE INTO _quarantine(event_id, reason, ts, event) VALUES(?,?,?,?)`,
|
|
496
|
+
r.id,
|
|
497
|
+
`corrupt-payload seq=${r.seq}`,
|
|
498
|
+
Date.now(),
|
|
499
|
+
raw.length ? JSON.stringify(raw[0]) : r.id,
|
|
500
|
+
);
|
|
501
|
+
run(db, `DELETE FROM entries WHERE event_id = ?`, r.id);
|
|
502
|
+
const m = all<{ n: number }>(db, `SELECT COUNT(*) AS n FROM tally_moves WHERE event_id = ?`, r.id);
|
|
503
|
+
run(db, `DELETE FROM tally_moves WHERE event_id = ?`, r.id);
|
|
504
|
+
run(db, `DELETE FROM records WHERE event_id = ?`, r.id);
|
|
505
|
+
if ((m[0]?.n ?? 0) > 0) {
|
|
506
|
+
// Balances derive from moves: rebuild atomically with the purge.
|
|
507
|
+
db.exec(`DELETE FROM tally`);
|
|
508
|
+
run(
|
|
509
|
+
db,
|
|
510
|
+
`INSERT INTO tally(item, qty) SELECT item, SUM(qty) FROM tally_moves WHERE voided = 0 GROUP BY item`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
db.exec('COMMIT');
|
|
514
|
+
} catch {
|
|
515
|
+
try {
|
|
516
|
+
db.exec('ROLLBACK');
|
|
517
|
+
} catch {
|
|
518
|
+
/* already rolled back */
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
/* read path: the row is still unreadable, so callers see absence */
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Compact swept-UUID reuse guards below a retained floor. Guards carry the
|
|
527
|
+
// excised seq; seqs never repeat across sweeps, so a guard below the floor
|
|
528
|
+
// can never shadow a live row again. Legacy seq-0 rows (unknown) stay.
|
|
529
|
+
function pruneSweptIdsImpl(floor: number | undefined): number {
|
|
530
|
+
let f = floor;
|
|
531
|
+
if (f === undefined) {
|
|
532
|
+
const m = all<{ m: number | null }>(db, `SELECT MIN(seq) AS m FROM _events`);
|
|
533
|
+
if (m[0]?.m === null || m[0]?.m === undefined) return 0; // empty read-model: no floor known
|
|
534
|
+
f = m[0].m as number;
|
|
535
|
+
}
|
|
536
|
+
if (!(f > 0)) return 0;
|
|
537
|
+
// Own transaction: excise above commits separately; a prune failure must
|
|
538
|
+
// never roll back the excise it compacts.
|
|
539
|
+
db.exec('BEGIN IMMEDIATE');
|
|
540
|
+
try {
|
|
541
|
+
run(db, `DELETE FROM _swept_ids WHERE seq > 0 AND seq < ?`, f);
|
|
542
|
+
const n = all<{ n: number }>(db, `SELECT changes() AS n`);
|
|
543
|
+
db.exec('COMMIT');
|
|
544
|
+
return n[0]?.n ?? 0;
|
|
545
|
+
} catch (err) {
|
|
546
|
+
try {
|
|
547
|
+
db.exec('ROLLBACK');
|
|
548
|
+
} catch {
|
|
549
|
+
/* already rolled back */
|
|
550
|
+
}
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const store: EventStore = {
|
|
556
|
+
apply(ev: LogEvent): void {
|
|
557
|
+
assertUntampered(ev);
|
|
558
|
+
// Atomic: _events row + routed rows commit together. A kill between
|
|
559
|
+
// them used to orphan the UUID and blind replay forever.
|
|
560
|
+
db.exec('BEGIN IMMEDIATE');
|
|
561
|
+
try {
|
|
562
|
+
if (!insertRaw(ev)) {
|
|
563
|
+
db.exec('ROLLBACK');
|
|
564
|
+
return; // idempotent by UUID
|
|
565
|
+
}
|
|
566
|
+
route(ev);
|
|
567
|
+
db.exec('COMMIT');
|
|
568
|
+
} catch (err) {
|
|
569
|
+
try {
|
|
570
|
+
db.exec('ROLLBACK');
|
|
571
|
+
} catch {
|
|
572
|
+
/* already rolled back */
|
|
573
|
+
}
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
replay(events: LogEvent[]): ReplayResult {
|
|
578
|
+
// Incremental only: the store can hold events the log no longer carries
|
|
579
|
+
// (post-truncate). Clearing here would wipe a healthy read-model.
|
|
580
|
+
// Per-event transaction: one poison event rolls back alone instead of
|
|
581
|
+
// starving the tail behind it. The skip count is returned so callers
|
|
582
|
+
// can surface poison instead of silently losing it.
|
|
583
|
+
let applied = 0;
|
|
584
|
+
let skipped = 0;
|
|
585
|
+
for (const ev of [...events].sort((a, b) => a.seq - b.seq)) {
|
|
586
|
+
db.exec('BEGIN IMMEDIATE');
|
|
587
|
+
try {
|
|
588
|
+
assertUntampered(ev);
|
|
589
|
+
if (!insertRaw(ev)) {
|
|
590
|
+
db.exec('ROLLBACK');
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
route(ev);
|
|
594
|
+
db.exec('COMMIT');
|
|
595
|
+
applied += 1;
|
|
596
|
+
} catch {
|
|
597
|
+
try {
|
|
598
|
+
db.exec('ROLLBACK');
|
|
599
|
+
} catch {
|
|
600
|
+
/* already rolled back */
|
|
601
|
+
}
|
|
602
|
+
skipped += 1;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { applied, skipped };
|
|
606
|
+
},
|
|
607
|
+
exciseMissing(kept: number[], forgiveBelow: number): number {
|
|
608
|
+
const have = new Set(kept);
|
|
609
|
+
const rows = all<{ seq: number; id: string }>(db, `SELECT seq, id FROM _events`);
|
|
610
|
+
const gone = rows.filter((r) => !have.has(r.seq) && r.seq >= forgiveBelow);
|
|
611
|
+
// One transaction: a failure mid-sweep (disk, lock, trigger) rolls the
|
|
612
|
+
// whole excise back instead of leaving half-deleted views behind, and
|
|
613
|
+
// the tally rebuild below commits atomically with the deletes above.
|
|
614
|
+
let n = 0;
|
|
615
|
+
if (gone.length > 0) {
|
|
616
|
+
db.exec('BEGIN IMMEDIATE');
|
|
617
|
+
try {
|
|
618
|
+
let moves = 0;
|
|
619
|
+
for (const g of gone) {
|
|
620
|
+
run(db, `DELETE FROM entries WHERE event_id = ?`, g.id);
|
|
621
|
+
const m = all<{ n: number }>(db, `SELECT COUNT(*) AS n FROM tally_moves WHERE event_id = ?`, g.id);
|
|
622
|
+
if (m[0]?.n) moves += 1;
|
|
623
|
+
run(db, `DELETE FROM tally_moves WHERE event_id = ?`, g.id);
|
|
624
|
+
run(db, `DELETE FROM records WHERE event_id = ?`, g.id);
|
|
625
|
+
run(db, `DELETE FROM _events WHERE id = ?`, g.id);
|
|
626
|
+
run(db, `INSERT OR IGNORE INTO _swept_ids(id, seq) VALUES(?, ?)`, g.id, g.seq);
|
|
627
|
+
}
|
|
628
|
+
if (moves > 0) {
|
|
629
|
+
// Balances derive from moves: rebuild so excised tally stops counting.
|
|
630
|
+
db.exec('DELETE FROM tally');
|
|
631
|
+
run(
|
|
632
|
+
db,
|
|
633
|
+
`INSERT INTO tally(item, qty) SELECT item, SUM(qty) FROM tally_moves WHERE voided = 0 GROUP BY item`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
db.exec('COMMIT');
|
|
637
|
+
} catch (err) {
|
|
638
|
+
try {
|
|
639
|
+
db.exec('ROLLBACK');
|
|
640
|
+
} catch {
|
|
641
|
+
/* already rolled back */
|
|
642
|
+
}
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
n = gone.length;
|
|
646
|
+
}
|
|
647
|
+
// Compact guards swept by older floors even when nothing new excised:
|
|
648
|
+
// a sweep that only advances the floor still retires old guards.
|
|
649
|
+
pruneSweptIdsImpl(forgiveBelow);
|
|
650
|
+
return n;
|
|
651
|
+
},
|
|
652
|
+
pruneSweptIds(beforeSeq?: number): number {
|
|
653
|
+
return pruneSweptIdsImpl(beforeSeq);
|
|
654
|
+
},
|
|
655
|
+
exec(sql: string): void {
|
|
656
|
+
db.exec(sql);
|
|
657
|
+
},
|
|
658
|
+
query<T = Record<string, unknown>>(sql: string, params?: SqlParams): T[] {
|
|
659
|
+
const stmt = db.prepare(sql);
|
|
660
|
+
if (params === undefined) return stmt.all() as T[];
|
|
661
|
+
if (Array.isArray(params)) return stmt.all(...params) as T[];
|
|
662
|
+
return stmt.all(params) as T[];
|
|
663
|
+
},
|
|
664
|
+
getEventById(id: string): LogEvent | null {
|
|
665
|
+
const rows = all<{
|
|
666
|
+
seq: number; id: string; type: string; actor: string | null; device_id: string;
|
|
667
|
+
ts_device: number; server_time: number | null; payload: string; hash: string; prev_hash: string;
|
|
668
|
+
}>(db, `SELECT * FROM _events WHERE id = ?`, id);
|
|
669
|
+
if (!rows.length) return null;
|
|
670
|
+
const r = rows[0];
|
|
671
|
+
let payload: Record<string, unknown>;
|
|
672
|
+
try {
|
|
673
|
+
payload = JSON.parse(r.payload) as Record<string, unknown>;
|
|
674
|
+
} catch {
|
|
675
|
+
payload = null as unknown as Record<string, unknown>;
|
|
676
|
+
}
|
|
677
|
+
if (payload === null || typeof payload !== 'object') {
|
|
678
|
+
// Corrupt _events payload (bit-rot, bad merge): quarantine the row —
|
|
679
|
+
// evidence kept, domain views purged — and report absence instead of
|
|
680
|
+
// throwing out of a read path (sync/tombstone call this in loops).
|
|
681
|
+
quarantineCorruptRow(r);
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
seq: r.seq, id: r.id, type: r.type, actor: r.actor ?? undefined,
|
|
686
|
+
device_id: r.device_id, ts_device: r.ts_device,
|
|
687
|
+
server_time: r.server_time ?? undefined,
|
|
688
|
+
payload, hash: r.hash, prev_hash: r.prev_hash,
|
|
689
|
+
};
|
|
690
|
+
},
|
|
691
|
+
hasId(id: string): boolean {
|
|
692
|
+
return all(db, `SELECT 1 FROM _events WHERE id = ? LIMIT 1`, id).length > 0;
|
|
693
|
+
},
|
|
694
|
+
getMeta(k: string): string | null {
|
|
695
|
+
const rows = all<{ v: string }>(db, `SELECT v FROM _meta WHERE k = ?`, k);
|
|
696
|
+
return rows.length ? rows[0].v : null;
|
|
697
|
+
},
|
|
698
|
+
setMeta(k: string, v: string): void {
|
|
699
|
+
run(db, `INSERT INTO _meta(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, k, v);
|
|
700
|
+
},
|
|
701
|
+
close(): void {
|
|
702
|
+
db.close();
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
return store;
|
|
706
|
+
}
|