fieldlog 0.15.0 → 0.15.1

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 CHANGED
@@ -1,333 +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
- }
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
+ }