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/src/kernel.ts ADDED
@@ -0,0 +1,333 @@
1
+ // kernel.ts — createKernel({file}): append/query/undo/sync.
2
+ // Local-first: append/query/undo NEVER touch the network. Sync is background
3
+ // delta handled by sync.ts against a Relay object.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { openLog, type AppendLog, type AppendInput, type LogEvent } from './log.js';
6
+ import { checkAppend, openStore, type EventStore, type SqlParams } from './store.js';
7
+ import {
8
+ createFailoverState,
9
+ getAckSeq,
10
+ getServerTime,
11
+ syncKernel as runSync,
12
+ syncWithFailover,
13
+ type FailoverState,
14
+ type PushResult,
15
+ type PullResult,
16
+ type Relay,
17
+ type SyncOpts,
18
+ } from './sync.js';
19
+ import { takeSnapshot, sweepLogFile } from './retain.js';
20
+ import { guardSeal, type Hold, type SplitPair } from './tombstone.js';
21
+ import { CAP_TOKEN_TTL_MS, mintCapToken, signEvent, type CapToken } from './auth.js';
22
+ import { openQuotaGuard, type QuotaGuard, type QuotaStatus } from './quota.js';
23
+
24
+ export interface KernelOpts {
25
+ file: string; // e.g. 'ledger.db' (+ sidecar 'ledger.log')
26
+ deviceId?: string;
27
+ /** Wall-clock source for ts_device (display only, never order). Test seam for skew. */
28
+ clock?: () => number;
29
+ /** Max locally queued events awaiting ack (default 50_000). Append past it throws ERR_OUTBOX_FULL. */
30
+ maxPending?: number;
31
+ /** Hard byte ceiling over [db, log] files, enforced fail-closed on every
32
+ * append (throws ERR_QUOTA_EXCEEDED / ERR_QUOTA_UNKNOWN). Unset = no quota. */
33
+ quotaLimitBytes?: number;
34
+ /** Per-append byte estimate reserved against the quota (default: measured
35
+ * JSON size of the event input, min 1). Test seam for deterministic denial. */
36
+ quotaEstimateBytes?: number;
37
+ /** ed25519 private key PEM: every local append is signed at source, so
38
+ * trusted-mode receivers verify (not dead-letter) legitimate traffic. */
39
+ privateKeyPem?: string;
40
+ }
41
+
42
+ /** Default bound on unsynced outbox events before append refuses with ERR_OUTBOX_FULL. */
43
+ export const DEFAULT_OUTBOX_CAP = 50_000;
44
+
45
+ export type AppendArgs =
46
+ | { type: string; payload: Record<string, unknown>; actor?: string }
47
+ | ({ type: string; actor?: string } & Record<string, unknown>);
48
+
49
+ export interface LogHealth {
50
+ events: number;
51
+ quarantined: number;
52
+ repairedTail: boolean;
53
+ gaps: number[];
54
+ /** Poison log lines skipped by the open-time replay (hash mismatch / corrupt apply). */
55
+ skipped: number;
56
+ }
57
+
58
+ export interface SnapshotInfo {
59
+ snapshot: string;
60
+ sealedSeq: number;
61
+ dbSeq: number;
62
+ }
63
+
64
+ export interface TruncateInfo {
65
+ removed: number;
66
+ kept: number;
67
+ sealedSeq: number;
68
+ /** Holds on this replica and whether each blocked the sweep. */
69
+ held: Hold[];
70
+ /** Tombstone/target pairs the seal would have split. */
71
+ pairs: SplitPair[];
72
+ }
73
+
74
+ export interface Kernel {
75
+ deviceId: string;
76
+ dbPath: string;
77
+ logPath: string;
78
+ append(args: AppendArgs): Promise<LogEvent>;
79
+ query<T = Record<string, unknown>>(sql: string, params?: SqlParams): Promise<T[]>;
80
+ undo(eventId: string, actor?: string): Promise<LogEvent>;
81
+ /** Resolve an IOU: 'resolved' needs online ack; failed/expired record locally. */
82
+ resolve(eventId: string, outcome: 'resolved' | 'failed' | 'expired', actor?: string): Promise<LogEvent>;
83
+ /** Sync via one relay, or fail over across a list in order (sticks to first healthy). */
84
+ sync(relay: Relay | Relay[], opts?: SyncOpts): Promise<PushResult & PullResult & { pushRelay?: number; pullRelay?: number }>;
85
+ /** Mint a relay capability token for this kernel's deviceId with a device private key. */
86
+ capToken(privateKeyPem: string, scopes?: string[], ttlMs?: number): CapToken;
87
+ conflicts(): Promise<Record<string, unknown>[]>;
88
+ ackSeq(): number;
89
+ serverTime(): number | null;
90
+ verifyLog(): { ok: boolean; at?: number; reason?: string; gaps?: number[]; skipped: number };
91
+ health(): LogHealth;
92
+ /** Current quota snapshot, or null when no quotaLimitBytes was configured. */
93
+ quota(): QuotaStatus | null;
94
+ /** Online full copy of the db + seal the acked prefix into it. */
95
+ snapshot(dest?: string): Promise<SnapshotInfo>;
96
+ /** Sweep the sealed log prefix (guarded by ack seq, holds, tombstone pairs). */
97
+ truncate(): Promise<TruncateInfo>;
98
+ close(): void;
99
+ }
100
+ export function logPathFor(file: string): string {
101
+ return file.replace(/\.(db|sqlite|sqlite3)$/, '') + '.log';
102
+ }
103
+
104
+ function toAppendInput(args: AppendArgs, deviceId: string, clock: () => number): AppendInput {
105
+ const { type, actor, payload, ...rest } = args as {
106
+ type: string;
107
+ actor?: string;
108
+ payload?: Record<string, unknown>;
109
+ } & Record<string, unknown>;
110
+ if (!type) throw new Error('append: type is required');
111
+ // Shorthand (README): append({type:'entry', value, actor}) → payload.
112
+ // Explicit: append({type, payload}) — extra keys merge under payload.
113
+ const { device_id: _d, id: _i, ts_device: _t, ...clean } = rest;
114
+ void _d;
115
+ void _i;
116
+ void _t;
117
+ return { type, actor, device_id: deviceId, ts_device: clock(), payload: { ...clean, ...(payload ?? {}) } };
118
+ }
119
+
120
+ export async function createKernel(opts: KernelOpts): Promise<Kernel> {
121
+ const dbPath = opts.file;
122
+ const logPath = logPathFor(opts.file);
123
+ const store: EventStore = openStore(dbPath);
124
+ const storedDevice: string | null = store.getMeta('device.id');
125
+ const storedExplicit: string | null = store.getMeta('device.explicit');
126
+ if (storedDevice && opts.deviceId && storedDevice !== opts.deviceId && storedExplicit === '1') {
127
+ const msg =
128
+ `ERR_DEVICE_MISMATCH: explicit deviceId '${opts.deviceId}' != stored '${storedDevice}' for '${dbPath}'; ` +
129
+ `refusing to split-brain (reopen with the stored id, or use a fresh file for a new device)`;
130
+ store.close();
131
+ throw new Error(msg);
132
+ }
133
+ const deviceId: string = opts.deviceId ?? storedDevice ?? randomUUID();
134
+ if (!storedDevice) {
135
+ store.setMeta('device.id', deviceId);
136
+ store.setMeta('device.explicit', opts.deviceId ? '1' : '0');
137
+ } else if (opts.deviceId && storedDevice !== opts.deviceId) {
138
+ // First explicit open over an auto-generated id: adopt it (the common
139
+ // init-then-sync flow), and mark it explicit so any later id throws.
140
+ store.setMeta('device.id', deviceId);
141
+ store.setMeta('device.explicit', '1');
142
+ }
143
+ const signer = opts.privateKeyPem ? (ev: LogEvent) => signEvent(opts.privateKeyPem as string, ev) : undefined;
144
+ let log: AppendLog = openLog(logPath, deviceId, signer);
145
+ const openReplay = store.replay(log.readAll());
146
+ if (openReplay.skipped > 0) {
147
+ console.warn(`WARN_REPLAY_SKIPPED: ${openReplay.skipped} poison log line(s) skipped on open of '${logPath}'`);
148
+ }
149
+ let replaySkipped = openReplay.skipped;
150
+ store.exciseMissing(
151
+ log.readAll().map((e) => e.seq),
152
+ log.sealedBelow,
153
+ );
154
+
155
+ const clock = opts.clock ?? Date.now;
156
+ const maxPending = opts.maxPending ?? DEFAULT_OUTBOX_CAP;
157
+ if (!Number.isInteger(maxPending) || maxPending < 1) {
158
+ throw new Error(`maxPending must be a positive integer, got ${opts.maxPending}`);
159
+ }
160
+ // Byte quota over the two state files (db + log sidecar). Fail-closed:
161
+ // every append reserves its estimated size first, so ERR_QUOTA_EXCEEDED
162
+ // and ERR_QUOTA_UNKNOWN propagate and nothing is written past the ceiling.
163
+ const quota: QuotaGuard | null =
164
+ opts.quotaLimitBytes === undefined
165
+ ? null
166
+ : openQuotaGuard({ limitBytes: opts.quotaLimitBytes, files: [dbPath, logPath] });
167
+ if (!quota) console.warn(`WARN_UNCAPPED: no quotaLimitBytes set — uncapped growth; set quotaLimitBytes to bound disk usage ('${dbPath}')`);
168
+ const quotaFixed = opts.quotaEstimateBytes;
169
+ if (quotaFixed !== undefined && (!Number.isInteger(quotaFixed) || quotaFixed < 1)) {
170
+ throw new Error(`quotaEstimateBytes must be a positive integer, got ${opts.quotaEstimateBytes}`);
171
+ }
172
+ // Failover memory across sync calls: failed relays cool down with backoff,
173
+ // then get re-probed; list order decides fail-back.
174
+ const failover: FailoverState = createFailoverState(0);
175
+ // Append/truncate mutex: truncate closes and reopens the log fd, so an
176
+ // append racing it could write into a closed fd or a stale generation.
177
+ // Serialize both through one promise chain (cooperative: same process).
178
+ let tail: Promise<void> = Promise.resolve();
179
+ function serialize<T>(fn: () => Promise<T>): Promise<T> {
180
+ const next = tail.then(fn);
181
+ tail = next.then(
182
+ () => undefined,
183
+ () => undefined,
184
+ );
185
+ return next;
186
+ }
187
+ /** Re-drive log lines the read-model never applied (kill between log.append
188
+ * and store.apply), like the sync/deltasync pull path. Idempotent by UUID.
189
+ * Gated on a suspect flag: a split can only appear when an append's
190
+ * store.apply throws (flagged below) or across a restart (covered by the
191
+ * open-time replay), so the steady path stays O(1) instead of O(log). */
192
+ let splitSuspect = false;
193
+ function healSplit(): void {
194
+ if (!splitSuspect) return;
195
+ splitSuspect = false;
196
+ for (const e of log.readAll()) {
197
+ if (store.hasId(e.id)) continue;
198
+ try {
199
+ store.apply(e);
200
+ } catch {
201
+ splitSuspect = true; // still failing: leave it for the next append or restart
202
+ }
203
+ }
204
+ }
205
+ async function appendInner(args: AppendArgs): Promise<LogEvent> {
206
+ healSplit();
207
+ const input = toAppendInput(args, deviceId, clock);
208
+ checkAppend(input.type, input.payload ?? {}); // fail fast: no poison lines in the log
209
+ const pending = log.maxSeq() - getAckSeq(store);
210
+ if (pending >= maxPending) {
211
+ throw new Error(
212
+ `ERR_OUTBOX_FULL: outbox holds ${pending} pending events (cap ${maxPending}); ` +
213
+ `oldest unsynced seq is ${getAckSeq(store) + 1}; sync to drain before appending`,
214
+ );
215
+ }
216
+ // Reserve before growing state; release once the growth is measured on
217
+ // disk (or when the append fails) so `held` never double-counts `used`.
218
+ let reserved = 0;
219
+ if (quota) {
220
+ reserved = quotaFixed ?? Math.max(1, Buffer.byteLength(JSON.stringify(input), 'utf8'));
221
+ quota.reserve(reserved); // throws ERR_QUOTA_* fail-closed: nothing written below
222
+ }
223
+ let ev: LogEvent;
224
+ try {
225
+ ev = log.append(input);
226
+ } catch (err) {
227
+ if (quota) quota.release(reserved);
228
+ throw err;
229
+ }
230
+ if (quota) quota.release(reserved);
231
+ // Contract: the log line above is already fsynced, so a store failure
232
+ // here is a split, not a loss. Fail loud (never swallow) and let the
233
+ // next append/restart re-drive the durable line via healSplit/replay.
234
+ try {
235
+ store.apply(ev);
236
+ } catch (err) {
237
+ splitSuspect = true; // the durable line above still needs re-driving
238
+ const why = err instanceof Error ? err.message : String(err);
239
+ throw new Error(
240
+ `ERR_APPLY_SPLIT: log seq ${ev.seq} (id ${ev.id}) is durable but the read-model apply failed (${why}); ` +
241
+ `it will be re-driven on the next append or restart`,
242
+ );
243
+ }
244
+ return ev;
245
+ }
246
+ const kernel: Kernel = {
247
+ deviceId,
248
+ dbPath,
249
+ logPath,
250
+ append: (args) => serialize(() => appendInner(args)),
251
+
252
+ query: <T = Record<string, unknown>>(sql: string, params?: SqlParams): Promise<T[]> =>
253
+ Promise.resolve(store.query<T>(sql, params)),
254
+ undo: async (eventId, actor) => {
255
+ // Blind compensator by design: the target may live on a peer replica
256
+ // not yet synced here. Convergence is by fold, not by local existence.
257
+ return serialize(() => appendInner({ type: 'undo.compensate', payload: { reverses: eventId }, actor }));
258
+ },
259
+ resolve: async (eventId, outcome, actor) => {
260
+ const type = outcome === 'resolved' ? 'entry.resolved' : outcome === 'failed' ? 'entry.failed' : 'entry.expired';
261
+ return serialize(() => appendInner({ type, payload: { event_id: eventId }, actor }));
262
+ },
263
+ sync: (relay, syncOpts) => {
264
+ const relays = Array.isArray(relay) ? relay : [relay];
265
+ if (
266
+ relays.length === 0 ||
267
+ relays.some((r) => !r || typeof (r as Relay).push !== 'function' || typeof (r as Relay).pull !== 'function')
268
+ ) {
269
+ throw new Error('sync needs a Relay object (push/pull) — raw URLs carry no transport in v0.1');
270
+ }
271
+ if (!Array.isArray(relay)) return runSync(log, store, relay as Relay, deviceId, syncOpts);
272
+ return syncWithFailover(log, store, relays as Relay[], deviceId, syncOpts, failover);
273
+ },
274
+ capToken: (privateKeyPem, scopes = ['relay:push', 'relay:pull'], ttlMs = CAP_TOKEN_TTL_MS) =>
275
+ mintCapToken(privateKeyPem, deviceId, scopes, ttlMs),
276
+ conflicts: () => Promise.resolve(store.query(`SELECT * FROM conflicts WHERE status = 'open'`)),
277
+ ackSeq: () => getAckSeq(store),
278
+ serverTime: () => getServerTime(store),
279
+ verifyLog: () => {
280
+ const v = log.verify();
281
+ return { ...v, skipped: replaySkipped };
282
+ },
283
+ health: () => {
284
+ const v = log.verify();
285
+ return { events: log.readAll().length, quarantined: log.quarantined, repairedTail: log.repairedTail, gaps: v.gaps ?? [], skipped: replaySkipped };
286
+ },
287
+ quota: () => (quota ? quota.status() : null),
288
+ snapshot: (dest) => Promise.resolve(takeSnapshot(store, dbPath, getAckSeq(store), dest)),
289
+ truncate: () =>
290
+ serialize(async () => {
291
+ const sealed = Number(store.getMeta('snapshot.sealed_seq') ?? 0);
292
+ const noop = { removed: 0, kept: log.readAll().length, sealedSeq: 0, held: [], pairs: [] };
293
+ if (sealed <= 0) return noop;
294
+ // Guard the seal before sweeping: never remove unacked/unapplied
295
+ // data, a legally-held event, or half a tombstone/target pair
296
+ // (a swept target whose hide survives — or vice versa — would
297
+ // resurrect or orphan on replay). Sweep report.effective and tell
298
+ // the operator exactly what blocked the rest.
299
+ const report = guardSeal(
300
+ store,
301
+ log.readAll().map((e) => e.seq),
302
+ sealed,
303
+ getAckSeq(store),
304
+ );
305
+ if (report.effective <= 0)
306
+ return { removed: 0, kept: log.readAll().length, sealedSeq: 0, held: report.held, pairs: report.pairs };
307
+ // The log fd must be closed for the sweep, so a failed sweep must
308
+ // still reopen it: a closed-but-referenced log breaks every later
309
+ // append with EBADF. finally keeps the kernel usable either way.
310
+ log.close();
311
+ try {
312
+ const swept = sweepLogFile(logPath, report.effective);
313
+ return { ...swept, sealedSeq: report.effective, held: report.held, pairs: report.pairs };
314
+ } finally {
315
+ log = openLog(logPath, deviceId, signer);
316
+ const r = store.replay(log.readAll()); // incremental: kept suffix re-applies, db stands
317
+ if (r.skipped > 0) {
318
+ console.warn(`WARN_REPLAY_SKIPPED: ${r.skipped} poison log line(s) skipped after truncate of '${logPath}'`);
319
+ }
320
+ replaySkipped += r.skipped;
321
+ store.exciseMissing(
322
+ log.readAll().map((e) => e.seq),
323
+ log.sealedBelow,
324
+ );
325
+ }
326
+ }),
327
+ close: () => {
328
+ log.close();
329
+ store.close();
330
+ },
331
+ };
332
+ return kernel;
333
+ }
package/src/log.ts ADDED
@@ -0,0 +1,344 @@
1
+ // log.ts — JSONL append-only log: UUID per event, hash chain, fsync per append.
2
+ // Boring file: `tail -f ledger.log` friendly. One JSON object per line.
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import {
5
+ appendFileSync,
6
+ closeSync,
7
+ existsSync,
8
+ fsyncSync,
9
+ ftruncateSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readFileSync,
13
+ writeSync,
14
+ } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+
17
+ export const GENESIS_HASH = 'GENESIS';
18
+
19
+ /** First line of a swept log: chains the kept suffix to the removed prefix. */
20
+ export interface TruncateMarker {
21
+ v: 1;
22
+ marker: 'fieldlog-truncate' | 'fielog-truncate';
23
+ truncated_before: number; // first kept seq; removed seqs are all below this
24
+ tip: string; // hash of the last removed event; verify base for the suffix
25
+ next_seq: number; // seq counter at sweep time; never reuse a seq
26
+ }
27
+ export function isMarker(o: unknown): o is TruncateMarker {
28
+ if (!o || typeof o !== 'object') return false;
29
+ if (!('marker' in o) || (o.marker !== 'fieldlog-truncate' && o.marker !== 'fielog-truncate')) return false;
30
+ if (!('v' in o) || o.v !== 1) return false;
31
+ return (
32
+ 'tip' in o &&
33
+ typeof o.tip === 'string' &&
34
+ 'next_seq' in o &&
35
+ typeof o.next_seq === 'number' &&
36
+ 'truncated_before' in o &&
37
+ typeof o.truncated_before === 'number'
38
+ );
39
+ }
40
+
41
+ export interface LogEvent {
42
+ id: string; // UUID, idempotency key across devices/relays
43
+ seq: number; // local monotonic sequence, assigned on append
44
+ type: string;
45
+ actor?: string;
46
+ device_id: string;
47
+ ts_device: number; // wall clock, display only — NEVER authoritative
48
+ payload: Record<string, unknown>;
49
+ prev_hash: string;
50
+ hash: string;
51
+ // Set only when learned from the relay / a peer; never hashed.
52
+ origin_seq?: number;
53
+ origin_device?: string;
54
+ server_time?: number;
55
+ // Auth envelope: device signature over the hash-chain hash. Never hashed
56
+ // (signature covers the hash, not vice versa). Unsigned locals stay valid;
57
+ // pull verification only enforces this when a trusted registry is given.
58
+ signature?: string;
59
+ countersignatures?: Array<{ deviceId: string; signatureHex: string }>;
60
+ }
61
+
62
+ export interface AppendInput {
63
+ type: string;
64
+ payload?: Record<string, unknown>;
65
+ actor?: string;
66
+ device_id?: string;
67
+ id?: string;
68
+ ts_device?: number;
69
+ // Preserved when re-importing a remote event locally.
70
+ origin_seq?: number;
71
+ origin_device?: string;
72
+ server_time?: number;
73
+ signature?: string;
74
+ countersignatures?: Array<{ deviceId: string; signatureHex: string }>;
75
+ }
76
+
77
+ /** Deep key-sorted copy of JSON data so identical payloads hash identically
78
+ * regardless of key insertion order across devices. Arrays keep their order
79
+ * (they are sequences, not sets); non-plain objects keep JSON semantics. */
80
+ function canonicalValue(v: unknown): unknown {
81
+ if (Array.isArray(v)) return v.map(canonicalValue);
82
+ if (v !== null && typeof v === 'object') {
83
+ const proto = Object.getPrototypeOf(v);
84
+ if (proto !== Object.prototype && proto !== null) return v;
85
+ const src = v as Record<string, unknown>;
86
+ const out: Record<string, unknown> = {};
87
+ for (const k of Object.keys(src).sort()) out[k] = canonicalValue(src[k]);
88
+ return out;
89
+ }
90
+ return v;
91
+ }
92
+
93
+ /** Canonical bytes covered by the hash chain (server_time excluded on purpose). */
94
+ export function canonicalOf(e: Omit<LogEvent, 'hash'>): string {
95
+ return JSON.stringify({
96
+ id: e.id,
97
+ seq: e.seq,
98
+ type: e.type,
99
+ actor: e.actor ?? null,
100
+ device_id: e.device_id,
101
+ ts_device: e.ts_device,
102
+ payload: canonicalValue(e.payload),
103
+ prev_hash: e.prev_hash,
104
+ });
105
+ }
106
+
107
+ export function hashFor(e: Omit<LogEvent, 'hash'>): string {
108
+ return createHash('sha256').update(canonicalOf(e), 'utf8').digest('hex');
109
+ }
110
+
111
+ export interface VerifyResult {
112
+ ok: boolean;
113
+ at?: number;
114
+ reason?: string;
115
+ gaps?: number[]; // seqs re-anchored after a quarantined line (known gap, not tamper)
116
+ }
117
+
118
+ export interface AppendLog {
119
+ path: string;
120
+ append(input: AppendInput): LogEvent;
121
+ readAll(): LogEvent[];
122
+ readAfter(seq: number): LogEvent[];
123
+ /** UUID lookup over the live file (dedupe retries without re-appending). */
124
+ hasId(id: string): boolean;
125
+ getById(id: string): LogEvent | null;
126
+ maxSeq(): number;
127
+ lastHash(): string;
128
+ verify(): VerifyResult;
129
+ /** true when open truncated a torn tail write (kill mid-append). */
130
+ repairedTail: boolean;
131
+ /** mid-file lines skipped into <path>.quarantine. */
132
+ quarantined: number;
133
+ /** first kept seq after a sweep (0 when never truncated); excise forgives below this. */
134
+ sealedBelow: number;
135
+ close(): void;
136
+ }
137
+
138
+ export function openLog(path: string, defaultDeviceId: string, signer?: (ev: LogEvent) => string): AppendLog {
139
+ const dir = dirname(path);
140
+ if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true });
141
+ const quarantinePath = path + '.quarantine';
142
+ const seenQ = new Set<string>();
143
+ if (existsSync(quarantinePath)) {
144
+ for (const line of readFileSync(quarantinePath, 'utf8').split('\n')) {
145
+ const t = line.trim();
146
+ if (!t) continue;
147
+ try {
148
+ const parsed: unknown = JSON.parse(t);
149
+ if (parsed && typeof parsed === 'object' && 'sha' in parsed && typeof parsed.sha === 'string') {
150
+ seenQ.add(parsed.sha);
151
+ }
152
+ } catch {
153
+ seenQ.add(createHash('sha256').update(t, 'utf8').digest('hex'));
154
+ }
155
+ }
156
+ }
157
+ const noteQuarantine = (lineNo: number, raw: string): void => {
158
+ const sha = createHash('sha256').update(raw, 'utf8').digest('hex');
159
+ if (seenQ.has(sha)) return; // reopening must not duplicate forensics
160
+ seenQ.add(sha);
161
+ appendFileSync(quarantinePath, JSON.stringify({ line: lineNo, sha, raw }) + '\n');
162
+ };
163
+ let events: LogEvent[] = [];
164
+ let marker: TruncateMarker | null = null;
165
+ const gapBefore = new Set<number>(); // kept seqs following a skipped line
166
+ let skipPending = false;
167
+ let quarantined = 0;
168
+ let repairedTail = false;
169
+ if (existsSync(path)) {
170
+ const raw = readFileSync(path, 'utf8');
171
+ const parts = raw.split('\n');
172
+ // Last content line, not assumed position: a kill can land between the
173
+ // content write and its trailing newline, leaving a complete event with
174
+ // no terminator. Skipping it would silently drop a durable event.
175
+ let lastIdx = parts.length - 1;
176
+ while (lastIdx >= 0 && !parts[lastIdx].trim()) lastIdx--;
177
+ // Offsets locate a torn tail for truncation.
178
+ let off = 0;
179
+ const starts: number[] = parts.map((p) => {
180
+ const s = off;
181
+ off += Buffer.byteLength(p, 'utf8') + 1;
182
+ return s;
183
+ });
184
+ for (let i = 0; i <= lastIdx; i++) {
185
+ const t = parts[i].trim();
186
+ if (!t) continue;
187
+ try {
188
+ const parsed: unknown = JSON.parse(t);
189
+ if (isMarker(parsed)) {
190
+ marker = parsed; // last marker wins; old ones are dropped by sweep
191
+ skipPending = false;
192
+ continue;
193
+ }
194
+ const ev = parsed as LogEvent;
195
+ if (skipPending) {
196
+ gapBefore.add(ev.seq);
197
+ skipPending = false;
198
+ }
199
+ events.push(ev);
200
+ } catch {
201
+ if (i === lastIdx) {
202
+ // Torn tail: the write never completed, so no event was ever
203
+ // durable — truncate it, keep a forensic copy, carry on.
204
+ const f = openSync(path, 'r+');
205
+ try {
206
+ ftruncateSync(f, starts[i]);
207
+ fsyncSync(f);
208
+ } finally {
209
+ closeSync(f);
210
+ }
211
+ try {
212
+ const dfd = openSync(dirname(path), 'r');
213
+ try {
214
+ fsyncSync(dfd);
215
+ } finally {
216
+ closeSync(dfd);
217
+ }
218
+ } catch {
219
+ /* platforms without directory fsync: file fsync still holds */
220
+ }
221
+ noteQuarantine(i + 1, t + ' /* torn tail, truncated on open */');
222
+ repairedTail = true;
223
+ } else {
224
+ noteQuarantine(i + 1, t);
225
+ quarantined += 1;
226
+ skipPending = true;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ const byId = new Map<string, LogEvent>(events.map((e) => [e.id, e]));
232
+ const keptMax = events.length === 0 ? 0 : Math.max(...events.map((e) => e.seq));
233
+ let nextSeq = Math.max(marker?.next_seq ?? 1, keptMax + 1); // seqs never reused across sweeps
234
+ let tip = events.length === 0 ? (marker?.tip ?? GENESIS_HASH) : events[events.length - 1].hash;
235
+
236
+ // Long-lived append fd so every write can be followed by fsync.
237
+ const fd = openSync(path, 'a');
238
+
239
+ return {
240
+ path,
241
+ append(input: AppendInput): LogEvent {
242
+ if (!input.type || typeof input.type !== 'string') {
243
+ throw new Error('log.append: type must be a non-empty string');
244
+ }
245
+ if (input.id !== undefined && byId.has(input.id)) {
246
+ throw new Error(`log.append: duplicate id ${input.id}`);
247
+ }
248
+ const core: Omit<LogEvent, 'hash'> = {
249
+ id: input.id ?? randomUUID(),
250
+ seq: nextSeq,
251
+ type: input.type,
252
+ actor: input.actor,
253
+ device_id: input.device_id ?? defaultDeviceId,
254
+ ts_device: input.ts_device ?? Date.now(),
255
+ payload: input.payload ?? {},
256
+ prev_hash: tip,
257
+ origin_seq: input.origin_seq,
258
+ origin_device: input.origin_device,
259
+ server_time: input.server_time,
260
+ };
261
+ const ev: LogEvent = { ...core, hash: hashFor(core) };
262
+ // Source signing: the device key covers the chain hash BEFORE the line
263
+ // hits disk, so relayed copies always carry a verifiable signature and
264
+ // trusted-mode receivers apply (not dead-letter) legitimate traffic.
265
+ // input.signature is never preserved: it covered the origin hash, which
266
+ // the local re-hash replaced — keeping it would fail verification under
267
+ // the local device_id and brick relayed pulls.
268
+ if (signer && !ev.signature) ev.signature = signer(ev);
269
+ writeSync(fd, JSON.stringify(ev) + '\n');
270
+ fsyncSync(fd); // durable before ack — offline means the disk is the server
271
+ events.push(ev);
272
+ byId.set(ev.id, ev);
273
+ nextSeq += 1;
274
+ tip = ev.hash;
275
+ return ev;
276
+ },
277
+ readAll(): LogEvent[] {
278
+ return [...events];
279
+ },
280
+ readAfter(seq: number): LogEvent[] {
281
+ return events.filter((e) => e.seq > seq);
282
+ },
283
+ hasId(id: string): boolean {
284
+ return byId.has(id);
285
+ },
286
+ getById(id: string): LogEvent | null {
287
+ return byId.get(id) ?? null;
288
+ },
289
+ maxSeq(): number {
290
+ return nextSeq - 1;
291
+ },
292
+ lastHash(): string {
293
+ return tip;
294
+ },
295
+ verify(): VerifyResult {
296
+ let prev = marker?.tip ?? GENESIS_HASH; // swept prefix re-anchors here
297
+ let expectedSeq = marker?.truncated_before ?? 1; // seqs run contiguously from here
298
+ const gaps: number[] = [];
299
+ for (const e of events) {
300
+ const { hash, signature: _s, countersignatures: _c, ...core } = e;
301
+ void _s;
302
+ void _c;
303
+ if (hashFor(core) !== hash) {
304
+ return { ok: false, at: e.seq, reason: 'hash mismatch (tampered payload?)' };
305
+ }
306
+ // A re-chained survivor hides a surgically removed prefix/suffix from
307
+ // the prev_hash check, so seq continuity is enforced too. Only a
308
+ // forward jump onto a quarantined gap is forgiven (re-anchored below).
309
+ const seqForgiven = e.seq > expectedSeq && gapBefore.has(e.seq);
310
+ if (e.seq !== expectedSeq && !seqForgiven) {
311
+ return {
312
+ ok: false,
313
+ at: e.seq,
314
+ reason:
315
+ e.seq < expectedSeq
316
+ ? 'duplicate seq (forked/edited log?)'
317
+ : 'seq gap (truncated/edited log?)',
318
+ };
319
+ }
320
+ if (e.prev_hash !== prev) {
321
+ if (!gapBefore.has(e.seq)) {
322
+ return { ok: false, at: e.seq, reason: 'prev_hash mismatch (truncated/edited log?)' };
323
+ }
324
+ }
325
+ if (seqForgiven || e.prev_hash !== prev) {
326
+ gaps.push(e.seq); // known gap: predecessor was quarantined, re-anchor
327
+ }
328
+ prev = hash;
329
+ expectedSeq = e.seq + 1;
330
+ }
331
+ return gaps.length > 0 ? { ok: true, gaps } : { ok: true };
332
+ },
333
+ repairedTail,
334
+ quarantined,
335
+ sealedBelow: marker?.truncated_before ?? 0,
336
+ close(): void {
337
+ try {
338
+ closeSync(fd);
339
+ } catch {
340
+ /* already closed */
341
+ }
342
+ },
343
+ };
344
+ }