@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.
- package/dist/chunk-4PACTXAB.js +17 -0
- package/dist/chunk-4PACTXAB.js.map +1 -0
- package/dist/chunk-77GBZ2KR.js +85 -0
- package/dist/chunk-77GBZ2KR.js.map +1 -0
- package/dist/chunk-7OQNJWBY.js +391 -0
- package/dist/chunk-7OQNJWBY.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-WT7IN2OD.js +9887 -0
- package/dist/chunk-WT7IN2OD.js.map +1 -0
- package/dist/chunk-XXK7FR37.js +640 -0
- package/dist/chunk-XXK7FR37.js.map +1 -0
- package/dist/chunk-YIF3X32A.js +2792 -0
- package/dist/chunk-YIF3X32A.js.map +1 -0
- package/dist/cli-UZEMPIPW.js +6266 -0
- package/dist/cli-UZEMPIPW.js.map +1 -0
- package/dist/cli.js +37 -26127
- package/dist/cli.js.map +1 -1
- package/dist/extension/mu-pi.js +129 -103
- package/dist/extension/mu-pi.js.map +1 -1
- package/dist/index.d.ts +108 -53
- package/dist/index.js +324 -11728
- package/dist/index.js.map +1 -1
- package/dist/tui-X2XICJTN.js +6452 -0
- package/dist/tui-X2XICJTN.js.map +1 -0
- package/dist/tui-sync-worker.js +16 -0
- package/dist/tui-sync-worker.js.map +1 -0
- package/dist/yank-T3AYQTVR.js +12 -0
- package/dist/yank-T3AYQTVR.js.map +1 -0
- package/docs/ARCHITECTURE.md +9 -3
- package/docs/architecture/control-socket.md +5 -1
- package/docs/architecture/sync.md +18 -4
- package/docs/guide/dispatch.md +4 -0
- package/docs/guide/sql.md +1 -1
- package/docs/reference/env.md +2 -1
- package/package.json +1 -1
|
@@ -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
|