@mu-crew/mu 3.9.0 → 3.10.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,2792 @@
1
+ import {
2
+ defaultStateDir,
3
+ xdgStateHome
4
+ } from "./chunk-4PACTXAB.js";
5
+
6
+ // src/capture.ts
7
+ var UNRESOLVED = "<unresolved>";
8
+ var OP_CTX_DDL = `
9
+ CREATE TEMP TABLE IF NOT EXISTS _op_ctx (
10
+ group_id TEXT,
11
+ actor TEXT,
12
+ intent TEXT,
13
+ applying INTEGER NOT NULL DEFAULT 0
14
+ );
15
+ CREATE TEMP TABLE IF NOT EXISTS _op_clock (
16
+ now_ms INTEGER NOT NULL DEFAULT 0
17
+ );
18
+ CREATE TEMP TABLE IF NOT EXISTS _op_dying (
19
+ kind TEXT NOT NULL,
20
+ id INTEGER NOT NULL,
21
+ key TEXT NOT NULL,
22
+ PRIMARY KEY (kind, id)
23
+ );
24
+ `;
25
+ var OP_CTX_SEED = `
26
+ INSERT INTO _op_ctx (group_id, actor, intent, applying)
27
+ SELECT NULL, NULL, NULL, 0 WHERE NOT EXISTS (SELECT 1 FROM _op_ctx);
28
+ INSERT INTO _op_clock (now_ms)
29
+ SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM _op_clock);
30
+ `;
31
+ var NOT_APPLYING = "(SELECT applying FROM _op_ctx) = 0";
32
+ var ADVANCE_CLOCK = `
33
+ UPDATE _op_clock SET now_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER);
34
+ UPDATE machine_identity
35
+ SET last_wall = MAX(last_wall, (SELECT now_ms FROM _op_clock)),
36
+ last_counter = CASE WHEN (SELECT now_ms FROM _op_clock) > last_wall
37
+ THEN 0 ELSE last_counter + 1 END
38
+ WHERE id = 1;`;
39
+ var HLC = `(SELECT printf('%015d.%06d.%s', last_wall, last_counter, machine_id)
40
+ FROM machine_identity WHERE id = 1)`;
41
+ var OP_COLS = "(hlc, machine_id, group_id, actor, intent, entity, key, op, payload, created_at)";
42
+ var OP_CTX_VALUES = `
43
+ (SELECT machine_id FROM machine_identity WHERE id = 1),
44
+ COALESCE((SELECT group_id FROM _op_ctx), lower(hex(randomblob(8)))),
45
+ (SELECT actor FROM _op_ctx),
46
+ (SELECT intent FROM _op_ctx)`;
47
+ var CREATED_AT = `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
48
+ var wsKey = (idExpr) => `COALESCE(
49
+ (SELECT w.name FROM workstreams w WHERE w.id = ${idExpr}),
50
+ (SELECT d.key FROM _op_dying d WHERE d.kind = 'workstream' AND d.id = ${idExpr}),
51
+ '${UNRESOLVED}')`;
52
+ var taskKey = (idExpr) => `COALESCE(
53
+ (SELECT ${wsKey("t.workstream_id")} || '/' || t.local_id
54
+ FROM tasks t WHERE t.id = ${idExpr}),
55
+ (SELECT d.key FROM _op_dying d WHERE d.kind = 'task' AND d.id = ${idExpr}),
56
+ '${UNRESOLVED}')`;
57
+ function changedPayload(cols) {
58
+ let expr = "'{}'";
59
+ for (const col of cols) {
60
+ expr = `CASE WHEN NEW.${col} IS NOT OLD.${col}
61
+ THEN json_insert(${expr}, '$.${col}', NEW.${col})
62
+ ELSE ${expr} END`;
63
+ }
64
+ return expr;
65
+ }
66
+ function fullPayload(cols) {
67
+ const pairs = cols.map((c) => `'${c}', NEW.${c}`).join(", ");
68
+ return `json_object(${pairs})`;
69
+ }
70
+ function fullOldPayload(cols) {
71
+ const pairs = cols.map((c) => `'${c}', OLD.${c}`).join(", ");
72
+ return `json_object(${pairs})`;
73
+ }
74
+ function noteTombstonePayload(keyExpr, cols) {
75
+ return `CASE WHEN EXISTS (
76
+ SELECT 1 FROM ops WHERE entity = 'note' AND key = ${keyExpr} AND op = 'put'
77
+ ) THEN '{}' ELSE ${fullOldPayload(cols)} END`;
78
+ }
79
+ function anyChanged(cols) {
80
+ return cols.map((c) => `NEW.${c} IS NOT OLD.${c}`).join("\n OR ");
81
+ }
82
+ function emitOp(entity, opKind, keyExpr, payloadExpr) {
83
+ return `${ADVANCE_CLOCK}
84
+ INSERT INTO ops ${OP_COLS} VALUES (
85
+ ${HLC},${OP_CTX_VALUES},
86
+ '${entity}',
87
+ ${keyExpr},
88
+ '${opKind}',
89
+ ${payloadExpr},
90
+ ${CREATED_AT}
91
+ );`;
92
+ }
93
+ var CAPTURED_COLUMNS = {
94
+ workstreams: ["name", "created_at"],
95
+ tasks: [
96
+ "local_id",
97
+ "title",
98
+ "status",
99
+ "substate",
100
+ "impact",
101
+ "effort_days",
102
+ "owner_id",
103
+ "created_at",
104
+ "updated_at"
105
+ ],
106
+ task_notes: ["author", "content", "created_at"],
107
+ task_edges: ["created_at"]
108
+ };
109
+ var KEY_COLUMNS = {
110
+ workstreams: ["name"],
111
+ tasks: ["workstream_id", "local_id"],
112
+ task_notes: ["id", "task_id"],
113
+ task_edges: ["from_task_id", "to_task_id"]
114
+ };
115
+ function keyGuard(table) {
116
+ const cols = KEY_COLUMNS[table];
117
+ const msg = `mu: ${table}.${cols.join("/")} is part of the natural key that every op is filed under; changing it would desync the ops log, sync and undo. Add a new row (mu task add / mu workstream init) instead of moving or renaming one.`;
118
+ return `
119
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_${table}_key
120
+ BEFORE UPDATE OF ${cols.join(", ")} ON ${table}
121
+ WHEN ${anyChanged(cols)}
122
+ BEGIN
123
+ SELECT RAISE(ABORT, '${msg.replaceAll("'", "''")}');
124
+ END;`;
125
+ }
126
+ var DYING_STASH = (kind, keyExpr) => `INSERT OR REPLACE INTO _op_dying (kind, id, key) VALUES ('${kind}', OLD.id, ${keyExpr});`;
127
+ function buildTriggerDdl() {
128
+ const wsCols = CAPTURED_COLUMNS.workstreams;
129
+ const taskCols = CAPTURED_COLUMNS.tasks;
130
+ const noteCols = CAPTURED_COLUMNS.task_notes;
131
+ const edgeCols = CAPTURED_COLUMNS.task_edges;
132
+ const taskKeyNew = `${wsKey("NEW.workstream_id")} || '/' || NEW.local_id`;
133
+ const taskKeyOld = `${wsKey("OLD.workstream_id")} || '/' || OLD.local_id`;
134
+ const edgeKeyNew = `${taskKey("NEW.from_task_id")} || '->' || ${taskKey("NEW.to_task_id")}`;
135
+ const edgeKeyOld = `${taskKey("OLD.from_task_id")} || '->' || ${taskKey("OLD.to_task_id")}`;
136
+ return `
137
+ -- \u2500\u2500\u2500 natural-key guards (see KEY_COLUMNS) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
138
+ ${keyGuard("workstreams")}
139
+ ${keyGuard("tasks")}
140
+ ${keyGuard("task_notes")}
141
+ ${keyGuard("task_edges")}
142
+
143
+ -- \u2500\u2500\u2500 workstreams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
144
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_workstreams_ins
145
+ AFTER INSERT ON workstreams WHEN ${NOT_APPLYING}
146
+ BEGIN
147
+ ${emitOp("workstream", "put", "NEW.name", fullPayload(wsCols))}
148
+ END;
149
+
150
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_workstreams_upd
151
+ AFTER UPDATE ON workstreams WHEN ${NOT_APPLYING}
152
+ AND (${anyChanged(wsCols)})
153
+ BEGIN
154
+ ${emitOp("workstream", "put", "NEW.name", changedPayload(wsCols))}
155
+ END;
156
+
157
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_workstreams_del
158
+ BEFORE DELETE ON workstreams WHEN ${NOT_APPLYING}
159
+ BEGIN
160
+ ${DYING_STASH("workstream", "OLD.name")}
161
+ ${emitOp("workstream", "del", "OLD.name", "'{}'")}
162
+ END;
163
+
164
+ -- \u2500\u2500\u2500 tasks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
165
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_tasks_ins
166
+ AFTER INSERT ON tasks WHEN ${NOT_APPLYING}
167
+ BEGIN
168
+ ${emitOp("task", "put", taskKeyNew, fullPayload(taskCols))}
169
+ END;
170
+
171
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_tasks_upd
172
+ AFTER UPDATE ON tasks WHEN ${NOT_APPLYING}
173
+ AND (${anyChanged(taskCols)})
174
+ BEGIN
175
+ ${emitOp("task", "put", taskKeyNew, changedPayload(taskCols))}
176
+ END;
177
+
178
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_tasks_del
179
+ BEFORE DELETE ON tasks WHEN ${NOT_APPLYING}
180
+ BEGIN
181
+ ${DYING_STASH("task", taskKeyOld)}
182
+ ${emitOp("task", "del", taskKeyOld, "'{}'")}
183
+ END;
184
+
185
+ -- \u2500\u2500\u2500 task_notes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
186
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_notes_ins
187
+ AFTER INSERT ON task_notes WHEN ${NOT_APPLYING}
188
+ BEGIN
189
+ ${emitOp("note", "put", `${taskKey("NEW.task_id")} || '#' || NEW.id`, fullPayload(noteCols))}
190
+ END;
191
+
192
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_notes_upd
193
+ AFTER UPDATE ON task_notes WHEN ${NOT_APPLYING}
194
+ AND (${anyChanged(noteCols)})
195
+ BEGIN
196
+ ${emitOp("note", "put", `${taskKey("NEW.task_id")} || '#' || NEW.id`, changedPayload(noteCols))}
197
+ END;
198
+
199
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_notes_del
200
+ BEFORE DELETE ON task_notes WHEN ${NOT_APPLYING}
201
+ BEGIN
202
+ ${emitOp("note", "del", `${taskKey("OLD.task_id")} || '#' || OLD.id`, noteTombstonePayload(`${taskKey("OLD.task_id")} || '#' || OLD.id`, noteCols))}
203
+ END;
204
+
205
+ -- \u2500\u2500\u2500 task_edges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
206
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_edges_ins
207
+ AFTER INSERT ON task_edges WHEN ${NOT_APPLYING}
208
+ BEGIN
209
+ ${emitOp("edge", "put", edgeKeyNew, fullPayload(edgeCols))}
210
+ END;
211
+
212
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_edges_upd
213
+ AFTER UPDATE ON task_edges WHEN ${NOT_APPLYING}
214
+ AND (${anyChanged(edgeCols)})
215
+ BEGIN
216
+ ${emitOp("edge", "put", edgeKeyNew, changedPayload(edgeCols))}
217
+ END;
218
+
219
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_edges_del
220
+ BEFORE DELETE ON task_edges WHEN ${NOT_APPLYING}
221
+ BEGIN
222
+ ${emitOp("edge", "del", edgeKeyOld, "'{}'")}
223
+ END;
224
+ `;
225
+ }
226
+ var CAPTURE_TRIGGER_DDL = buildTriggerDdl();
227
+ function captureBookkeepingChanges(db, groupId) {
228
+ const row = db.prepare(
229
+ `SELECT COUNT(*) AS ops,
230
+ COALESCE(SUM(op = 'del' AND entity IN ('workstream', 'task')), 0) AS stashed
231
+ FROM ops WHERE group_id = ?`
232
+ ).get(groupId);
233
+ return 3 * row.ops + row.stashed;
234
+ }
235
+ function installCapture(db) {
236
+ db.exec(OP_CTX_DDL);
237
+ db.exec(OP_CTX_SEED);
238
+ db.exec(CAPTURE_TRIGGER_DDL);
239
+ }
240
+
241
+ // src/tasks/status.ts
242
+ var TASK_STATUSES = ["OPEN", "IN_PROGRESS", "CLOSED"];
243
+ function isTaskStatus(s) {
244
+ return TASK_STATUSES.includes(s);
245
+ }
246
+ var TASK_STATUS_LIST = TASK_STATUSES.join(" | ");
247
+ var TASK_SUBSTATES = {
248
+ OPEN: ["todo", "parked", "triage"],
249
+ IN_PROGRESS: ["active"],
250
+ CLOSED: ["done", "rejected", "wontfix", "duplicate", "superseded"]
251
+ };
252
+ var DEFAULT_SUBSTATE = { OPEN: "todo", IN_PROGRESS: "active", CLOSED: "done" };
253
+ var TASK_SUBSTATE_ROWS = TASK_STATUSES.flatMap(
254
+ (status) => TASK_SUBSTATES[status].map(
255
+ (substate) => [status, substate, substate === DEFAULT_SUBSTATE[status] ? 1 : 0]
256
+ )
257
+ );
258
+ function isValidPair(status, substate) {
259
+ if (!isTaskStatus(status)) return false;
260
+ return TASK_SUBSTATES[status].includes(substate);
261
+ }
262
+ var LEGACY_STATUS_PAIRS = {
263
+ REJECTED: { status: "CLOSED", substate: "rejected" },
264
+ DEFERRED: { status: "OPEN", substate: "parked" }
265
+ };
266
+ function mapLegacyStatus(value) {
267
+ const pair = LEGACY_STATUS_PAIRS[value];
268
+ return pair ? { ...pair } : null;
269
+ }
270
+ function resolvePair(status, substate) {
271
+ const legacy = mapLegacyStatus(status);
272
+ if (legacy) return legacy;
273
+ if (!isTaskStatus(status)) return null;
274
+ if (typeof substate === "string" && isValidPair(status, substate)) {
275
+ return { status, substate };
276
+ }
277
+ return { status, substate: DEFAULT_SUBSTATE[status] };
278
+ }
279
+ function formatPair(pair) {
280
+ return pair.substate === DEFAULT_SUBSTATE[pair.status] ? pair.status : `${pair.status}/${pair.substate}`;
281
+ }
282
+
283
+ // src/db.ts
284
+ import { randomUUID } from "crypto";
285
+ import { mkdirSync } from "fs";
286
+ import { homedir, hostname } from "os";
287
+ import { dirname, join, resolve } from "path";
288
+ import Database from "better-sqlite3";
289
+ var statementCache = /* @__PURE__ */ new WeakMap();
290
+ function prepareCached(db, sql) {
291
+ let byDb = statementCache.get(db);
292
+ if (byDb === void 0) {
293
+ byDb = /* @__PURE__ */ new Map();
294
+ statementCache.set(db, byDb);
295
+ }
296
+ let stmt = byDb.get(sql);
297
+ if (stmt === void 0) {
298
+ stmt = db.prepare(sql);
299
+ byDb.set(sql, stmt);
300
+ }
301
+ return stmt;
302
+ }
303
+ function defaultDbPath() {
304
+ if (process.env.MU_DB_PATH) return process.env.MU_DB_PATH;
305
+ return join(defaultStateDir(), "mu.db");
306
+ }
307
+ function openDb(options = {}) {
308
+ const path = options.path ?? defaultDbPath();
309
+ refuseUserDbDuringTests(path);
310
+ mkdirSync(dirname(path), { recursive: true });
311
+ const db = new Database(path, { readonly: options.readonly ?? false });
312
+ if (!options.readonly) {
313
+ db.pragma("busy_timeout = 5000");
314
+ const detectedVersion = detectExistingSchemaVersion(db);
315
+ if (detectedVersion !== null && detectedVersion > CURRENT_SCHEMA_VERSION) {
316
+ try {
317
+ db.close();
318
+ } catch {
319
+ }
320
+ throw new SchemaTooNewError(detectedVersion, CURRENT_SCHEMA_VERSION);
321
+ }
322
+ if (detectedVersion !== null && detectedVersion < MIN_ACCEPTED_SCHEMA_VERSION) {
323
+ try {
324
+ db.close();
325
+ } catch {
326
+ }
327
+ throw new SchemaTooOldError(detectedVersion, MIN_ACCEPTED_SCHEMA_VERSION);
328
+ }
329
+ db.pragma("journal_mode = WAL");
330
+ db.pragma("foreign_keys = ON");
331
+ if (detectedVersion !== CURRENT_SCHEMA_VERSION || !schemaIsCurrent(db)) applySchema(db);
332
+ seedMachineIdentity(db);
333
+ installCapture(db);
334
+ } else {
335
+ db.pragma("foreign_keys = ON");
336
+ }
337
+ return db;
338
+ }
339
+ function refuseUserDbDuringTests(path) {
340
+ const inTest = process.env.VITEST !== void 0 || process.env.NODE_ENV === "test";
341
+ if (!inTest) return;
342
+ const home = process.env.HOME ?? homedir();
343
+ const realDb = resolve(join(xdgStateHome(home), "mu", "mu.db"));
344
+ if (resolve(path) === realDb) {
345
+ throw new Error(
346
+ `openDb refused: tests must NEVER write to the user DB (${realDb}). Set MU_DB_PATH to a per-test temp path (test/_runCli.ts does this automatically) or pass an explicit { path } argument. The leak source is the call site of openDb in this stack frame.`
347
+ );
348
+ }
349
+ }
350
+ var WorkstreamNotFoundError = class extends Error {
351
+ constructor(workstream) {
352
+ super(`no such workstream: ${workstream}`);
353
+ this.workstream = workstream;
354
+ }
355
+ workstream;
356
+ name = "WorkstreamNotFoundError";
357
+ errorNextSteps() {
358
+ return [
359
+ { intent: "List workstreams", command: "mu workstream list" },
360
+ {
361
+ intent: "Initialise this workstream",
362
+ command: `mu workstream init ${this.workstream}`
363
+ }
364
+ ];
365
+ }
366
+ };
367
+ function resolveWorkstreamId(db, workstream) {
368
+ const row = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
369
+ if (!row) throw new WorkstreamNotFoundError(workstream);
370
+ return row.id;
371
+ }
372
+ function tryResolveWorkstreamId(db, workstream) {
373
+ const row = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
374
+ return row ? row.id : null;
375
+ }
376
+ var SchemaTooOldError = class extends Error {
377
+ constructor(detectedVersion, requiredVersion) {
378
+ super(
379
+ `Detected v${detectedVersion} schema; v${requiredVersion} is required. mu ships NO in-place migration \u2014 your v${detectedVersion} DB is untouched. Back it up or move it aside, then start a fresh v${requiredVersion} DB.`
380
+ );
381
+ this.detectedVersion = detectedVersion;
382
+ this.requiredVersion = requiredVersion;
383
+ }
384
+ detectedVersion;
385
+ requiredVersion;
386
+ name = "SchemaTooOldError";
387
+ errorNextSteps() {
388
+ return [
389
+ {
390
+ intent: "Keep a copy of the old DB before anything else",
391
+ command: `mu db backup "$HOME/mu-v${this.detectedVersion}-backup.db"`
392
+ },
393
+ {
394
+ intent: "Move the old DB aside without deleting it",
395
+ command: `mv "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}" "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}.old"`
396
+ },
397
+ {
398
+ intent: `Migrate the old DB into a fresh v${this.requiredVersion} DB (run in a mu git checkout: the npm package does not ship scripts/)`,
399
+ command: `npx tsx scripts/migrate.ts "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}.old" --out /tmp/mu-v${this.requiredVersion}.db`
400
+ },
401
+ {
402
+ intent: "Verify the fresh DB before swapping",
403
+ command: `MU_DB_PATH=/tmp/mu-v${this.requiredVersion}.db mu doctor --deep`
404
+ }
405
+ ];
406
+ }
407
+ };
408
+ var SchemaTooNewError = class extends Error {
409
+ constructor(detectedVersion, supportedVersion) {
410
+ super(`DB is v${detectedVersion}; this mu understands up to v${supportedVersion}. Upgrade mu.`);
411
+ this.detectedVersion = detectedVersion;
412
+ this.supportedVersion = supportedVersion;
413
+ }
414
+ detectedVersion;
415
+ supportedVersion;
416
+ name = "SchemaTooNewError";
417
+ errorNextSteps() {
418
+ return [{ intent: "Check which mu is on PATH", command: "which mu && mu --version" }];
419
+ }
420
+ };
421
+ function detectExistingSchemaVersion(db) {
422
+ const hasVersionTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'").get();
423
+ if (hasVersionTable) {
424
+ const row = db.prepare("SELECT version FROM schema_version WHERE id = 1").get();
425
+ return row?.version ?? null;
426
+ }
427
+ const hasWorkstreams = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workstreams'").get();
428
+ if (hasWorkstreams) return 1;
429
+ return null;
430
+ }
431
+ function seedMachineIdentity(db) {
432
+ const row = db.prepare("SELECT COUNT(*) AS count FROM machine_identity").get();
433
+ if (row.count !== 0) return;
434
+ db.prepare(
435
+ `INSERT OR IGNORE INTO machine_identity (id, machine_id, hostname, created_at)
436
+ VALUES (1, ?, ?, ?)`
437
+ ).run(randomUUID(), hostname(), (/* @__PURE__ */ new Date()).toISOString());
438
+ }
439
+ function applySchema(db) {
440
+ db.exec("BEGIN IMMEDIATE");
441
+ try {
442
+ db.exec(CURRENT_SCHEMA);
443
+ const seed = db.prepare(
444
+ "INSERT OR IGNORE INTO task_substates (status, substate, is_default) VALUES (?, ?, ?)"
445
+ );
446
+ for (const [status, substate, isDefault] of TASK_SUBSTATE_ROWS) {
447
+ seed.run(status, substate, isDefault);
448
+ }
449
+ db.exec("COMMIT");
450
+ } catch (err) {
451
+ db.exec("ROLLBACK");
452
+ const msg = err instanceof Error ? err.message : String(err);
453
+ if (!/already exists/i.test(msg)) throw err;
454
+ }
455
+ db.prepare("INSERT OR IGNORE INTO schema_version (id, version) VALUES (1, ?)").run(
456
+ CURRENT_SCHEMA_VERSION
457
+ );
458
+ }
459
+ var expectedCache;
460
+ function expectedObjects() {
461
+ if (expectedCache !== void 0) return expectedCache;
462
+ const names = (re) => [...CURRENT_SCHEMA.matchAll(re)].map((m) => m[1]).filter((n) => n !== void 0);
463
+ const views = /* @__PURE__ */ new Map();
464
+ for (const sql of [READY_VIEW_SQL, BLOCKED_VIEW_SQL, GOALS_VIEW_SQL]) {
465
+ const m = /CREATE VIEW (\w+)[^;]*/.exec(sql);
466
+ if (m?.[1] !== void 0) views.set(m[1], m[0]);
467
+ }
468
+ expectedCache = {
469
+ tables: names(/CREATE TABLE IF NOT EXISTS (\w+)/g),
470
+ indexes: names(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)/g),
471
+ views
472
+ };
473
+ return expectedCache;
474
+ }
475
+ function schemaIsCurrent(db) {
476
+ const rows = db.prepare("SELECT type, name, sql FROM sqlite_master").all();
477
+ const have = new Map(rows.map((r) => [`${r.type}:${r.name}`, r.sql]));
478
+ const expected = expectedObjects();
479
+ if (expected.tables.some((t) => !have.has(`table:${t}`))) return false;
480
+ if (expected.indexes.some((i) => !have.has(`index:${i}`))) return false;
481
+ for (const [name, sql] of expected.views) {
482
+ if (have.get(`view:${name}`) !== sql) return false;
483
+ }
484
+ const pairs = new Set(
485
+ db.prepare("SELECT status, substate FROM task_substates").all().map((r) => `${r.status}/${r.substate}`)
486
+ );
487
+ return TASK_SUBSTATE_ROWS.every(([status, substate]) => pairs.has(`${status}/${substate}`));
488
+ }
489
+ var CURRENT_SCHEMA_VERSION = 11;
490
+ var MIN_ACCEPTED_SCHEMA_VERSION = 11;
491
+ var EXPECTED_TABLES = [
492
+ "agents",
493
+ "machine_identity",
494
+ "ops",
495
+ "schema_version",
496
+ "sync_fingerprints",
497
+ "sync_peers",
498
+ "task_edges",
499
+ "task_notes",
500
+ "task_substates",
501
+ "tasks",
502
+ "vcs_workspaces",
503
+ "workstreams"
504
+ ];
505
+ var SYNCED_ENTITIES = ["workstream", "task", "edge", "note", "message"];
506
+ var MACHINE_LOCAL_ENTITIES = ["agent", "workspace", "event", "broadcast"];
507
+ var PORTABLE_TABLES = ["task_edges", "task_notes", "tasks", "workstreams"];
508
+ var MACHINE_LOCAL_TABLES = [
509
+ "agents",
510
+ "machine_identity",
511
+ "ops",
512
+ "schema_version",
513
+ "sync_fingerprints",
514
+ "sync_peers",
515
+ "task_substates",
516
+ "vcs_workspaces"
517
+ ];
518
+ var READY_VIEW_SQL = `
519
+ DROP VIEW IF EXISTS ready;
520
+ CREATE VIEW ready AS
521
+ SELECT t.*
522
+ FROM tasks t
523
+ WHERE t.status = 'OPEN'
524
+ AND t.substate NOT IN ('parked', 'triage')
525
+ AND NOT EXISTS (
526
+ SELECT 1
527
+ FROM task_edges e
528
+ JOIN tasks b ON e.from_task_id = b.id
529
+ WHERE e.to_task_id = t.id
530
+ AND b.status <> 'CLOSED'
531
+ );
532
+ `;
533
+ var BLOCKED_VIEW_SQL = `
534
+ DROP VIEW IF EXISTS blocked;
535
+ CREATE VIEW blocked AS
536
+ SELECT t.*
537
+ FROM tasks t
538
+ WHERE t.status = 'OPEN'
539
+ AND EXISTS (
540
+ SELECT 1
541
+ FROM task_edges e
542
+ JOIN tasks b ON e.from_task_id = b.id
543
+ WHERE e.to_task_id = t.id
544
+ AND b.status <> 'CLOSED'
545
+ );
546
+ `;
547
+ var GOALS_VIEW_SQL = `
548
+ DROP VIEW IF EXISTS goals;
549
+ CREATE VIEW goals AS
550
+ SELECT t.*
551
+ FROM tasks t
552
+ WHERE t.status <> 'CLOSED'
553
+ AND NOT EXISTS (
554
+ SELECT 1 FROM task_edges WHERE from_task_id = t.id
555
+ );
556
+ `;
557
+ var CURRENT_SCHEMA = `
558
+ -- \u2500\u2500\u2500 Schema versioning \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
559
+ --
560
+ -- Single-row table tracking which schema version this DB is at. Migrations
561
+ -- read and update this; the row is INSERT-OR-IGNOREd by applySchema with
562
+ -- the current version on a fresh DB.
563
+ CREATE TABLE IF NOT EXISTS schema_version (
564
+ id INTEGER PRIMARY KEY CHECK (id = 1),
565
+ version INTEGER NOT NULL
566
+ );
567
+
568
+ -- machine_identity: one durable identity per DB/machine, seeded by
569
+ -- openDb after schema creation. hostname is advisory only.
570
+ --
571
+ -- last_wall / last_counter are the persisted hybrid logical clock
572
+ -- (src/hlc.ts, VOCABULARY \xA7 HLC). They live here because every mu
573
+ -- invocation is a fresh process \u2014 an in-memory counter would reset
574
+ -- constantly and mint duplicate HLCs, which UNIQUE (machine_id, hlc)
575
+ -- on the ops table would then reject.
576
+ CREATE TABLE IF NOT EXISTS machine_identity (
577
+ id INTEGER PRIMARY KEY CHECK (id = 1),
578
+ machine_id TEXT NOT NULL,
579
+ hostname TEXT,
580
+ created_at TEXT NOT NULL,
581
+ last_wall INTEGER NOT NULL DEFAULT 0,
582
+ last_counter INTEGER NOT NULL DEFAULT 0
583
+ );
584
+
585
+ -- \u2500\u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
586
+
587
+ -- workstreams: top of the hierarchy. name stays globally unique
588
+ -- because it IS a tmux session name; no <scope_id> column because
589
+ -- there's no parent.
590
+ CREATE TABLE IF NOT EXISTS workstreams (
591
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
592
+ name TEXT UNIQUE NOT NULL,
593
+ created_at TEXT NOT NULL -- ISO 8601
594
+ );
595
+
596
+
597
+ -- agents: one row per managed pane. Per-workstream unique on name.
598
+ CREATE TABLE IF NOT EXISTS agents (
599
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
600
+ workstream_id INTEGER NOT NULL REFERENCES workstreams (id) ON DELETE CASCADE,
601
+ name TEXT NOT NULL, -- per-workstream unique
602
+ cli TEXT NOT NULL DEFAULT 'pi',
603
+ pane_id TEXT NOT NULL,
604
+ status TEXT NOT NULL, -- DEPRECATED (2.0): always 'spawning'; runtime state lives in murmur. Drop at the first v11 migration.
605
+ role TEXT NOT NULL DEFAULT 'full-access',
606
+ tab TEXT,
607
+ created_at TEXT NOT NULL,
608
+ updated_at TEXT NOT NULL,
609
+ UNIQUE (workstream_id, name),
610
+ CHECK (status IN (
611
+ 'spawning', 'busy', 'needs_input', 'needs_permission',
612
+ 'free', 'unreachable', 'terminated'
613
+ )),
614
+ CHECK (role IN ('full-access', 'read-only'))
615
+ );
616
+
617
+ CREATE INDEX IF NOT EXISTS idx_agents_workstream ON agents (workstream_id);
618
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
619
+
620
+ -- task_substates: the legal (status, substate) pairs. Seeded from
621
+ -- TASK_SUBSTATE_ROWS (src/tasks/status.ts) by applySchema; exactly one
622
+ -- default per status.
623
+ CREATE TABLE IF NOT EXISTS task_substates (
624
+ status TEXT NOT NULL,
625
+ substate TEXT NOT NULL,
626
+ is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
627
+ PRIMARY KEY (status, substate)
628
+ );
629
+
630
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_substates_one_default
631
+ ON task_substates (status) WHERE is_default = 1;
632
+
633
+ -- tasks: per-workstream unique on local_id (TRULY local now \u2014
634
+ -- different workstreams may reuse the same local_id).
635
+ CREATE TABLE IF NOT EXISTS tasks (
636
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
637
+ workstream_id INTEGER NOT NULL REFERENCES workstreams (id) ON DELETE CASCADE,
638
+ local_id TEXT NOT NULL, -- per-workstream unique
639
+ title TEXT NOT NULL,
640
+ status TEXT NOT NULL DEFAULT 'OPEN',
641
+ -- OPEN | IN_PROGRESS | CLOSED \u2014 see VOCABULARY.md.
642
+ substate TEXT NOT NULL,
643
+ -- Qualifies status. No DEFAULT on purpose: a writer that forgets it
644
+ -- must fail loudly rather than silently pick one.
645
+ impact INTEGER NOT NULL,
646
+ effort_days REAL NOT NULL,
647
+ owner_id INTEGER REFERENCES agents (id) ON DELETE SET NULL,
648
+ created_at TEXT NOT NULL,
649
+ updated_at TEXT NOT NULL,
650
+ UNIQUE (workstream_id, local_id),
651
+ CHECK (impact BETWEEN 1 AND 100),
652
+ CHECK (effort_days > 0),
653
+ FOREIGN KEY (status, substate) REFERENCES task_substates (status, substate)
654
+ DEFERRABLE INITIALLY DEFERRED
655
+ );
656
+
657
+ CREATE INDEX IF NOT EXISTS idx_tasks_workstream ON tasks (workstream_id);
658
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status);
659
+ CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
660
+
661
+ -- task_edges: composite PK by pair. INTEGER FKs into tasks.id.
662
+ CREATE TABLE IF NOT EXISTS task_edges (
663
+ from_task_id INTEGER NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
664
+ to_task_id INTEGER NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
665
+ created_at TEXT NOT NULL,
666
+ PRIMARY KEY (from_task_id, to_task_id),
667
+ CHECK (from_task_id <> to_task_id)
668
+ );
669
+
670
+ CREATE INDEX IF NOT EXISTS idx_task_edges_to ON task_edges (to_task_id);
671
+
672
+ -- task_notes: append-only context. author stays free-text
673
+ -- ("orchestrator", "user", "\u03C0 - mu", "system") \u2014 not always a
674
+ -- registered agent.
675
+ CREATE TABLE IF NOT EXISTS task_notes (
676
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
677
+ task_id INTEGER NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
678
+ author TEXT,
679
+ content TEXT NOT NULL,
680
+ created_at TEXT NOT NULL
681
+ );
682
+
683
+ CREATE INDEX IF NOT EXISTS idx_task_notes_task ON task_notes (task_id);
684
+
685
+ -- \u2500\u2500\u2500 The ops log (VISION.md \xA7 2b, VOCABULARY.md \xA7 op / ops log) \u2500\u2500\u2500\u2500\u2500\u2500
686
+ --
687
+ -- The single append-only record of every change. Sync, undo, and
688
+ -- history are all queries or replays over this one table.
689
+ --
690
+ -- Deliberately FK-free. An op must stay readable after the row (and
691
+ -- the workstream) it records is gone \u2014 a tombstone op for a destroyed
692
+ -- workstream is the whole point \u2014 so 'key' holds the NATURAL key
693
+ -- ('<workstream>/<local_id>'), never a surrogate id. That is also why
694
+ -- keys don't collide across machines.
695
+ --
696
+ -- seq local-only append cursor (AUTOINCREMENT never recycles)
697
+ -- hlc the ORDERING key across machines (VOCABULARY \xA7 HLC)
698
+ -- machine_id which peer wrote this op
699
+ -- group_id the undo unit: all ops of one operator action
700
+ -- actor who caused it (may not be a registered worker)
701
+ -- intent semantic label ('task.close', 'agent.spawn')
702
+ -- entity/key what it addresses ('task', 'mu/ops-log')
703
+ -- op 'put' (semantic partial update) | 'del' (tombstone)
704
+ -- payload JSON of ONLY the columns that changed
705
+ --
706
+ -- UNIQUE (machine_id, hlc) is load-bearing: it makes ingest idempotent
707
+ -- for free, which is what lets 'mu sync --repair' be nothing more than
708
+ -- "re-read that peer's segment from zero".
709
+ CREATE TABLE IF NOT EXISTS ops (
710
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
711
+ hlc TEXT NOT NULL,
712
+ machine_id TEXT NOT NULL,
713
+ group_id TEXT NOT NULL,
714
+ actor TEXT,
715
+ intent TEXT,
716
+ entity TEXT NOT NULL,
717
+ key TEXT NOT NULL,
718
+ op TEXT NOT NULL CHECK (op IN ('put','del')),
719
+ payload TEXT NOT NULL,
720
+ created_at TEXT NOT NULL,
721
+ UNIQUE (machine_id, hlc)
722
+ );
723
+
724
+ -- Four indexes, one per read shape that actually exists:
725
+ -- hlc \u2014 replay/rebuild walks the log in HLC order.
726
+ -- entity,key \u2014 "what happened to this task" (history + per-field merge).
727
+ -- group_id \u2014 'mu undo <group>' gathers one action's ops.
728
+ -- machine_id,entity,hlc \u2014 sync flush seeks this machine's SYNCED ops
729
+ -- past the segment's last hlc, so a trailing run of
730
+ -- machine-local ops (agent.*) costs nothing per flush.
731
+ -- (Other machine_id lookups ride the UNIQUE (machine_id, hlc) index;
732
+ -- seq is the PK. No speculative index beyond these.)
733
+ CREATE INDEX IF NOT EXISTS idx_ops_hlc ON ops (hlc);
734
+ CREATE INDEX IF NOT EXISTS idx_ops_entity_key ON ops (entity, key);
735
+ CREATE INDEX IF NOT EXISTS idx_ops_group ON ops (group_id);
736
+ CREATE INDEX IF NOT EXISTS idx_ops_machine_entity_hlc ON ops (machine_id, entity, hlc);
737
+
738
+ -- sync_peers: one row per known peer, holding its watermark \u2014 how far
739
+ -- into that peer's segment we have applied. One integer suffices
740
+ -- because segments are append-only and ordered. Rows are created on
741
+ -- demand at first ingest; there is no membership list to configure.
742
+ -- A Syncthing conflict copy gets its own row, keyed by its file stem.
743
+ -- last_seen_at is the ISO time of the last watermark write.
744
+ CREATE TABLE IF NOT EXISTS sync_peers (
745
+ machine_id TEXT PRIMARY KEY,
746
+ last_applied_seq INTEGER NOT NULL DEFAULT 0,
747
+ last_seen_at TEXT
748
+ );
749
+
750
+ -- sync_fingerprints: the caught-up fingerprint, '<size> <mtimeMs>
751
+ -- <manifest sha256>' of a peer segment, recorded with the watermark
752
+ -- when ingest read that segment cleanly to its end (src/segments.ts
753
+ -- segmentCaughtUp). Keyed like sync_peers; a watermark write without a
754
+ -- clean read deletes the row. A cache: losing it costs one full read.
755
+ -- Additive, so no schema bump: an older mu ignores it, and any
756
+ -- watermark it writes can no longer match a stale fingerprint.
757
+ CREATE TABLE IF NOT EXISTS sync_fingerprints (
758
+ machine_id TEXT PRIMARY KEY,
759
+ fingerprint TEXT NOT NULL
760
+ );
761
+
762
+ -- vcs_workspaces: one isolated working copy per agent.
763
+ -- UNIQUE (agent_id) enforces the 1:1 invariant; workstream_id is
764
+ -- denormalised for query convenience. path is UNIQUE because two
765
+ -- agents pointing at the same on-disk workspace would defeat the
766
+ -- purpose.
767
+ CREATE TABLE IF NOT EXISTS vcs_workspaces (
768
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
769
+ agent_id INTEGER NOT NULL UNIQUE REFERENCES agents (id) ON DELETE CASCADE,
770
+ workstream_id INTEGER NOT NULL REFERENCES workstreams (id) ON DELETE CASCADE,
771
+ backend TEXT NOT NULL CHECK (backend IN ('jj', 'sl', 'git', 'none')),
772
+ path TEXT NOT NULL UNIQUE,
773
+ parent_ref TEXT,
774
+ created_at TEXT NOT NULL
775
+ );
776
+
777
+ CREATE INDEX IF NOT EXISTS idx_vcs_workspaces_workstream ON vcs_workspaces (workstream_id);
778
+
779
+ -- \u2500\u2500\u2500 Views (replaced when stale so the latest definition wins) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
780
+ -- See READY_VIEW_SQL / BLOCKED_VIEW_SQL / GOALS_VIEW_SQL above for the
781
+ -- canonical DDL \u2014 interpolated here so applySchema is one db.exec().
782
+ ${READY_VIEW_SQL}
783
+ ${BLOCKED_VIEW_SQL}
784
+ ${GOALS_VIEW_SQL}
785
+ `;
786
+
787
+ // src/hlc.ts
788
+ var WALL_WIDTH = 15;
789
+ var COUNTER_WIDTH = 6;
790
+ var SEP = ".";
791
+ var MAX_WALL = 10 ** WALL_WIDTH - 1;
792
+ var MAX_COUNTER = 10 ** COUNTER_WIDTH - 1;
793
+ var HlcParseError = class extends Error {
794
+ constructor(value) {
795
+ super(`Malformed HLC: ${JSON.stringify(value)}`);
796
+ this.value = value;
797
+ this.name = "HlcParseError";
798
+ }
799
+ value;
800
+ };
801
+ var HlcOverflowError = class extends Error {
802
+ constructor(field, value) {
803
+ super(
804
+ field === "counter" ? `HLC counter overflow (${value} > ${MAX_COUNTER}): more than ${MAX_COUNTER + 1} ops in one millisecond` : `HLC wall-clock overflow (${value} > ${MAX_WALL}): system clock is far in the future`
805
+ );
806
+ this.field = field;
807
+ this.value = value;
808
+ this.name = "HlcOverflowError";
809
+ }
810
+ field;
811
+ value;
812
+ };
813
+ var MachineIdentityMissingError = class extends Error {
814
+ constructor() {
815
+ super("machine_identity row (id = 1) is missing; the DB was not opened via openDb()");
816
+ this.name = "MachineIdentityMissingError";
817
+ }
818
+ };
819
+ function formatHlc(hlc) {
820
+ const { wallMs, counter, machineId } = hlc;
821
+ if (!Number.isInteger(wallMs) || wallMs < 0) throw new HlcParseError(String(wallMs));
822
+ if (!Number.isInteger(counter) || counter < 0) throw new HlcParseError(String(counter));
823
+ if (wallMs > MAX_WALL) throw new HlcOverflowError("wall", wallMs);
824
+ if (counter > MAX_COUNTER) throw new HlcOverflowError("counter", counter);
825
+ if (machineId.length === 0 || machineId.includes(SEP)) throw new HlcParseError(machineId);
826
+ return `${String(wallMs).padStart(WALL_WIDTH, "0")}${SEP}${String(counter).padStart(
827
+ COUNTER_WIDTH,
828
+ "0"
829
+ )}${SEP}${machineId}`;
830
+ }
831
+ function parseHlc(value) {
832
+ const parts = value.split(SEP);
833
+ if (parts.length !== 3) throw new HlcParseError(value);
834
+ const [wall, counter, machineId] = parts;
835
+ if (wall === void 0 || counter === void 0 || machineId === void 0) {
836
+ throw new HlcParseError(value);
837
+ }
838
+ if (wall.length !== WALL_WIDTH || counter.length !== COUNTER_WIDTH || machineId.length === 0) {
839
+ throw new HlcParseError(value);
840
+ }
841
+ if (!/^\d+$/.test(wall) || !/^\d+$/.test(counter)) throw new HlcParseError(value);
842
+ return { wallMs: Number(wall), counter: Number(counter), machineId };
843
+ }
844
+ function compareHlc(a, b) {
845
+ if (a < b) return -1;
846
+ if (a > b) return 1;
847
+ return 0;
848
+ }
849
+ function nextHlc(db, now = Date.now()) {
850
+ const row = db.prepare(
851
+ `UPDATE machine_identity
852
+ SET last_wall = MAX(last_wall, @now),
853
+ last_counter = CASE WHEN @now > last_wall THEN 0 ELSE last_counter + 1 END
854
+ WHERE id = 1
855
+ RETURNING machine_id, last_wall, last_counter`
856
+ ).get({ now: Math.floor(now) });
857
+ if (!row) throw new MachineIdentityMissingError();
858
+ return formatHlc({ wallMs: row.last_wall, counter: row.last_counter, machineId: row.machine_id });
859
+ }
860
+ function receiveHlc(db, remoteHlc, now = Date.now()) {
861
+ const remote = parseHlc(remoteHlc);
862
+ const wallNow = Math.floor(now);
863
+ const read = prepareCached(
864
+ db,
865
+ "SELECT machine_id, last_wall, last_counter FROM machine_identity WHERE id = 1"
866
+ );
867
+ const write = prepareCached(
868
+ db,
869
+ "UPDATE machine_identity SET last_wall = @wall, last_counter = @counter WHERE id = 1"
870
+ );
871
+ const advance = () => {
872
+ const row = read.get();
873
+ if (!row) throw new MachineIdentityMissingError();
874
+ const wall = Math.max(row.last_wall, remote.wallMs, wallNow);
875
+ const localAtMax = row.last_wall === wall;
876
+ const remoteAtMax = remote.wallMs === wall;
877
+ let counter;
878
+ if (!localAtMax && !remoteAtMax) {
879
+ counter = 0;
880
+ } else if (localAtMax && remoteAtMax) {
881
+ counter = Math.max(row.last_counter, remote.counter) + 1;
882
+ } else if (localAtMax) {
883
+ counter = row.last_counter + 1;
884
+ } else {
885
+ counter = remote.counter + 1;
886
+ }
887
+ if (counter > MAX_COUNTER) throw new HlcOverflowError("counter", counter);
888
+ write.run({ wall, counter });
889
+ return formatHlc({ wallMs: wall, counter, machineId: row.machine_id });
890
+ };
891
+ if (db.inTransaction) return advance();
892
+ return db.transaction(advance).immediate();
893
+ }
894
+
895
+ // src/op-context.ts
896
+ import { randomUUID as randomUUID2 } from "crypto";
897
+ function currentOpContext(db) {
898
+ const row = db.prepare("SELECT group_id, actor, intent, applying FROM _op_ctx").get();
899
+ if (!row) return { groupId: null, actor: null, intent: null, applying: false };
900
+ return {
901
+ groupId: row.group_id,
902
+ actor: row.actor,
903
+ intent: row.intent,
904
+ applying: row.applying !== 0
905
+ };
906
+ }
907
+ function writeCtx(db, row) {
908
+ prepareCached(
909
+ db,
910
+ `UPDATE _op_ctx SET group_id = @group_id, actor = @actor,
911
+ intent = @intent, applying = @applying`
912
+ ).run(row);
913
+ }
914
+ function readCtx(db) {
915
+ return prepareCached(db, "SELECT group_id, actor, intent, applying FROM _op_ctx").get();
916
+ }
917
+ function withOpContext(db, ctx, fn) {
918
+ const previous = readCtx(db);
919
+ if (!previous) {
920
+ return fn();
921
+ }
922
+ const groupId = ctx.group === "new" ? randomUUID2() : ctx.group !== void 0 ? ctx.group : previous.group_id ?? randomUUID2();
923
+ const intent = ctx.intent !== void 0 ? ctx.intent : previous.intent ?? ctx.intentIfUnset ?? null;
924
+ writeCtx(db, {
925
+ group_id: groupId,
926
+ // An explicit undefined inherits; only a provided value overrides.
927
+ actor: ctx.actor !== void 0 ? ctx.actor : previous.actor,
928
+ intent,
929
+ applying: previous.applying
930
+ });
931
+ try {
932
+ return fn();
933
+ } finally {
934
+ writeCtx(db, previous);
935
+ }
936
+ }
937
+ function withCaptureSuppressed(db, fn) {
938
+ const previous = readCtx(db);
939
+ if (!previous) return fn();
940
+ writeCtx(db, { ...previous, applying: 1 });
941
+ try {
942
+ return fn();
943
+ } finally {
944
+ writeCtx(db, previous);
945
+ }
946
+ }
947
+
948
+ // src/file-lock.ts
949
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
950
+ import { join as join2 } from "path";
951
+ function locksDir() {
952
+ return join2(defaultStateDir(), "locks");
953
+ }
954
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 15e3;
955
+ var DEFAULT_STALE_LOCK_MS = 3e4;
956
+ var RETRY_INTERVAL_MS = 25;
957
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
958
+ function resolveTimeout(opts) {
959
+ const envVar = opts?.timeoutEnvVar;
960
+ if (envVar !== void 0) {
961
+ const raw = process.env[envVar];
962
+ if (raw !== void 0 && raw !== "") {
963
+ const n = Number.parseInt(raw, 10);
964
+ if (Number.isFinite(n) && n >= 0) return n;
965
+ }
966
+ }
967
+ return opts?.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
968
+ }
969
+ async function isStale(path, staleMs) {
970
+ try {
971
+ const s = await stat(path);
972
+ return Date.now() - s.mtimeMs > staleMs;
973
+ } catch {
974
+ return false;
975
+ }
976
+ }
977
+ async function withFileLock(lockPath, label, fn, opts) {
978
+ const staleMs = opts?.staleLockMs ?? DEFAULT_STALE_LOCK_MS;
979
+ const deadline = Date.now() + resolveTimeout(opts);
980
+ const onUnavailable = opts?.onUnavailable;
981
+ const unlocked = async () => onUnavailable === void 0 ? await fn() : onUnavailable();
982
+ let held = false;
983
+ try {
984
+ await mkdir(locksDir(), { recursive: true });
985
+ } catch {
986
+ return await unlocked();
987
+ }
988
+ while (!held) {
989
+ try {
990
+ await mkdir(lockPath);
991
+ held = true;
992
+ } catch (err) {
993
+ const code = err.code;
994
+ if (code !== "EEXIST") return await unlocked();
995
+ if (await isStale(lockPath, staleMs)) {
996
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {
997
+ });
998
+ continue;
999
+ }
1000
+ if (Date.now() >= deadline) return await unlocked();
1001
+ await sleep(RETRY_INTERVAL_MS);
1002
+ }
1003
+ }
1004
+ await writeFile(
1005
+ join2(lockPath, "meta.json"),
1006
+ JSON.stringify({ pid: process.pid, label, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() })
1007
+ ).catch(() => {
1008
+ });
1009
+ try {
1010
+ return await fn();
1011
+ } finally {
1012
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {
1013
+ });
1014
+ }
1015
+ }
1016
+ async function readFileLockMeta(lockPath) {
1017
+ try {
1018
+ const raw = await readFile(join2(lockPath, "meta.json"), "utf8");
1019
+ return JSON.parse(raw);
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ // src/legacy-ops.ts
1026
+ var LEGACY_LOG_ONLY_INTENTS = /* @__PURE__ */ new Set(["workstream.export"]);
1027
+ function isLegacyLogOnlyIntent(intent) {
1028
+ return intent !== null && LEGACY_LOG_ONLY_INTENTS.has(intent);
1029
+ }
1030
+ var LEGACY_LOG_ONLY_SQL_EXCLUSION = `(intent IS NULL OR intent NOT IN (${[
1031
+ ...LEGACY_LOG_ONLY_INTENTS
1032
+ ].map((i) => `'${i}'`).join(", ")}))`;
1033
+ var LEGACY_INTENT_SYNONYMS = /* @__PURE__ */ new Map([
1034
+ ["workstream.teardown", ["workstream.destroy"]]
1035
+ ]);
1036
+ function intentSpellings(intent) {
1037
+ const legacy = LEGACY_INTENT_SYNONYMS.get(intent);
1038
+ return legacy === void 0 ? [intent] : [intent, ...legacy];
1039
+ }
1040
+
1041
+ // src/apply.ts
1042
+ var OpEntityNotSyncedError = class extends Error {
1043
+ constructor(entity) {
1044
+ super(
1045
+ `op entity ${JSON.stringify(entity)} is machine-local and must never arrive from a peer (synced entities: ${SYNCED_ENTITIES.join(", ")})`
1046
+ );
1047
+ this.entity = entity;
1048
+ this.name = "OpEntityNotSyncedError";
1049
+ }
1050
+ entity;
1051
+ };
1052
+ var OpKeyMalformedError = class extends Error {
1053
+ constructor(entity, key) {
1054
+ super(`malformed ${entity} key: ${JSON.stringify(key)}`);
1055
+ this.entity = entity;
1056
+ this.key = key;
1057
+ this.name = "OpKeyMalformedError";
1058
+ }
1059
+ entity;
1060
+ key;
1061
+ };
1062
+ function isSyncedEntity(entity) {
1063
+ return SYNCED_ENTITIES.includes(entity);
1064
+ }
1065
+ function isKnownMachineLocalEntity(entity) {
1066
+ return MACHINE_LOCAL_ENTITIES.includes(entity);
1067
+ }
1068
+ function fieldHlc(db, op, field) {
1069
+ const row = prepareCached(
1070
+ db,
1071
+ `SELECT MAX(hlc) AS hlc
1072
+ FROM ops
1073
+ WHERE entity = @entity
1074
+ AND key = @key
1075
+ AND op = 'put'
1076
+ AND hlc <> @self
1077
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
1078
+ AND json_type(payload, '$.' || @field) IS NOT NULL`
1079
+ ).get({ entity: op.entity, key: op.key, self: op.hlc, field });
1080
+ return row?.hlc ?? null;
1081
+ }
1082
+ function tombstoneHlc(db, op) {
1083
+ const row = prepareCached(
1084
+ db,
1085
+ `SELECT MAX(hlc) AS hlc
1086
+ FROM ops
1087
+ WHERE entity = @entity AND key = @key AND op = 'del' AND hlc <> @self`
1088
+ ).get({ entity: op.entity, key: op.key, self: op.hlc });
1089
+ return row?.hlc ?? null;
1090
+ }
1091
+ function anyHlc(db, op, kind) {
1092
+ const row = prepareCached(
1093
+ db,
1094
+ `SELECT MAX(hlc) AS hlc
1095
+ FROM ops
1096
+ WHERE entity = @entity AND key = @key AND op = @kind AND hlc <> @self`
1097
+ ).get({ entity: op.entity, key: op.key, kind, self: op.hlc });
1098
+ return row?.hlc ?? null;
1099
+ }
1100
+ function wins(candidate, incumbent) {
1101
+ if (incumbent === null) return true;
1102
+ return compareHlc(candidate, incumbent) > 0;
1103
+ }
1104
+ function decodePayload(payload) {
1105
+ const parsed = JSON.parse(payload);
1106
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1107
+ throw new SyntaxError(`op payload must be a JSON object, got ${payload.slice(0, 40)}`);
1108
+ }
1109
+ const entries = [];
1110
+ for (const [field, value] of Object.entries(parsed)) {
1111
+ if (value === null || typeof value === "string" || typeof value === "number") {
1112
+ entries.push([field, value]);
1113
+ continue;
1114
+ }
1115
+ if (typeof value === "boolean") {
1116
+ entries.push([field, value ? 1 : 0]);
1117
+ continue;
1118
+ }
1119
+ throw new SyntaxError(`op payload field ${JSON.stringify(field)} has unsupported type`);
1120
+ }
1121
+ return entries;
1122
+ }
1123
+ function parseTaskKey(key) {
1124
+ const slash = key.indexOf("/");
1125
+ if (slash <= 0 || slash === key.length - 1) throw new OpKeyMalformedError("task", key);
1126
+ const workstream = key.slice(0, slash);
1127
+ const localId = key.slice(slash + 1);
1128
+ if (workstream.length === 0 || localId.length === 0) {
1129
+ throw new OpKeyMalformedError("task", key);
1130
+ }
1131
+ return { workstream, localId };
1132
+ }
1133
+ function parseNoteKey(key) {
1134
+ const hash = key.lastIndexOf("#");
1135
+ if (hash <= 0 || hash === key.length - 1) throw new OpKeyMalformedError("note", key);
1136
+ return { taskKey: key.slice(0, hash) };
1137
+ }
1138
+ function parseEdgeKey(key) {
1139
+ const arrow = key.indexOf("->");
1140
+ if (arrow <= 0) throw new OpKeyMalformedError("edge", key);
1141
+ const blocker = key.slice(0, arrow);
1142
+ const blocked = key.slice(arrow + 2);
1143
+ if (blocker.length === 0 || blocked.length === 0) throw new OpKeyMalformedError("edge", key);
1144
+ return { blocker, blocked };
1145
+ }
1146
+ var NEVER_APPLY = /* @__PURE__ */ new Set(["owner_id", "local_id", "name", "id", "workstream_id", "task_id"]);
1147
+ function ensureWorkstreamRow(db, name) {
1148
+ const existing = prepareCached(db, "SELECT id FROM workstreams WHERE name = ?").get(name);
1149
+ if (existing) return existing.id;
1150
+ prepareCached(db, "INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(
1151
+ name,
1152
+ (/* @__PURE__ */ new Date()).toISOString()
1153
+ );
1154
+ const created = prepareCached(db, "SELECT id FROM workstreams WHERE name = ?").get(name);
1155
+ if (!created) throw new Error(`failed to create workstream row for ${name}`);
1156
+ return created.id;
1157
+ }
1158
+ function taskRowId(db, key) {
1159
+ const { workstream, localId } = parseTaskKey(key);
1160
+ const row = prepareCached(
1161
+ db,
1162
+ `SELECT t.id AS id
1163
+ FROM tasks t
1164
+ JOIN workstreams w ON w.id = t.workstream_id
1165
+ WHERE w.name = ? AND t.local_id = ?`
1166
+ ).get(workstream, localId);
1167
+ return row?.id ?? null;
1168
+ }
1169
+ function applyFieldLww(db, op, table, rowId, entries) {
1170
+ const applied = [];
1171
+ for (const [field, value] of entries) {
1172
+ if (NEVER_APPLY.has(field)) continue;
1173
+ if (!wins(op.hlc, fieldHlc(db, op, field))) continue;
1174
+ const current = prepareCached(db, `SELECT ${field} AS v FROM ${table} WHERE id = ?`).get(
1175
+ rowId
1176
+ );
1177
+ if (current !== void 0 && current.v === value) continue;
1178
+ prepareCached(db, `UPDATE ${table} SET ${field} = ? WHERE id = ?`).run(value, rowId);
1179
+ applied.push(field);
1180
+ }
1181
+ return applied;
1182
+ }
1183
+ var APPLIABLE_COLUMNS = {
1184
+ tasks: ["title", "status", "substate", "impact", "effort_days", "created_at", "updated_at"],
1185
+ workstreams: ["created_at"]
1186
+ };
1187
+ function filterAppliable(table, entries) {
1188
+ const allowed = APPLIABLE_COLUMNS[table];
1189
+ return entries.filter(([field]) => allowed.includes(field));
1190
+ }
1191
+ function applyTaskPut(db, op) {
1192
+ const { workstream, localId } = parseTaskKey(op.key);
1193
+ const entries = [];
1194
+ for (const entry of filterAppliable("tasks", decodePayload(op.payload))) {
1195
+ const [field, value] = entry;
1196
+ if (field === "substate") continue;
1197
+ if (field === "status") {
1198
+ if (typeof value !== "string") continue;
1199
+ const legacy = mapLegacyStatus(value);
1200
+ if (legacy) entries.push(["status", legacy.status]);
1201
+ else if (isTaskStatus(value)) entries.push(entry);
1202
+ continue;
1203
+ }
1204
+ entries.push(entry);
1205
+ }
1206
+ const pending = { hlc: op.hlc, substate: carriedSubstate(JSON.parse(op.payload)) };
1207
+ const existing = taskRowId(db, op.key);
1208
+ if (existing === null) {
1209
+ const tomb = tombstoneHlc(db, op);
1210
+ if (tomb !== null && !wins(op.hlc, tomb)) {
1211
+ return { changed: false, appliedFields: [], skipped: "older-than-tombstone" };
1212
+ }
1213
+ const wsId = ensureWorkstreamRow(db, workstream);
1214
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1215
+ prepareCached(
1216
+ db,
1217
+ `INSERT INTO tasks (workstream_id, local_id, title, status, substate, impact, effort_days,
1218
+ owner_id, created_at, updated_at)
1219
+ VALUES (?, ?, ?, 'OPEN', 'todo', 50, 1, NULL, ?, ?)`
1220
+ ).run(wsId, localId, localId, now, now);
1221
+ const rowId = taskRowId(db, op.key);
1222
+ if (rowId === null) throw new Error(`failed to create task row for ${op.key}`);
1223
+ const applied2 = applyFieldLww(db, op, "tasks", rowId, entries);
1224
+ if (repairTaskPair(db, rowId, pending)) applied2.push("substate");
1225
+ return { changed: true, appliedFields: applied2 };
1226
+ }
1227
+ const applied = applyFieldLww(db, op, "tasks", existing, entries);
1228
+ if (repairTaskPair(db, existing, pending)) applied.push("substate");
1229
+ return applied.length > 0 ? { changed: true, appliedFields: applied } : { changed: false, appliedFields: [], skipped: "older-than-current" };
1230
+ }
1231
+ function carriedSubstate(payload) {
1232
+ if (payload === null || typeof payload !== "object") return null;
1233
+ const { status, substate } = payload;
1234
+ if (typeof status === "string") {
1235
+ const legacy = mapLegacyStatus(status);
1236
+ if (legacy) return legacy.substate;
1237
+ if (typeof substate !== "string" && isTaskStatus(status)) return DEFAULT_SUBSTATE[status];
1238
+ }
1239
+ return typeof substate === "string" ? substate : null;
1240
+ }
1241
+ function repairTaskPair(db, rowId, pending) {
1242
+ const row = prepareCached(
1243
+ db,
1244
+ `SELECT t.status, t.substate, w.name || '/' || t.local_id AS key
1245
+ FROM tasks t JOIN workstreams w ON w.id = t.workstream_id
1246
+ WHERE t.id = ?`
1247
+ ).get(rowId);
1248
+ if (!row) return false;
1249
+ let winner = null;
1250
+ const writers = prepareCached(
1251
+ db,
1252
+ `SELECT hlc, payload FROM ops
1253
+ WHERE entity = 'task' AND key = ? AND op = 'put' AND hlc <> ?
1254
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
1255
+ AND (json_type(payload, '$.substate') IS NOT NULL
1256
+ OR json_type(payload, '$.status') IS NOT NULL)
1257
+ ORDER BY hlc DESC`
1258
+ ).iterate(row.key, pending?.hlc ?? "");
1259
+ for (const w of writers) {
1260
+ const substate = carriedSubstate(JSON.parse(w.payload));
1261
+ if (substate === null) continue;
1262
+ winner = { hlc: w.hlc, substate };
1263
+ break;
1264
+ }
1265
+ if (pending && pending.substate !== null && wins(pending.hlc, winner?.hlc ?? null)) {
1266
+ winner = { hlc: pending.hlc, substate: pending.substate };
1267
+ }
1268
+ const target = resolvePair(row.status, winner?.substate ?? row.substate);
1269
+ if (target === null || target.substate === row.substate) return false;
1270
+ prepareCached(db, "UPDATE tasks SET substate = ? WHERE id = ?").run(target.substate, rowId);
1271
+ return true;
1272
+ }
1273
+ function applyWorkstreamPut(db, op) {
1274
+ const name = op.key;
1275
+ if (name.length === 0) throw new OpKeyMalformedError("workstream", name);
1276
+ const entries = filterAppliable("workstreams", decodePayload(op.payload));
1277
+ const existing = prepareCached(db, "SELECT id FROM workstreams WHERE name = ?").get(name);
1278
+ if (!existing) {
1279
+ const tomb = tombstoneHlc(db, op);
1280
+ if (tomb !== null && !wins(op.hlc, tomb)) {
1281
+ return { changed: false, appliedFields: [], skipped: "older-than-tombstone" };
1282
+ }
1283
+ const id = ensureWorkstreamRow(db, name);
1284
+ const applied2 = applyFieldLww(db, op, "workstreams", id, entries);
1285
+ return { changed: true, appliedFields: applied2 };
1286
+ }
1287
+ const applied = applyFieldLww(db, op, "workstreams", existing.id, entries);
1288
+ return applied.length > 0 ? { changed: true, appliedFields: applied } : { changed: false, appliedFields: [], skipped: "older-than-current" };
1289
+ }
1290
+ function applyNotePut(db, op) {
1291
+ const { taskKey: taskKey2 } = parseNoteKey(op.key);
1292
+ const taskId = taskRowId(db, taskKey2);
1293
+ if (taskId === null) {
1294
+ return { changed: false, appliedFields: [], skipped: "absent" };
1295
+ }
1296
+ const entries = decodePayload(op.payload);
1297
+ const author = entries.find(([f]) => f === "author")?.[1] ?? null;
1298
+ const content = entries.find(([f]) => f === "content")?.[1] ?? "";
1299
+ const stamped = entries.find(([f]) => f === "created_at")?.[1];
1300
+ const createdAt = stamped ?? (/* @__PURE__ */ new Date()).toISOString();
1301
+ const authorText = author === null ? null : String(author);
1302
+ const already = prepareCached(
1303
+ db,
1304
+ `SELECT 1 AS present FROM task_notes
1305
+ WHERE task_id = @taskId AND content = @content
1306
+ AND COALESCE(author, '') = COALESCE(@author, '')
1307
+ AND (@stamped IS NULL OR created_at = @stamped)
1308
+ LIMIT 1`
1309
+ ).get({
1310
+ taskId,
1311
+ content: String(content),
1312
+ author: authorText,
1313
+ stamped: stamped === void 0 || stamped === null ? null : String(stamped)
1314
+ });
1315
+ if (already) return { changed: false, appliedFields: [], skipped: "already-present" };
1316
+ const tomb = tombstoneHlc(db, op);
1317
+ if (tomb !== null && !wins(op.hlc, tomb)) {
1318
+ return { changed: false, appliedFields: [], skipped: "older-than-tombstone" };
1319
+ }
1320
+ prepareCached(
1321
+ db,
1322
+ "INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)"
1323
+ ).run(taskId, authorText, String(content), String(createdAt));
1324
+ return { changed: true, appliedFields: ["content"] };
1325
+ }
1326
+ function applyEdgePut(db, op) {
1327
+ const { blocker, blocked } = parseEdgeKey(op.key);
1328
+ const fromId = taskRowId(db, blocker);
1329
+ const toId = taskRowId(db, blocked);
1330
+ if (fromId === null || toId === null) {
1331
+ return { changed: false, appliedFields: [], skipped: "absent" };
1332
+ }
1333
+ const removedAt = anyHlc(db, op, "del");
1334
+ if (removedAt !== null && !wins(op.hlc, removedAt)) {
1335
+ return { changed: false, appliedFields: [], skipped: "older-than-tombstone" };
1336
+ }
1337
+ const existing = prepareCached(
1338
+ db,
1339
+ "SELECT 1 AS present FROM task_edges WHERE from_task_id = ? AND to_task_id = ?"
1340
+ ).get(fromId, toId);
1341
+ if (existing) return { changed: false, appliedFields: [], skipped: "already-present" };
1342
+ const entries = decodePayload(op.payload);
1343
+ const createdAt = entries.find(([f]) => f === "created_at")?.[1] ?? (/* @__PURE__ */ new Date()).toISOString();
1344
+ prepareCached(
1345
+ db,
1346
+ "INSERT INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)"
1347
+ ).run(fromId, toId, String(createdAt));
1348
+ return { changed: true, appliedFields: ["created_at"] };
1349
+ }
1350
+ function applyDel(db, op) {
1351
+ const newestPut = anyHlc(db, op, "put");
1352
+ if (newestPut !== null && !wins(op.hlc, newestPut)) {
1353
+ return { changed: false, appliedFields: [], skipped: "older-than-current" };
1354
+ }
1355
+ switch (op.entity) {
1356
+ case "workstream": {
1357
+ const r = prepareCached(db, "DELETE FROM workstreams WHERE name = ?").run(op.key);
1358
+ return {
1359
+ changed: r.changes > 0,
1360
+ appliedFields: [],
1361
+ ...r.changes > 0 ? {} : { skipped: "absent" }
1362
+ };
1363
+ }
1364
+ case "task": {
1365
+ const rowId = taskRowId(db, op.key);
1366
+ if (rowId === null) return { changed: false, appliedFields: [], skipped: "absent" };
1367
+ prepareCached(db, "DELETE FROM tasks WHERE id = ?").run(rowId);
1368
+ return { changed: true, appliedFields: [] };
1369
+ }
1370
+ case "note": {
1371
+ const { taskKey: taskKey2 } = parseNoteKey(op.key);
1372
+ const taskId = taskRowId(db, taskKey2);
1373
+ if (taskId === null) return { changed: false, appliedFields: [], skipped: "absent" };
1374
+ let entries = decodePayload(op.payload);
1375
+ if (!entries.some(([f, v]) => f === "content" && v !== null)) {
1376
+ const src = prepareCached(
1377
+ db,
1378
+ `SELECT payload FROM ops
1379
+ WHERE entity = 'note' AND key = @key AND op = 'put'
1380
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
1381
+ ORDER BY hlc DESC LIMIT 1`
1382
+ ).get({ key: op.key });
1383
+ if (!src) return { changed: false, appliedFields: [], skipped: "absent" };
1384
+ entries = decodePayload(src.payload);
1385
+ }
1386
+ const content = entries.find(([f]) => f === "content")?.[1] ?? "";
1387
+ const author = entries.find(([f]) => f === "author")?.[1] ?? null;
1388
+ const createdAt = entries.find(([f]) => f === "created_at")?.[1] ?? null;
1389
+ const r = prepareCached(
1390
+ db,
1391
+ `DELETE FROM task_notes
1392
+ WHERE task_id = @taskId AND content = @content
1393
+ AND COALESCE(author, '') = COALESCE(@author, '')
1394
+ AND (@createdAt IS NULL OR created_at = @createdAt)`
1395
+ ).run({
1396
+ taskId,
1397
+ content: String(content),
1398
+ author: author === null ? null : String(author),
1399
+ createdAt: createdAt === null ? null : String(createdAt)
1400
+ });
1401
+ return {
1402
+ changed: r.changes > 0,
1403
+ appliedFields: [],
1404
+ ...r.changes > 0 ? {} : { skipped: "absent" }
1405
+ };
1406
+ }
1407
+ case "edge": {
1408
+ const { blocker, blocked } = parseEdgeKey(op.key);
1409
+ const fromId = taskRowId(db, blocker);
1410
+ const toId = taskRowId(db, blocked);
1411
+ if (fromId === null || toId === null) {
1412
+ return { changed: false, appliedFields: [], skipped: "absent" };
1413
+ }
1414
+ const r = prepareCached(
1415
+ db,
1416
+ "DELETE FROM task_edges WHERE from_task_id = ? AND to_task_id = ?"
1417
+ ).run(fromId, toId);
1418
+ return {
1419
+ changed: r.changes > 0,
1420
+ appliedFields: [],
1421
+ ...r.changes > 0 ? {} : { skipped: "absent" }
1422
+ };
1423
+ }
1424
+ default:
1425
+ return { changed: false, appliedFields: [], skipped: "absent" };
1426
+ }
1427
+ }
1428
+ function applyOp(db, op) {
1429
+ if (isKnownMachineLocalEntity(op.entity)) throw new OpEntityNotSyncedError(op.entity);
1430
+ if (!isSyncedEntity(op.entity)) return { changed: false, appliedFields: [] };
1431
+ return withCaptureSuppressed(db, () => {
1432
+ if (op.op === "del") return applyDel(db, op);
1433
+ switch (op.entity) {
1434
+ case "workstream":
1435
+ return applyWorkstreamPut(db, op);
1436
+ case "task":
1437
+ return db.transaction(() => applyTaskPut(db, op)).immediate();
1438
+ case "note":
1439
+ return applyNotePut(db, op);
1440
+ case "edge":
1441
+ return applyEdgePut(db, op);
1442
+ case "message":
1443
+ return { changed: false, appliedFields: [] };
1444
+ default:
1445
+ return { changed: false, appliedFields: [] };
1446
+ }
1447
+ });
1448
+ }
1449
+ function reprojectDeferredOps(db) {
1450
+ installOpKeyFunctions(db);
1451
+ const rows = [
1452
+ ...db.prepare(
1453
+ `SELECT o.hlc, o.machine_id, o.group_id, o.actor, o.intent, o.entity,
1454
+ o.key, o.op, o.payload
1455
+ FROM ops o
1456
+ JOIN tasks ft ON ft.local_id = edge_local(o.key, 0)
1457
+ JOIN workstreams fw ON fw.id = ft.workstream_id AND fw.name = edge_ws(o.key, 0)
1458
+ JOIN tasks tt ON tt.local_id = edge_local(o.key, 1)
1459
+ JOIN workstreams tw ON tw.id = tt.workstream_id AND tw.name = edge_ws(o.key, 1)
1460
+ WHERE o.entity = 'edge' AND o.op = 'put'
1461
+ AND NOT EXISTS (SELECT 1 FROM task_edges e
1462
+ WHERE e.from_task_id = ft.id AND e.to_task_id = tt.id)
1463
+ AND NOT EXISTS (SELECT 1 FROM ops d
1464
+ WHERE d.entity = 'edge' AND d.key = o.key
1465
+ AND d.op = 'del' AND d.hlc > o.hlc)`
1466
+ ).all(),
1467
+ ...db.prepare(
1468
+ `SELECT o.hlc, o.machine_id, o.group_id, o.actor, o.intent, o.entity,
1469
+ o.key, o.op, o.payload
1470
+ FROM ops o
1471
+ JOIN tasks t ON t.local_id = note_local(o.key)
1472
+ JOIN workstreams w ON w.id = t.workstream_id AND w.name = note_ws(o.key)
1473
+ WHERE o.entity = 'note' AND o.op = 'put'
1474
+ AND NOT EXISTS (SELECT 1 FROM task_notes n
1475
+ WHERE n.task_id = t.id
1476
+ AND n.content = COALESCE(json_extract(o.payload, '$.content'), '')
1477
+ AND COALESCE(n.author, '')
1478
+ = COALESCE(json_extract(o.payload, '$.author'), '')
1479
+ AND n.created_at = json_extract(o.payload, '$.created_at'))
1480
+ AND NOT EXISTS (SELECT 1 FROM ops d
1481
+ WHERE d.entity = 'note' AND d.key = o.key
1482
+ AND d.op = 'del' AND d.hlc > o.hlc)`
1483
+ ).all()
1484
+ ];
1485
+ if (rows.length === 0) return 0;
1486
+ let changed = 0;
1487
+ for (const row of rows.sort((a, b) => compareHlc(a.hlc, b.hlc))) {
1488
+ if (applyOp(db, rowToOp(row)).changed) changed += 1;
1489
+ }
1490
+ return changed;
1491
+ }
1492
+ function rowToOp(row) {
1493
+ return {
1494
+ hlc: row.hlc,
1495
+ machineId: row.machine_id,
1496
+ groupId: row.group_id,
1497
+ actor: row.actor,
1498
+ intent: row.intent,
1499
+ entity: row.entity,
1500
+ key: row.key,
1501
+ op: row.op === "del" ? "del" : "put",
1502
+ payload: row.payload
1503
+ };
1504
+ }
1505
+ function installOpKeyFunctions(db) {
1506
+ const safe = (f) => {
1507
+ try {
1508
+ return f();
1509
+ } catch {
1510
+ return null;
1511
+ }
1512
+ };
1513
+ const opts = { deterministic: true, varargs: false };
1514
+ db.function(
1515
+ "edge_ws",
1516
+ opts,
1517
+ (key, side) => safe(() => {
1518
+ const parts = parseEdgeKey(String(key));
1519
+ return parseTaskKey(Number(side) === 0 ? parts.blocker : parts.blocked).workstream;
1520
+ })
1521
+ );
1522
+ db.function(
1523
+ "edge_local",
1524
+ opts,
1525
+ (key, side) => safe(() => {
1526
+ const parts = parseEdgeKey(String(key));
1527
+ return parseTaskKey(Number(side) === 0 ? parts.blocker : parts.blocked).localId;
1528
+ })
1529
+ );
1530
+ db.function(
1531
+ "note_ws",
1532
+ opts,
1533
+ (key) => safe(() => parseTaskKey(parseNoteKey(String(key)).taskKey).workstream)
1534
+ );
1535
+ db.function(
1536
+ "note_local",
1537
+ opts,
1538
+ (key) => safe(() => parseTaskKey(parseNoteKey(String(key)).taskKey).localId)
1539
+ );
1540
+ }
1541
+ function applyOps(db, ops) {
1542
+ const ordered = [...ops].sort((a, b) => compareHlc(a.hlc, b.hlc));
1543
+ const run = db.transaction(() => ordered.map((op) => applyOp(db, op)));
1544
+ return run.immediate();
1545
+ }
1546
+
1547
+ // src/segment-manifest.ts
1548
+ import { createHash } from "crypto";
1549
+ import { existsSync, readFileSync, writeFileSync } from "fs";
1550
+
1551
+ // src/sha256-resumable.ts
1552
+ var K = new Uint32Array([
1553
+ 1116352408,
1554
+ 1899447441,
1555
+ 3049323471,
1556
+ 3921009573,
1557
+ 961987163,
1558
+ 1508970993,
1559
+ 2453635748,
1560
+ 2870763221,
1561
+ 3624381080,
1562
+ 310598401,
1563
+ 607225278,
1564
+ 1426881987,
1565
+ 1925078388,
1566
+ 2162078206,
1567
+ 2614888103,
1568
+ 3248222580,
1569
+ 3835390401,
1570
+ 4022224774,
1571
+ 264347078,
1572
+ 604807628,
1573
+ 770255983,
1574
+ 1249150122,
1575
+ 1555081692,
1576
+ 1996064986,
1577
+ 2554220882,
1578
+ 2821834349,
1579
+ 2952996808,
1580
+ 3210313671,
1581
+ 3336571891,
1582
+ 3584528711,
1583
+ 113926993,
1584
+ 338241895,
1585
+ 666307205,
1586
+ 773529912,
1587
+ 1294757372,
1588
+ 1396182291,
1589
+ 1695183700,
1590
+ 1986661051,
1591
+ 2177026350,
1592
+ 2456956037,
1593
+ 2730485921,
1594
+ 2820302411,
1595
+ 3259730800,
1596
+ 3345764771,
1597
+ 3516065817,
1598
+ 3600352804,
1599
+ 4094571909,
1600
+ 275423344,
1601
+ 430227734,
1602
+ 506948616,
1603
+ 659060556,
1604
+ 883997877,
1605
+ 958139571,
1606
+ 1322822218,
1607
+ 1537002063,
1608
+ 1747873779,
1609
+ 1955562222,
1610
+ 2024104815,
1611
+ 2227730452,
1612
+ 2361852424,
1613
+ 2428436474,
1614
+ 2756734187,
1615
+ 3204031479,
1616
+ 3329325298
1617
+ ]);
1618
+ var INITIAL_H = "6a09e667bb67ae853c6ef372a54ff53a510e527f9b05688c1f83d9ab5be0cd19";
1619
+ function sha256Initial() {
1620
+ return { h: INITIAL_H, n: 0, tail: "" };
1621
+ }
1622
+ function wordsOf(hex) {
1623
+ const h = new Uint32Array(8);
1624
+ for (let i = 0; i < 8; i++) h[i] = Number.parseInt(hex.slice(i * 8, i * 8 + 8), 16);
1625
+ return h;
1626
+ }
1627
+ function hexOf(h) {
1628
+ let out = "";
1629
+ for (const word of h) out += word.toString(16).padStart(8, "0");
1630
+ return out;
1631
+ }
1632
+ var W = new Uint32Array(64);
1633
+ function compress(h, data, offset, end) {
1634
+ let h0 = h[0] ?? 0;
1635
+ let h1 = h[1] ?? 0;
1636
+ let h2 = h[2] ?? 0;
1637
+ let h3 = h[3] ?? 0;
1638
+ let h4 = h[4] ?? 0;
1639
+ let h5 = h[5] ?? 0;
1640
+ let h6 = h[6] ?? 0;
1641
+ let h7 = h[7] ?? 0;
1642
+ for (let p = offset; p + 64 <= end; p += 64) {
1643
+ for (let i = 0; i < 16; i++) {
1644
+ const j = p + i * 4;
1645
+ W[i] = (data[j] ?? 0) << 24 | (data[j + 1] ?? 0) << 16 | (data[j + 2] ?? 0) << 8 | (data[j + 3] ?? 0);
1646
+ }
1647
+ for (let i = 16; i < 64; i++) {
1648
+ const w15 = W[i - 15] ?? 0;
1649
+ const w2 = W[i - 2] ?? 0;
1650
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
1651
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
1652
+ W[i] = (W[i - 16] ?? 0) + s0 + (W[i - 7] ?? 0) + s1 | 0;
1653
+ }
1654
+ let a = h0;
1655
+ let b = h1;
1656
+ let c = h2;
1657
+ let d = h3;
1658
+ let e = h4;
1659
+ let f = h5;
1660
+ let g = h6;
1661
+ let hh = h7;
1662
+ for (let i = 0; i < 64; i++) {
1663
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
1664
+ const ch = e & f ^ ~e & g;
1665
+ const t1 = hh + S1 + ch + (K[i] ?? 0) + (W[i] ?? 0) | 0;
1666
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
1667
+ const maj = a & b ^ a & c ^ b & c;
1668
+ const t2 = S0 + maj | 0;
1669
+ hh = g;
1670
+ g = f;
1671
+ f = e;
1672
+ e = d + t1 | 0;
1673
+ d = c;
1674
+ c = b;
1675
+ b = a;
1676
+ a = t1 + t2 | 0;
1677
+ }
1678
+ h0 = h0 + a | 0;
1679
+ h1 = h1 + b | 0;
1680
+ h2 = h2 + c | 0;
1681
+ h3 = h3 + d | 0;
1682
+ h4 = h4 + e | 0;
1683
+ h5 = h5 + f | 0;
1684
+ h6 = h6 + g | 0;
1685
+ h7 = h7 + hh | 0;
1686
+ }
1687
+ h.set([h0, h1, h2, h3, h4, h5, h6, h7]);
1688
+ }
1689
+ function sha256Update(state, data) {
1690
+ const pending = Buffer.from(state.tail, "base64");
1691
+ const all = pending.length === 0 ? data : Buffer.concat([pending, data]);
1692
+ const h = wordsOf(state.h);
1693
+ const whole = all.length - all.length % 64;
1694
+ compress(h, all, 0, whole);
1695
+ return {
1696
+ h: hexOf(h),
1697
+ n: state.n + data.length,
1698
+ tail: Buffer.from(all.subarray(whole)).toString("base64")
1699
+ };
1700
+ }
1701
+ function sha256Digest(state) {
1702
+ const pending = Buffer.from(state.tail, "base64");
1703
+ const padLen = pending.length < 56 ? 64 : 128;
1704
+ const block = Buffer.alloc(padLen);
1705
+ pending.copy(block);
1706
+ block[pending.length] = 128;
1707
+ const bits = state.n * 8;
1708
+ block.writeUInt32BE(Math.floor(bits / 2 ** 32), padLen - 8);
1709
+ block.writeUInt32BE(bits >>> 0, padLen - 4);
1710
+ const h = wordsOf(state.h);
1711
+ compress(h, block, 0, padLen);
1712
+ return hexOf(h);
1713
+ }
1714
+
1715
+ // src/segment-manifest.ts
1716
+ var SEGMENT_EXT = ".jsonl";
1717
+ var MANIFEST_EXT = ".manifest";
1718
+ function manifestPath(segment) {
1719
+ return segment.replace(new RegExp(`${SEGMENT_EXT}$`), MANIFEST_EXT);
1720
+ }
1721
+ function manifestSeal(m) {
1722
+ return createHash("sha256").update(
1723
+ [
1724
+ m.v,
1725
+ m.machine,
1726
+ m.count,
1727
+ m.lastHlc ?? "",
1728
+ m.sha256,
1729
+ m.size,
1730
+ m.mtimeMs,
1731
+ m.shaState.h,
1732
+ m.shaState.n,
1733
+ m.shaState.tail
1734
+ ].join("")
1735
+ ).digest("hex");
1736
+ }
1737
+ var HEX64 = /^[0-9a-f]{64}$/;
1738
+ function manifestSelfConsistent(m) {
1739
+ const { count, lastHlc, size, mtimeMs, shaState, seal } = m;
1740
+ if (!Number.isSafeInteger(count) || count < 0) return false;
1741
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) return false;
1742
+ if (typeof mtimeMs !== "number" || !Number.isFinite(mtimeMs)) return false;
1743
+ if (lastHlc !== null && typeof lastHlc !== "string") return false;
1744
+ if (count === 0 !== (size === 0) || count === 0 !== (lastHlc === null)) return false;
1745
+ if (typeof m.sha256 !== "string" || !HEX64.test(m.sha256)) return false;
1746
+ if (typeof seal !== "string" || !HEX64.test(seal)) return false;
1747
+ if (typeof shaState !== "object" || shaState === null || typeof shaState.h !== "string" || !HEX64.test(shaState.h) || shaState.n !== size || typeof shaState.tail !== "string") {
1748
+ return false;
1749
+ }
1750
+ const tail = Buffer.from(shaState.tail, "base64");
1751
+ if (tail.length !== size % 64 || tail.toString("base64") !== shaState.tail) return false;
1752
+ if (sha256Digest(shaState) !== m.sha256) return false;
1753
+ return manifestSeal({ ...m, size, mtimeMs, shaState }) === seal;
1754
+ }
1755
+ function writeManifestFile(segment, fields) {
1756
+ const manifest = {
1757
+ ...fields,
1758
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1759
+ seal: manifestSeal(fields)
1760
+ };
1761
+ writeFileSync(manifestPath(segment), `${JSON.stringify(manifest, null, 2)}
1762
+ `, "utf8");
1763
+ }
1764
+ function readManifest(segment) {
1765
+ const path = manifestPath(segment);
1766
+ if (!existsSync(path)) return null;
1767
+ try {
1768
+ return JSON.parse(readFileSync(path, "utf8"));
1769
+ } catch {
1770
+ return null;
1771
+ }
1772
+ }
1773
+
1774
+ // src/segments.ts
1775
+ import { createHash as createHash2 } from "crypto";
1776
+ import {
1777
+ appendFileSync,
1778
+ closeSync,
1779
+ existsSync as existsSync2,
1780
+ mkdirSync as mkdirSync2,
1781
+ openSync,
1782
+ readdirSync,
1783
+ readFileSync as readFileSync2,
1784
+ readSync,
1785
+ statSync,
1786
+ writeFileSync as writeFileSync2
1787
+ } from "fs";
1788
+ import { tmpdir } from "os";
1789
+ import { join as join3, resolve as resolve2, sep } from "path";
1790
+ import { crc32 } from "zlib";
1791
+ var SEGMENT_FORMAT_VERSION = 1;
1792
+ var SEGMENT_EXT2 = ".jsonl";
1793
+ function syncDir() {
1794
+ const dir = process.env.MU_SYNC_DIR;
1795
+ if (dir === void 0 || dir.trim() === "") return null;
1796
+ refuseUserSyncDirDuringTests(dir);
1797
+ return dir;
1798
+ }
1799
+ function refuseUserSyncDirDuringTests(dir) {
1800
+ const inTest = process.env.VITEST !== void 0 || process.env.NODE_ENV === "test";
1801
+ if (!inTest) return;
1802
+ const resolved = resolve2(dir);
1803
+ const temp = resolve2(tmpdir());
1804
+ if (resolved === temp || resolved.startsWith(temp + sep)) return;
1805
+ throw new Error(
1806
+ `syncDir refused: tests must NEVER flush ops into a real sync dir (MU_SYNC_DIR=${resolved}). Doing so writes a segment named after this run's machine_id and leaves a permanent phantom peer. Point MU_SYNC_DIR at a mkdtemp() path under ${temp}, or leave it unset (test/_setup.ts clears it by default).`
1807
+ );
1808
+ }
1809
+ function localMachineId(db) {
1810
+ const row = db.prepare("SELECT machine_id FROM machine_identity WHERE id = 1").get();
1811
+ if (row === void 0) throw new Error("machine_identity row missing; not a mu DB");
1812
+ return row.machine_id;
1813
+ }
1814
+ function segmentPath(dir, machineId) {
1815
+ return join3(dir, `${machineId}${SEGMENT_EXT2}`);
1816
+ }
1817
+ function canonicalBytes(line, payloadText) {
1818
+ return [
1819
+ String(line.v),
1820
+ line.hlc,
1821
+ line.machine,
1822
+ line.group,
1823
+ line.intent ?? "",
1824
+ line.actor ?? "",
1825
+ line.entity,
1826
+ line.key,
1827
+ line.op,
1828
+ payloadText
1829
+ ].join("");
1830
+ }
1831
+ function computeCrc(line, payloadText) {
1832
+ return crc32(canonicalBytes(line, payloadText)).toString(16).padStart(8, "0");
1833
+ }
1834
+ function payloadTextFor(payload) {
1835
+ try {
1836
+ JSON.parse(payload);
1837
+ return payload;
1838
+ } catch {
1839
+ return JSON.stringify(payload);
1840
+ }
1841
+ }
1842
+ function encodeSegmentLine(op) {
1843
+ const base = {
1844
+ v: SEGMENT_FORMAT_VERSION,
1845
+ hlc: op.hlc,
1846
+ machine: op.machineId,
1847
+ group: op.groupId,
1848
+ intent: op.intent,
1849
+ actor: op.actor,
1850
+ entity: op.entity,
1851
+ key: op.key,
1852
+ op: op.op,
1853
+ payload: null
1854
+ // replaced below; kept out of the crc input shape
1855
+ };
1856
+ const payloadText = payloadTextFor(op.payload);
1857
+ const crc = computeCrc(base, payloadText);
1858
+ return `{"v":${base.v},"hlc":${JSON.stringify(base.hlc)},"machine":${JSON.stringify(
1859
+ base.machine
1860
+ )},"group":${JSON.stringify(base.group)},"intent":${JSON.stringify(
1861
+ base.intent
1862
+ )},"actor":${JSON.stringify(base.actor)},"entity":${JSON.stringify(
1863
+ base.entity
1864
+ )},"key":${JSON.stringify(base.key)},"op":${JSON.stringify(
1865
+ base.op
1866
+ )},"payload":${payloadText},"crc":${JSON.stringify(crc)}}`;
1867
+ }
1868
+ function decodeLine(raw) {
1869
+ let parsed;
1870
+ try {
1871
+ parsed = JSON.parse(raw);
1872
+ } catch (err) {
1873
+ const detail = err instanceof Error ? err.message : String(err);
1874
+ return looksComplete(raw) ? { ok: false, kind: "malformed-shape", detail: `complete line is not valid JSON: ${detail}` } : { ok: false, kind: "torn-write", detail };
1875
+ }
1876
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1877
+ return { ok: false, kind: "malformed-shape", detail: "line is not a JSON object" };
1878
+ }
1879
+ const obj = parsed;
1880
+ if (typeof obj.v !== "number") {
1881
+ return { ok: false, kind: "malformed-shape", detail: "missing numeric 'v'" };
1882
+ }
1883
+ if (obj.v !== SEGMENT_FORMAT_VERSION) {
1884
+ return {
1885
+ ok: false,
1886
+ kind: "unknown-version",
1887
+ detail: `segment format v${obj.v}, this mu understands v${SEGMENT_FORMAT_VERSION}`
1888
+ };
1889
+ }
1890
+ for (const field of ["hlc", "machine", "group", "entity", "key", "op", "crc"]) {
1891
+ if (typeof obj[field] !== "string") {
1892
+ return { ok: false, kind: "malformed-shape", detail: `missing string '${field}'` };
1893
+ }
1894
+ }
1895
+ const opKind = obj.op;
1896
+ if (opKind !== "put" && opKind !== "del") {
1897
+ return { ok: false, kind: "malformed-shape", detail: `bad op '${String(opKind)}'` };
1898
+ }
1899
+ if (!("payload" in obj)) {
1900
+ return { ok: false, kind: "malformed-shape", detail: "missing 'payload'" };
1901
+ }
1902
+ const payloadText = extractPayloadText(raw);
1903
+ if (payloadText === null) {
1904
+ return { ok: false, kind: "malformed-shape", detail: "could not locate payload text" };
1905
+ }
1906
+ const line = {
1907
+ v: obj.v,
1908
+ hlc: obj.hlc,
1909
+ machine: obj.machine,
1910
+ group: obj.group,
1911
+ intent: typeof obj.intent === "string" ? obj.intent : null,
1912
+ actor: typeof obj.actor === "string" ? obj.actor : null,
1913
+ entity: obj.entity,
1914
+ key: obj.key,
1915
+ op: opKind,
1916
+ payload: obj.payload,
1917
+ crc: obj.crc
1918
+ };
1919
+ const expected = computeCrc({ ...line, payload: null }, payloadText);
1920
+ if (expected !== line.crc) {
1921
+ return {
1922
+ ok: false,
1923
+ kind: "crc-mismatch",
1924
+ detail: `crc ${line.crc} != computed ${expected} (bit rot?)`
1925
+ };
1926
+ }
1927
+ return { ok: true, line, payloadText: storedPayloadFor(line, payloadText) };
1928
+ }
1929
+ function storedPayloadFor(line, payloadText) {
1930
+ if (typeof line.payload !== "string") return payloadText;
1931
+ try {
1932
+ JSON.parse(line.payload);
1933
+ return payloadText;
1934
+ } catch {
1935
+ return line.payload;
1936
+ }
1937
+ }
1938
+ function looksComplete(raw) {
1939
+ return raw.trimEnd().endsWith("}") && raw.includes(',"crc":"');
1940
+ }
1941
+ function extractPayloadText(raw) {
1942
+ const start = raw.indexOf('"payload":');
1943
+ if (start < 0) return null;
1944
+ const end = raw.lastIndexOf(',"crc":');
1945
+ if (end <= start) return null;
1946
+ return raw.slice(start + '"payload":'.length, end);
1947
+ }
1948
+ var AMBIENT_FLUSH_LOCK_WAIT_MS = 2e3;
1949
+ var VERIFY_FLUSH_LOCK_WAIT_MS = 15e3;
1950
+ async function flushSegment(db, dir = syncDir(), opts) {
1951
+ if (dir === null)
1952
+ return { segmentPath: null, appended: 0, total: 0, skippedLocal: 0, selfRepaired: null };
1953
+ const machineId = localMachineId(db);
1954
+ mkdirSync2(dir, { recursive: true });
1955
+ const path = segmentPath(dir, machineId);
1956
+ const verify = opts?.verify === true;
1957
+ const pre = verify ? null : trustedTail(path, machineId);
1958
+ if (pre !== null) {
1959
+ const pending = pendingLines(db, machineId, pre.lastHlc, false);
1960
+ if (pending.lines.length === 0) {
1961
+ return {
1962
+ segmentPath: path,
1963
+ appended: 0,
1964
+ total: pre.count,
1965
+ skippedLocal: pending.skippedLocal,
1966
+ selfRepaired: null
1967
+ };
1968
+ }
1969
+ }
1970
+ const lockName = createHash2("sha256").update(`${dir}${machineId}`).digest("hex").slice(0, 16);
1971
+ return withFileLock(
1972
+ join3(locksDir(), `segment-${lockName}.lock`),
1973
+ `segment:${machineId}`,
1974
+ () => Promise.resolve(flushLocked(db, path, machineId, verify)),
1975
+ {
1976
+ acquireTimeoutMs: opts?.lockWaitMs ?? (verify ? VERIFY_FLUSH_LOCK_WAIT_MS : AMBIENT_FLUSH_LOCK_WAIT_MS),
1977
+ onUnavailable: () => ({
1978
+ segmentPath: path,
1979
+ appended: 0,
1980
+ total: pre?.count ?? 0,
1981
+ skippedLocal: 0,
1982
+ selfRepaired: null,
1983
+ lockBusy: true
1984
+ })
1985
+ }
1986
+ );
1987
+ }
1988
+ function flushLocked(db, path, machineId, verify) {
1989
+ const trusted = verify ? null : trustedTail(path, machineId);
1990
+ let count;
1991
+ let since;
1992
+ let selfRepaired = null;
1993
+ if (trusted !== null) {
1994
+ count = trusted.count;
1995
+ since = trusted.lastHlc;
1996
+ } else {
1997
+ const existing = readSegmentTail(path);
1998
+ count = existing.count;
1999
+ since = existing.lastHlc;
2000
+ if (existing.defect !== null) {
2001
+ selfRepaired = existing.defect;
2002
+ writeFileSync2(
2003
+ path,
2004
+ existing.goodLines.length > 0 ? `${existing.goodLines.join("\n")}
2005
+ ` : "",
2006
+ "utf8"
2007
+ );
2008
+ }
2009
+ }
2010
+ const pending = pendingLines(db, machineId, since, trusted === null);
2011
+ let appended = null;
2012
+ if (pending.lines.length > 0) {
2013
+ appended = Buffer.from(`${pending.lines.join("\n")}
2014
+ `, "utf8");
2015
+ appendFileSync(path, appended);
2016
+ } else if (!existsSync2(path)) {
2017
+ writeFileSync2(path, "", "utf8");
2018
+ }
2019
+ const total = count + pending.lines.length;
2020
+ const result = {
2021
+ segmentPath: path,
2022
+ appended: pending.lines.length,
2023
+ total,
2024
+ skippedLocal: pending.skippedLocal,
2025
+ selfRepaired
2026
+ };
2027
+ if (trusted !== null && appended === null) return result;
2028
+ writeManifest(
2029
+ path,
2030
+ machineId,
2031
+ total,
2032
+ pending.lastHlc ?? since,
2033
+ trusted !== null && appended !== null ? { state: trusted.shaState, size: trusted.size, appended } : null
2034
+ );
2035
+ return result;
2036
+ }
2037
+ var SYNCED_ENTITIES_SQL = SYNCED_ENTITIES.map((e) => `'${e}'`).join(", ");
2038
+ function pendingLines(db, machineId, since, countTrailing) {
2039
+ const from = since ?? "";
2040
+ const rows = prepareCached(
2041
+ db,
2042
+ `SELECT hlc, machine_id, group_id, intent, actor, entity, key, op, payload
2043
+ FROM ops INDEXED BY idx_ops_machine_entity_hlc
2044
+ WHERE machine_id = @machineId
2045
+ AND entity IN (${SYNCED_ENTITIES_SQL})
2046
+ AND hlc > @from
2047
+ ORDER BY hlc`
2048
+ ).all({ machineId, from });
2049
+ const lines = [];
2050
+ let lastHlc = null;
2051
+ for (const row2 of rows) {
2052
+ if (isLegacyLogOnlyIntent(row2.intent)) continue;
2053
+ lines.push(
2054
+ encodeSegmentLine({
2055
+ hlc: row2.hlc,
2056
+ machineId: row2.machine_id,
2057
+ groupId: row2.group_id,
2058
+ intent: row2.intent,
2059
+ actor: row2.actor,
2060
+ entity: row2.entity,
2061
+ key: row2.key,
2062
+ op: row2.op === "del" ? "del" : "put",
2063
+ payload: row2.payload
2064
+ })
2065
+ );
2066
+ lastHlc = row2.hlc;
2067
+ }
2068
+ let row;
2069
+ if (countTrailing) {
2070
+ row = prepareCached(
2071
+ db,
2072
+ "SELECT COUNT(*) AS n FROM ops WHERE machine_id = @machineId AND hlc > @from"
2073
+ ).get({ machineId, from });
2074
+ } else if (lastHlc !== null) {
2075
+ row = prepareCached(
2076
+ db,
2077
+ `SELECT COUNT(*) AS n FROM ops
2078
+ WHERE machine_id = @machineId AND hlc > @from AND hlc <= @upTo`
2079
+ ).get({ machineId, from, upTo: lastHlc });
2080
+ } else {
2081
+ return { lines, lastHlc, skippedLocal: 0 };
2082
+ }
2083
+ return { lines, lastHlc, skippedLocal: row.n - lines.length };
2084
+ }
2085
+ function trustedTail(path, machineId) {
2086
+ const m = readManifest(path);
2087
+ if (m === null || m.v !== SEGMENT_FORMAT_VERSION || m.machine !== machineId) return null;
2088
+ if (!manifestSelfConsistent(m)) return null;
2089
+ const { count, lastHlc, size, mtimeMs, shaState } = m;
2090
+ let stat2;
2091
+ try {
2092
+ stat2 = statSync(path);
2093
+ } catch {
2094
+ return null;
2095
+ }
2096
+ if (stat2.size !== size || stat2.mtimeMs !== mtimeMs) return null;
2097
+ if (count === 0) return lastHlc === null ? { count, lastHlc, size, shaState } : null;
2098
+ const last = readLastLine(path, size);
2099
+ if (last === null) return null;
2100
+ const decoded = decodeLine(last);
2101
+ if (!decoded.ok || decoded.line.hlc !== lastHlc) return null;
2102
+ return { count, lastHlc, size, shaState };
2103
+ }
2104
+ function readLastLine(path, size) {
2105
+ if (size === 0) return null;
2106
+ const fd = openSync(path, "r");
2107
+ try {
2108
+ const last = Buffer.alloc(1);
2109
+ readSync(fd, last, 0, 1, size - 1);
2110
+ if (last[0] !== 10) return null;
2111
+ const chunks = [];
2112
+ let end = size - 1;
2113
+ while (end > 0) {
2114
+ const start = Math.max(0, end - 64 * 1024);
2115
+ const buf = Buffer.alloc(end - start);
2116
+ readSync(fd, buf, 0, buf.length, start);
2117
+ const nl = buf.lastIndexOf(10);
2118
+ if (nl >= 0) {
2119
+ chunks.unshift(buf.subarray(nl + 1));
2120
+ break;
2121
+ }
2122
+ chunks.unshift(buf);
2123
+ end = start;
2124
+ }
2125
+ return Buffer.concat(chunks).toString("utf8");
2126
+ } finally {
2127
+ closeSync(fd);
2128
+ }
2129
+ }
2130
+ function segmentFingerprint(path) {
2131
+ try {
2132
+ const { size, mtimeMs } = statSync(path);
2133
+ const manifest = readFileSync2(manifestPath(path));
2134
+ const sha = createHash2("sha256").update(manifest).digest("hex");
2135
+ let count = null;
2136
+ try {
2137
+ count = JSON.parse(manifest.toString("utf8")).count;
2138
+ } catch {
2139
+ }
2140
+ return { key: `${size} ${mtimeMs} ${sha}`, count };
2141
+ } catch {
2142
+ return null;
2143
+ }
2144
+ }
2145
+ function recordedFingerprint(db, watermarkKey) {
2146
+ const row = prepareCached(
2147
+ db,
2148
+ "SELECT fingerprint FROM sync_fingerprints WHERE machine_id = ?"
2149
+ ).get(watermarkKey);
2150
+ return row?.fingerprint ?? null;
2151
+ }
2152
+ function segmentCaughtUp(db, watermarkKey, path, lines) {
2153
+ const recorded = recordedFingerprint(db, watermarkKey);
2154
+ if (recorded === null) return false;
2155
+ const now = segmentFingerprint(path);
2156
+ return now !== null && now.count === lines && now.key === recorded;
2157
+ }
2158
+ function segmentLineCount(path) {
2159
+ return readSegmentTail(path).count;
2160
+ }
2161
+ function readSegmentTail(path) {
2162
+ if (!existsSync2(path)) return { count: 0, lastHlc: null, goodLines: [], defect: null };
2163
+ const lines = segmentLines(readFileSync2(path, "utf8"));
2164
+ let lastHlc = null;
2165
+ let count = 0;
2166
+ let defect = null;
2167
+ for (let index = 0; index < lines.length; index++) {
2168
+ const line = lines[index];
2169
+ if (line === void 0) break;
2170
+ if (line.trim() === "") {
2171
+ defect = { kind: "malformed-shape", line: index + 1, detail: "blank line" };
2172
+ break;
2173
+ }
2174
+ const decoded = decodeLine(line);
2175
+ if (!decoded.ok) {
2176
+ defect = { kind: decoded.kind, line: index + 1, detail: decoded.detail };
2177
+ break;
2178
+ }
2179
+ lastHlc = decoded.line.hlc;
2180
+ count += 1;
2181
+ }
2182
+ return { count, lastHlc, goodLines: lines.slice(0, count), defect };
2183
+ }
2184
+ function segmentLines(raw) {
2185
+ const lines = raw.split("\n");
2186
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
2187
+ return lines;
2188
+ }
2189
+ function writeManifest(path, machine, count, lastHlc, incremental) {
2190
+ let stat2 = statSync(path);
2191
+ let shaState;
2192
+ if (incremental !== null && stat2.size === incremental.size + incremental.appended.length) {
2193
+ shaState = sha256Update(incremental.state, incremental.appended);
2194
+ } else {
2195
+ const bytes = readFileSync2(path);
2196
+ shaState = sha256Update(sha256Initial(), bytes);
2197
+ stat2 = statSync(path);
2198
+ }
2199
+ writeManifestFile(path, {
2200
+ v: SEGMENT_FORMAT_VERSION,
2201
+ machine,
2202
+ count,
2203
+ lastHlc,
2204
+ sha256: sha256Digest(shaState),
2205
+ size: shaState.n,
2206
+ mtimeMs: stat2.mtimeMs,
2207
+ shaState
2208
+ });
2209
+ }
2210
+ function verifyAgainstManifest(segment) {
2211
+ const manifest = readManifest(segment);
2212
+ if (manifest === null) return { ok: true };
2213
+ if (!existsSync2(segment)) return { ok: false, reason: "segment missing but manifest present" };
2214
+ const sha = createHash2("sha256").update(readFileSync2(segment)).digest("hex");
2215
+ if (sha === manifest.sha256) return { ok: true };
2216
+ const tail = readSegmentTail(segment);
2217
+ if (tail.count >= manifest.count) {
2218
+ return { ok: true };
2219
+ }
2220
+ return {
2221
+ ok: false,
2222
+ reason: `sha mismatch and file has FEWER records than the manifest (${tail.count} < ${manifest.count}): truncated`
2223
+ };
2224
+ }
2225
+ function discoverPeers(dir, selfMachineId) {
2226
+ if (!existsSync2(dir)) return [];
2227
+ const peers = [];
2228
+ for (const name of readdirSync(dir)) {
2229
+ if (!name.endsWith(SEGMENT_EXT2)) continue;
2230
+ const stem = name.slice(0, -SEGMENT_EXT2.length);
2231
+ const conflictAt = stem.indexOf(".sync-conflict-");
2232
+ const conflictCopy = conflictAt > 0;
2233
+ const machineId = conflictCopy ? stem.slice(0, conflictAt) : stem;
2234
+ if (machineId === selfMachineId && !conflictCopy) continue;
2235
+ const path = join3(dir, name);
2236
+ try {
2237
+ if (!statSync(path).isFile()) continue;
2238
+ } catch {
2239
+ continue;
2240
+ }
2241
+ peers.push({ machineId, watermarkKey: conflictCopy ? stem : machineId, path, conflictCopy });
2242
+ }
2243
+ return peers.sort((a, b) => a.path.localeCompare(b.path));
2244
+ }
2245
+ function peerWatermarkKey(peer) {
2246
+ return peer.watermarkKey ?? peer.machineId;
2247
+ }
2248
+ function getWatermark(db, watermarkKey) {
2249
+ const row = db.prepare("SELECT last_applied_seq AS n FROM sync_peers WHERE machine_id = ?").get(watermarkKey);
2250
+ return row?.n ?? 0;
2251
+ }
2252
+ function setWatermark(db, watermarkKey, value, fingerprint) {
2253
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2254
+ prepareCached(
2255
+ db,
2256
+ `INSERT INTO sync_peers (machine_id, last_applied_seq, last_seen_at)
2257
+ VALUES (@machineId, @value, @seenAt)
2258
+ ON CONFLICT (machine_id) DO UPDATE
2259
+ SET last_applied_seq = @value, last_seen_at = @seenAt`
2260
+ ).run({ machineId: watermarkKey, value, seenAt: now });
2261
+ if (fingerprint == null) {
2262
+ prepareCached(db, "DELETE FROM sync_fingerprints WHERE machine_id = ?").run(watermarkKey);
2263
+ } else {
2264
+ prepareCached(
2265
+ db,
2266
+ `INSERT INTO sync_fingerprints (machine_id, fingerprint) VALUES (?, ?)
2267
+ ON CONFLICT (machine_id) DO UPDATE SET fingerprint = excluded.fingerprint`
2268
+ ).run(watermarkKey, fingerprint);
2269
+ }
2270
+ }
2271
+ function markReprojectionPending(db) {
2272
+ prepareCached(db, "PRAGMA user_version = 1").run();
2273
+ }
2274
+ function reprojectIfPending(db, opts) {
2275
+ if (opts?.force !== true && db.pragma("user_version", { simple: true }) === 0) return 0;
2276
+ return db.transaction(() => {
2277
+ const pending = db.pragma("user_version", { simple: true }) !== 0;
2278
+ if (!pending && opts?.force !== true) return 0;
2279
+ const changed = reprojectDeferredOps(db);
2280
+ if (pending) db.pragma("user_version = 0");
2281
+ return changed;
2282
+ }).immediate();
2283
+ }
2284
+ function resetWatermark(db, watermarkKey) {
2285
+ setWatermark(db, watermarkKey, 0);
2286
+ }
2287
+ function ingestSegment(db, peer, opts) {
2288
+ const defects = [];
2289
+ const watermarkKey = peerWatermarkKey(peer);
2290
+ const start = getWatermark(db, watermarkKey);
2291
+ if (opts?.verify !== true && segmentCaughtUp(db, watermarkKey, peer.path, start)) {
2292
+ return {
2293
+ machineId: peer.machineId,
2294
+ path: peer.path,
2295
+ read: 0,
2296
+ applied: 0,
2297
+ changed: 0,
2298
+ watermark: start,
2299
+ defects,
2300
+ truncatedAt: null
2301
+ };
2302
+ }
2303
+ if (!existsSync2(peer.path)) {
2304
+ return {
2305
+ machineId: peer.machineId,
2306
+ path: peer.path,
2307
+ read: 0,
2308
+ applied: 0,
2309
+ changed: 0,
2310
+ watermark: start,
2311
+ defects,
2312
+ truncatedAt: null
2313
+ };
2314
+ }
2315
+ const fingerprint = segmentFingerprint(peer.path);
2316
+ const verified = verifyAgainstManifest(peer.path);
2317
+ if (!verified.ok) {
2318
+ defects.push({ kind: "manifest-mismatch", line: 0, detail: verified.reason });
2319
+ }
2320
+ const lines = segmentLines(readFileSync2(peer.path, "utf8"));
2321
+ let applied = 0;
2322
+ let changed = 0;
2323
+ let watermark = start;
2324
+ let truncatedAt = null;
2325
+ let previousHlc = null;
2326
+ if (start > 0 && start <= lines.length) {
2327
+ const prior = lines[start - 1];
2328
+ if (prior !== void 0) {
2329
+ const decoded = decodeLine(prior);
2330
+ if (decoded.ok) previousHlc = decoded.line.hlc;
2331
+ }
2332
+ }
2333
+ const run = db.transaction(() => {
2334
+ for (let index = start; index < lines.length; index++) {
2335
+ const raw = lines[index];
2336
+ const lineNo = index + 1;
2337
+ if (raw === void 0 || raw.trim() === "") {
2338
+ defects.push({ kind: "malformed-shape", line: lineNo, detail: "blank line" });
2339
+ truncatedAt = lineNo;
2340
+ break;
2341
+ }
2342
+ const decoded = decodeLine(raw);
2343
+ if (!decoded.ok) {
2344
+ defects.push({ kind: decoded.kind, line: lineNo, detail: decoded.detail });
2345
+ truncatedAt = lineNo;
2346
+ break;
2347
+ }
2348
+ const op = {
2349
+ hlc: decoded.line.hlc,
2350
+ machineId: decoded.line.machine,
2351
+ groupId: decoded.line.group,
2352
+ actor: decoded.line.actor,
2353
+ intent: decoded.line.intent,
2354
+ entity: decoded.line.entity,
2355
+ key: decoded.line.key,
2356
+ op: decoded.line.op,
2357
+ payload: decoded.payloadText
2358
+ };
2359
+ if (previousHlc !== null && decoded.line.hlc <= previousHlc) {
2360
+ if (isAlreadyRecorded(db, op)) {
2361
+ defects.push({
2362
+ kind: "duplicate-op",
2363
+ line: lineNo,
2364
+ detail: `hlc ${decoded.line.hlc} re-delivered \u2014 skipped`
2365
+ });
2366
+ watermark = lineNo;
2367
+ continue;
2368
+ }
2369
+ defects.push({
2370
+ kind: "non-monotonic-hlc",
2371
+ line: lineNo,
2372
+ detail: `hlc ${decoded.line.hlc} <= previous ${previousHlc}`
2373
+ });
2374
+ truncatedAt = lineNo;
2375
+ break;
2376
+ }
2377
+ if (isLegacyLogOnlyIntent(decoded.line.intent)) {
2378
+ previousHlc = op.hlc;
2379
+ watermark = lineNo;
2380
+ continue;
2381
+ }
2382
+ try {
2383
+ const result = applyIncomingOp(db, op);
2384
+ if (result.changed) changed += 1;
2385
+ } catch (err) {
2386
+ if (err instanceof OpEntityNotSyncedError) {
2387
+ defects.push({
2388
+ kind: "entity-not-synced",
2389
+ line: lineNo,
2390
+ detail: `peer sent machine-local entity '${op.entity}' \u2014 skipped`
2391
+ });
2392
+ previousHlc = op.hlc;
2393
+ watermark = lineNo;
2394
+ continue;
2395
+ }
2396
+ throw err;
2397
+ }
2398
+ applied += 1;
2399
+ previousHlc = op.hlc;
2400
+ watermark = lineNo;
2401
+ }
2402
+ const clean = defects.length === 0 && watermark === lines.length;
2403
+ setWatermark(db, watermarkKey, watermark, clean ? fingerprint?.key ?? null : null);
2404
+ });
2405
+ run.immediate();
2406
+ return {
2407
+ machineId: peer.machineId,
2408
+ path: peer.path,
2409
+ read: watermark - start,
2410
+ applied,
2411
+ changed,
2412
+ watermark,
2413
+ defects,
2414
+ truncatedAt
2415
+ };
2416
+ }
2417
+ function isAlreadyRecorded(db, op) {
2418
+ const row = prepareCached(
2419
+ db,
2420
+ `SELECT 1 FROM ops
2421
+ WHERE machine_id = @machineId AND hlc = @hlc
2422
+ AND entity = @entity AND key = @key AND op = @op
2423
+ LIMIT 1`
2424
+ ).get({
2425
+ machineId: op.machineId,
2426
+ hlc: op.hlc,
2427
+ entity: op.entity,
2428
+ key: op.key,
2429
+ op: op.op
2430
+ });
2431
+ return row !== void 0;
2432
+ }
2433
+ function applyIncomingOp(db, op) {
2434
+ receiveHlc(db, op.hlc);
2435
+ const result = applyOp(db, op);
2436
+ if (result.changed || result.skipped === "absent") markReprojectionPending(db);
2437
+ prepareCached(
2438
+ db,
2439
+ `INSERT OR IGNORE INTO ops
2440
+ (hlc, machine_id, group_id, actor, intent, entity, key, op, payload, created_at)
2441
+ VALUES (@hlc, @machineId, @groupId, @actor, @intent, @entity, @key, @op, @payload, @createdAt)`
2442
+ ).run({
2443
+ hlc: op.hlc,
2444
+ machineId: op.machineId,
2445
+ groupId: op.groupId,
2446
+ actor: op.actor ?? null,
2447
+ intent: op.intent ?? null,
2448
+ entity: op.entity,
2449
+ key: op.key,
2450
+ op: op.op,
2451
+ payload: op.payload,
2452
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2453
+ });
2454
+ return { changed: result.changed };
2455
+ }
2456
+ async function syncPass(db, dir = syncDir()) {
2457
+ if (dir === null) {
2458
+ return {
2459
+ flushed: { segmentPath: null, appended: 0, total: 0, skippedLocal: 0, selfRepaired: null },
2460
+ ingested: [],
2461
+ defective: false
2462
+ };
2463
+ }
2464
+ const flushed = await flushSegment(db, dir, { verify: true });
2465
+ const self = localMachineId(db);
2466
+ const ingested = discoverPeers(dir, self).map(
2467
+ (peer) => ingestSegment(db, peer, { verify: true })
2468
+ );
2469
+ reprojectIfPending(db, { force: true });
2470
+ return {
2471
+ flushed,
2472
+ ingested,
2473
+ defective: flushed.selfRepaired !== null || ingested.some((r) => r.defects.length > 0)
2474
+ };
2475
+ }
2476
+
2477
+ // src/sync.ts
2478
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
2479
+ import Database2 from "better-sqlite3";
2480
+ var SyncPeerNotFoundError = class extends Error {
2481
+ constructor(ref, known) {
2482
+ super(
2483
+ `no peer matches ${JSON.stringify(ref)}${known.length === 0 ? " (no peers discovered)" : ` (known peers: ${known.join(", ")})`}`
2484
+ );
2485
+ this.ref = ref;
2486
+ this.known = known;
2487
+ }
2488
+ ref;
2489
+ known;
2490
+ name = "SyncPeerNotFoundError";
2491
+ };
2492
+ var SyncPeerRefAmbiguousError = class extends Error {
2493
+ constructor(ref, candidates) {
2494
+ super(
2495
+ `peer ref ${JSON.stringify(ref)} matches ${candidates.length} peers (${candidates.join(", ")}); pass more characters`
2496
+ );
2497
+ this.ref = ref;
2498
+ this.candidates = candidates;
2499
+ }
2500
+ ref;
2501
+ candidates;
2502
+ name = "SyncPeerRefAmbiguousError";
2503
+ };
2504
+ var SyncSourceNotFoundError = class extends Error {
2505
+ constructor(path) {
2506
+ super(`--from: no such file: ${path}`);
2507
+ this.path = path;
2508
+ }
2509
+ path;
2510
+ name = "SyncSourceNotFoundError";
2511
+ };
2512
+ var PEER_STALE_MS = 24 * 60 * 60 * 1e3;
2513
+ var PEER_SHORT_LEN = 8;
2514
+ function peerStatuses(db, dir) {
2515
+ const self = localMachineId(db);
2516
+ const now = Date.now();
2517
+ const rows = discoverPeers(dir, self).map((peer) => {
2518
+ let lastSeenMs = null;
2519
+ try {
2520
+ lastSeenMs = statSync2(peer.path).mtimeMs;
2521
+ } catch {
2522
+ lastSeenMs = null;
2523
+ }
2524
+ const watermarkKey = peerWatermarkKey(peer);
2525
+ const watermark = getWatermark(db, watermarkKey);
2526
+ const total = segmentCaughtUp(db, watermarkKey, peer.path, watermark) ? watermark : segmentLineCount(peer.path);
2527
+ const ageMs = lastSeenMs === null ? null : Math.max(0, now - lastSeenMs);
2528
+ return {
2529
+ machineId: peer.machineId,
2530
+ short: peer.machineId.slice(0, PEER_SHORT_LEN),
2531
+ path: peer.path,
2532
+ conflictCopy: peer.conflictCopy,
2533
+ watermarkKey,
2534
+ watermark,
2535
+ total,
2536
+ behind: Math.max(0, total - watermark),
2537
+ lastSeenMs,
2538
+ ageMs,
2539
+ stale: ageMs === null || ageMs > PEER_STALE_MS
2540
+ };
2541
+ });
2542
+ return rows.sort((a, b) => (b.lastSeenMs ?? 0) - (a.lastSeenMs ?? 0));
2543
+ }
2544
+ function resolvePeerRef(peers, ref) {
2545
+ const matches = peers.filter((p) => p.machineId.startsWith(ref));
2546
+ const exact = matches.filter((p) => p.machineId === ref);
2547
+ const candidates = exact.length > 0 ? exact : matches;
2548
+ const machines = [...new Set(candidates.map((p) => p.machineId))];
2549
+ const first = candidates.find((p) => !p.conflictCopy) ?? candidates[0];
2550
+ if (first === void 0) {
2551
+ throw new SyncPeerNotFoundError(
2552
+ ref,
2553
+ peers.map((p) => p.short)
2554
+ );
2555
+ }
2556
+ if (machines.length > 1) {
2557
+ throw new SyncPeerRefAmbiguousError(
2558
+ ref,
2559
+ machines.map((m) => m.slice(0, PEER_SHORT_LEN))
2560
+ );
2561
+ }
2562
+ return first;
2563
+ }
2564
+ function repairPeer(db, ref, dir) {
2565
+ const peers = peerStatuses(db, dir);
2566
+ const peer = resolvePeerRef(peers, ref);
2567
+ for (const p of peers) {
2568
+ if (p.machineId === peer.machineId) resetWatermark(db, peerWatermarkKey(p));
2569
+ }
2570
+ return peer;
2571
+ }
2572
+ function syncEnabled() {
2573
+ return syncDir() !== null;
2574
+ }
2575
+ function warn(message, opts) {
2576
+ if (opts?.quiet === true) return;
2577
+ process.stderr.write(`mu: sync: ${message}
2578
+ `);
2579
+ }
2580
+ function describeDefects(peerShort, defects) {
2581
+ const first = defects[0];
2582
+ const detail = first === void 0 ? "unknown defect" : `${first.kind} at line ${first.line}`;
2583
+ const more = defects.length > 1 ? ` (+${defects.length - 1} more)` : "";
2584
+ const skipped = /* @__PURE__ */ new Set(["entity-not-synced", "duplicate-op"]);
2585
+ const fileWide = /* @__PURE__ */ new Set(["manifest-mismatch"]);
2586
+ let hint;
2587
+ if (defects.every((d) => skipped.has(d.kind))) {
2588
+ hint = " \u2014 skipped; the rest of the segment applied";
2589
+ } else if (defects.every((d) => skipped.has(d.kind) || fileWide.has(d.kind))) {
2590
+ hint = " \u2014 the file is shorter than its manifest; copy it from the peer again";
2591
+ } else {
2592
+ hint = ` \u2014 re-read with \`mu sync --repair ${peerShort}\``;
2593
+ }
2594
+ return `peer ${peerShort}: ${detail}${more}${hint}`;
2595
+ }
2596
+ async function ambientIngest(db, opts) {
2597
+ const dir = syncDir();
2598
+ if (dir === null) return { ingested: [], warnings: [] };
2599
+ const warnings = [];
2600
+ const ingested = [];
2601
+ try {
2602
+ const self = localMachineId(db);
2603
+ for (const peer of discoverPeers(dir, self)) {
2604
+ try {
2605
+ const result = ingestSegment(db, peer);
2606
+ ingested.push(result);
2607
+ if (result.defects.length > 0) {
2608
+ const message = describeDefects(peer.machineId.slice(0, PEER_SHORT_LEN), result.defects);
2609
+ warnings.push(message);
2610
+ warn(message, opts);
2611
+ }
2612
+ } catch (err) {
2613
+ const message = `peer ${peer.machineId.slice(0, PEER_SHORT_LEN)}: ${err instanceof Error ? err.message : String(err)}`;
2614
+ warnings.push(message);
2615
+ warn(message, opts);
2616
+ }
2617
+ }
2618
+ reprojectIfPending(db);
2619
+ } catch (err) {
2620
+ const message = err instanceof Error ? err.message : String(err);
2621
+ warnings.push(message);
2622
+ warn(message, opts);
2623
+ }
2624
+ return { ingested, warnings };
2625
+ }
2626
+ async function ambientFlush(db, opts) {
2627
+ const dir = syncDir();
2628
+ if (dir === null) return null;
2629
+ try {
2630
+ const result = await flushSegment(db, dir);
2631
+ if (result.selfRepaired !== null) {
2632
+ const d = result.selfRepaired;
2633
+ warn(
2634
+ `own segment truncated back to its last good line: ${d.kind} at line ${d.line} (${d.detail}) \u2014 regenerated from the ops table`,
2635
+ opts
2636
+ );
2637
+ }
2638
+ return result;
2639
+ } catch (err) {
2640
+ warn(err instanceof Error ? err.message : String(err), opts);
2641
+ return null;
2642
+ }
2643
+ }
2644
+ async function ambientSyncPass(db, opts) {
2645
+ if (!syncEnabled()) return { ingested: [], flushed: null };
2646
+ const { ingested } = await ambientIngest(db, opts);
2647
+ const flushed = await ambientFlush(db, opts);
2648
+ return { ingested, flushed };
2649
+ }
2650
+ function ingestFromDb(db, path) {
2651
+ if (!existsSync3(path)) throw new SyncSourceNotFoundError(path);
2652
+ const self = localMachineId(db);
2653
+ const source = new Database2(path, { readonly: true, fileMustExist: true });
2654
+ try {
2655
+ const rows = source.prepare(
2656
+ `SELECT hlc, machine_id, group_id, actor, intent, entity, key, op, payload
2657
+ FROM ops
2658
+ WHERE machine_id != ?
2659
+ ORDER BY hlc`
2660
+ ).all(self);
2661
+ const synced = new Set(SYNCED_ENTITIES);
2662
+ let read = 0;
2663
+ let changed = 0;
2664
+ let skippedLocal = 0;
2665
+ const run = db.transaction(() => {
2666
+ for (const row of rows) {
2667
+ if (!synced.has(row.entity) || isLegacyLogOnlyIntent(row.intent)) {
2668
+ skippedLocal += 1;
2669
+ continue;
2670
+ }
2671
+ const op = {
2672
+ hlc: row.hlc,
2673
+ machineId: row.machine_id,
2674
+ groupId: row.group_id,
2675
+ actor: row.actor,
2676
+ intent: row.intent,
2677
+ entity: row.entity,
2678
+ key: row.key,
2679
+ op: row.op === "del" ? "del" : "put",
2680
+ payload: row.payload
2681
+ };
2682
+ const result = applyIncomingOp(db, op);
2683
+ read += 1;
2684
+ if (result.changed) changed += 1;
2685
+ }
2686
+ });
2687
+ run.immediate();
2688
+ changed += reprojectIfPending(db, { force: true });
2689
+ return { path, read, changed, skippedLocal };
2690
+ } finally {
2691
+ try {
2692
+ source.close();
2693
+ } catch {
2694
+ }
2695
+ }
2696
+ }
2697
+ function transportNextSteps(dir, peers) {
2698
+ const stale = peers.filter((p) => p.stale);
2699
+ if (stale.length === 0) return [];
2700
+ return [
2701
+ {
2702
+ intent: `Pull fresh segments (${stale.length} stale peer${stale.length === 1 ? "" : "s"})`,
2703
+ command: `rsync -av <host>:${dir}/ ${dir}/`
2704
+ },
2705
+ {
2706
+ intent: "Or stop doing it by hand",
2707
+ command: `# share ${dir} with Syncthing on every machine`
2708
+ }
2709
+ ];
2710
+ }
2711
+
2712
+ export {
2713
+ OP_CTX_DDL,
2714
+ CAPTURE_TRIGGER_DDL,
2715
+ captureBookkeepingChanges,
2716
+ installCapture,
2717
+ TASK_STATUSES,
2718
+ isTaskStatus,
2719
+ TASK_STATUS_LIST,
2720
+ TASK_SUBSTATES,
2721
+ DEFAULT_SUBSTATE,
2722
+ TASK_SUBSTATE_ROWS,
2723
+ isValidPair,
2724
+ mapLegacyStatus,
2725
+ resolvePair,
2726
+ formatPair,
2727
+ defaultDbPath,
2728
+ openDb,
2729
+ WorkstreamNotFoundError,
2730
+ resolveWorkstreamId,
2731
+ tryResolveWorkstreamId,
2732
+ SchemaTooOldError,
2733
+ SchemaTooNewError,
2734
+ CURRENT_SCHEMA_VERSION,
2735
+ EXPECTED_TABLES,
2736
+ SYNCED_ENTITIES,
2737
+ MACHINE_LOCAL_ENTITIES,
2738
+ PORTABLE_TABLES,
2739
+ MACHINE_LOCAL_TABLES,
2740
+ HlcParseError,
2741
+ HlcOverflowError,
2742
+ MachineIdentityMissingError,
2743
+ formatHlc,
2744
+ parseHlc,
2745
+ compareHlc,
2746
+ nextHlc,
2747
+ receiveHlc,
2748
+ isLegacyLogOnlyIntent,
2749
+ LEGACY_LOG_ONLY_SQL_EXCLUSION,
2750
+ intentSpellings,
2751
+ currentOpContext,
2752
+ withOpContext,
2753
+ withCaptureSuppressed,
2754
+ locksDir,
2755
+ withFileLock,
2756
+ readFileLockMeta,
2757
+ OpEntityNotSyncedError,
2758
+ OpKeyMalformedError,
2759
+ repairTaskPair,
2760
+ applyOp,
2761
+ applyOps,
2762
+ readManifest,
2763
+ SEGMENT_FORMAT_VERSION,
2764
+ syncDir,
2765
+ localMachineId,
2766
+ segmentPath,
2767
+ flushSegment,
2768
+ segmentLineCount,
2769
+ verifyAgainstManifest,
2770
+ discoverPeers,
2771
+ getWatermark,
2772
+ setWatermark,
2773
+ resetWatermark,
2774
+ ingestSegment,
2775
+ applyIncomingOp,
2776
+ syncPass,
2777
+ SyncPeerNotFoundError,
2778
+ SyncPeerRefAmbiguousError,
2779
+ SyncSourceNotFoundError,
2780
+ PEER_STALE_MS,
2781
+ PEER_SHORT_LEN,
2782
+ peerStatuses,
2783
+ resolvePeerRef,
2784
+ repairPeer,
2785
+ syncEnabled,
2786
+ ambientIngest,
2787
+ ambientFlush,
2788
+ ambientSyncPass,
2789
+ ingestFromDb,
2790
+ transportNextSteps
2791
+ };
2792
+ //# sourceMappingURL=chunk-YIF3X32A.js.map