@mu-crew/mu 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,640 @@
1
+ import {
2
+ groupIdFromPrefix
3
+ } from "./chunk-WT7IN2OD.js";
4
+ import {
5
+ LEGACY_LOG_ONLY_SQL_EXCLUSION,
6
+ localMachineId,
7
+ mapLegacyStatus,
8
+ repairTaskPair,
9
+ resolvePair,
10
+ resolveWorkstreamId,
11
+ withOpContext
12
+ } from "./chunk-YIF3X32A.js";
13
+
14
+ // src/undo.ts
15
+ var UndoGroupNotFoundError = class extends Error {
16
+ constructor(groupId) {
17
+ super(`no ops found for group ${JSON.stringify(groupId)}`);
18
+ this.groupId = groupId;
19
+ this.name = "UndoGroupNotFoundError";
20
+ }
21
+ groupId;
22
+ errorNextSteps() {
23
+ return [
24
+ { intent: "List recent undoable groups", command: "mu undo" },
25
+ { intent: "Inspect a group's ops", command: "mu log --group <id>" }
26
+ ];
27
+ }
28
+ };
29
+ var NothingToUndoError = class extends Error {
30
+ constructor() {
31
+ super("the ops log is empty; nothing to undo");
32
+ this.name = "NothingToUndoError";
33
+ }
34
+ errorNextSteps() {
35
+ return [{ intent: "Make a change first", command: "mu task add <id> -t <title>" }];
36
+ }
37
+ };
38
+ var UndoSupersededError = class extends Error {
39
+ constructor(groupId, conflicts) {
40
+ const shown = conflicts.slice(0, 5).map((c) => `${c.key}.${c.field} (changed by ${c.groupId.slice(0, 8)})`).join(", ");
41
+ super(
42
+ `group ${groupId.slice(0, 8)} has been superseded: ${conflicts.length} field(s) were changed by a later action \u2014 ${shown}. Undoing would discard that newer work.`
43
+ );
44
+ this.groupId = groupId;
45
+ this.conflicts = conflicts;
46
+ this.name = "UndoSupersededError";
47
+ }
48
+ groupId;
49
+ conflicts;
50
+ errorNextSteps() {
51
+ return [
52
+ {
53
+ intent: "Inspect what changed since",
54
+ command: `mu log --group ${this.groupId.slice(0, 8)}`
55
+ },
56
+ {
57
+ intent: "Undo anyway, discarding the newer edits",
58
+ command: `mu undo ${this.groupId.slice(0, 8)} --yes --force`
59
+ }
60
+ ];
61
+ }
62
+ };
63
+ var ENTITY_RESTORE_ORDER = {
64
+ workstream: 0,
65
+ task: 1,
66
+ note: 2,
67
+ edge: 2
68
+ };
69
+ function restoreRank(entity) {
70
+ return ENTITY_RESTORE_ORDER[entity] ?? 3;
71
+ }
72
+ function priorFieldValue(db, entity, key, hlc, field, machineId) {
73
+ const row = db.prepare(
74
+ `SELECT json_extract(payload, '$.' || @field) AS value
75
+ FROM ops
76
+ WHERE entity = @entity
77
+ AND key = @key
78
+ AND op = 'put'
79
+ AND hlc < @hlc
80
+ AND (@machineId IS NULL OR machine_id = @machineId)
81
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
82
+ AND json_type(payload, '$.' || @field) IS NOT NULL
83
+ ORDER BY hlc DESC
84
+ LIMIT 1`
85
+ ).get({ entity, key, hlc, field, machineId: machineId ?? null });
86
+ if (row === void 0) return { found: false };
87
+ return { found: true, value: row.value };
88
+ }
89
+ function laterWriters(db, entity, key, hlc, field, excludeGroup, machineId) {
90
+ return db.prepare(
91
+ `SELECT DISTINCT group_id AS groupId, intent
92
+ FROM ops
93
+ WHERE entity = @entity
94
+ AND key = @key
95
+ AND op = 'put'
96
+ AND hlc > @hlc
97
+ AND group_id <> @excludeGroup
98
+ AND (@machineId IS NULL OR machine_id = @machineId)
99
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
100
+ AND json_type(payload, '$.' || @field) IS NOT NULL`
101
+ ).all({ entity, key, hlc, field, excludeGroup, machineId: machineId ?? null });
102
+ }
103
+ function laterRowWriters(db, entity, key, hlc, excludeGroup) {
104
+ return db.prepare(
105
+ `SELECT DISTINCT group_id AS groupId, intent
106
+ FROM ops
107
+ WHERE entity = @entity
108
+ AND key = @key
109
+ AND op = 'put'
110
+ AND hlc > @hlc
111
+ AND group_id <> @excludeGroup
112
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}`
113
+ ).all({ entity, key, hlc, excludeGroup });
114
+ }
115
+ function isDeletedByLaterGroup(db, entity, key, groupId) {
116
+ const row = db.prepare(
117
+ `SELECT op, group_id AS groupId FROM ops
118
+ WHERE entity = @entity AND key = @key
119
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
120
+ ORDER BY hlc DESC LIMIT 1`
121
+ ).get({ entity, key });
122
+ return row?.op === "del" && row.groupId !== groupId;
123
+ }
124
+ function laterDeleters(db, entity, key, hlc, excludeGroup) {
125
+ return db.prepare(
126
+ `SELECT DISTINCT group_id AS groupId, intent
127
+ FROM ops
128
+ WHERE entity = @entity
129
+ AND key = @key
130
+ AND op = 'del'
131
+ AND hlc > @hlc
132
+ AND group_id <> @excludeGroup`
133
+ ).all({ entity, key, hlc, excludeGroup });
134
+ }
135
+ function listRecentGroups(db, limit = 10) {
136
+ const rows = db.prepare(
137
+ `SELECT group_id AS groupId,
138
+ MAX(hlc) AS hlc,
139
+ MIN(created_at) AS when_,
140
+ COUNT(*) AS ops,
141
+ MAX(actor) AS actor
142
+ FROM ops
143
+ WHERE entity IN ('workstream','task','note','edge')
144
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
145
+ GROUP BY group_id
146
+ ORDER BY MAX(hlc) DESC
147
+ LIMIT @limit`
148
+ ).all({ limit });
149
+ return rows.map((row) => {
150
+ const intents = db.prepare(
151
+ `SELECT DISTINCT intent FROM ops
152
+ WHERE group_id = @groupId AND intent IS NOT NULL
153
+ ORDER BY seq`
154
+ ).all({ groupId: row.groupId }).map((r) => r.intent);
155
+ return {
156
+ groupId: row.groupId,
157
+ intents,
158
+ actor: row.actor,
159
+ ops: row.ops,
160
+ when: row.when_,
161
+ hlc: row.hlc
162
+ };
163
+ });
164
+ }
165
+ function mostRecentGroup(db) {
166
+ return listRecentGroups(db, 1)[0] ?? null;
167
+ }
168
+ function resolveGroupId(db, prefix) {
169
+ const resolved = groupIdFromPrefix(db, prefix);
170
+ if (resolved === null) throw new UndoGroupNotFoundError(prefix);
171
+ return resolved;
172
+ }
173
+ var CAPTURED_TABLE_FOR = {
174
+ workstream: "workstreams",
175
+ task: "tasks",
176
+ note: "task_notes",
177
+ edge: "task_edges"
178
+ };
179
+ var NEVER_RESTORE = /* @__PURE__ */ new Set(["id", "local_id", "name", "workstream_id", "task_id"]);
180
+ var OWNER_FIELD = "owner_id";
181
+ var NEVER_RECONSTRUCT = /* @__PURE__ */ new Set([...NEVER_RESTORE, OWNER_FIELD]);
182
+ var TOUCH_FIELD = "updated_at";
183
+ function planUndo(db, groupId) {
184
+ const rows = db.prepare(
185
+ `SELECT o.seq, o.hlc, o.group_id, o.machine_id, o.intent, o.actor, o.entity, o.key, o.op, o.payload,
186
+ (SELECT COUNT(*) FROM ops p
187
+ WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc
188
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}) AS prior,
189
+ (SELECT p.op FROM ops p
190
+ WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc
191
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
192
+ ORDER BY p.hlc DESC LIMIT 1) AS priorKind
193
+ FROM ops o
194
+ WHERE o.group_id = @groupId
195
+ AND o.entity IN ('workstream','task','note','edge')
196
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
197
+ ORDER BY o.hlc`
198
+ ).all({ groupId });
199
+ if (rows.length === 0) throw new UndoGroupNotFoundError(groupId);
200
+ const inverses = [];
201
+ let skipped = 0;
202
+ const seenLegacyNoteTasks = /* @__PURE__ */ new Set();
203
+ const restoredFields = /* @__PURE__ */ new Map();
204
+ let machine;
205
+ const localMachine = () => {
206
+ machine ??= localMachineId(db);
207
+ return machine;
208
+ };
209
+ for (const row of rows) {
210
+ if (row.op === "del") {
211
+ let fields2 = reconstructRow(db, row.entity, row.key, row.hlc);
212
+ if (Object.keys(fields2).length === 0) fields2 = payloadFields(row.payload);
213
+ if (Object.keys(fields2).length === 0 && row.entity === "note") {
214
+ const recovered = recoverLegacyNoteContents(db, row.key, row.hlc, seenLegacyNoteTasks);
215
+ for (const legacy2 of recovered) {
216
+ inverses.push({
217
+ entity: "note",
218
+ key: legacy2.key,
219
+ op: "put",
220
+ fields: legacy2.fields,
221
+ summary: `note ${legacy2.key} (recovered from legacy history)`,
222
+ supersededBy: []
223
+ });
224
+ }
225
+ skipped += 1;
226
+ continue;
227
+ }
228
+ const status = fields2.status;
229
+ const legacy = typeof status === "string" ? mapLegacyStatus(status) : null;
230
+ if (legacy) {
231
+ fields2.status = legacy.status;
232
+ fields2.substate = legacy.substate;
233
+ }
234
+ const conflicts = laterRowWriters(db, row.entity, row.key, row.hlc, groupId).map((w) => ({
235
+ field: "<row>",
236
+ groupId: w.groupId,
237
+ intent: w.intent
238
+ }));
239
+ inverses.push({
240
+ entity: row.entity,
241
+ key: row.key,
242
+ op: "put",
243
+ fields: fields2,
244
+ summary: `${row.entity} ${row.key} (from tombstone)`,
245
+ supersededBy: conflicts
246
+ });
247
+ continue;
248
+ }
249
+ if (row.prior === 0 || row.priorKind === "del") {
250
+ if (isDeletedByLaterGroup(db, row.entity, row.key, groupId)) {
251
+ skipped += 1;
252
+ continue;
253
+ }
254
+ const later = [...laterRowWriters(db, row.entity, row.key, row.hlc, groupId)];
255
+ inverses.push({
256
+ entity: row.entity,
257
+ key: row.key,
258
+ op: "del",
259
+ fields: {},
260
+ summary: `${row.entity} ${row.key} (it was created by this group)`,
261
+ supersededBy: later.map((w) => ({ field: "<row>", groupId: w.groupId, intent: w.intent }))
262
+ });
263
+ continue;
264
+ }
265
+ const payload = JSON.parse(row.payload);
266
+ const fields = {};
267
+ const supersededBy = [];
268
+ const rowKey = `${row.entity}\0${row.key}`;
269
+ const restored = restoredFields.get(rowKey) ?? /* @__PURE__ */ new Set();
270
+ restoredFields.set(rowKey, restored);
271
+ for (const field of Object.keys(payload)) {
272
+ if (NEVER_RESTORE.has(field) || restored.has(field)) continue;
273
+ restored.add(field);
274
+ let machineId;
275
+ if (field === OWNER_FIELD) {
276
+ if (row.machine_id !== localMachine()) continue;
277
+ machineId = localMachine();
278
+ }
279
+ const prior = field === OWNER_FIELD ? priorOwner(db, row.key, row.hlc, localMachine()) : priorFieldValue(db, row.entity, row.key, row.hlc, field, machineId);
280
+ if (!prior.found) {
281
+ continue;
282
+ }
283
+ if (field === OWNER_FIELD && prior.value !== null && !agentExists(db, prior.value)) continue;
284
+ const writers = laterWriters(db, row.entity, row.key, row.hlc, field, groupId, machineId);
285
+ if (field === TOUCH_FIELD && writers.length > 0) continue;
286
+ fields[field] = prior.value;
287
+ for (const writer of writers) {
288
+ supersededBy.push({ field, groupId: writer.groupId, intent: writer.intent });
289
+ }
290
+ }
291
+ for (const deleter of laterDeleters(db, row.entity, row.key, row.hlc, groupId)) {
292
+ supersededBy.push({ field: "<row>", groupId: deleter.groupId, intent: deleter.intent });
293
+ }
294
+ if (Object.keys(fields).length === 0) {
295
+ skipped += 1;
296
+ continue;
297
+ }
298
+ inverses.push({
299
+ entity: row.entity,
300
+ key: row.key,
301
+ op: "put",
302
+ fields,
303
+ summary: `${row.entity} ${row.key} ${Object.entries(fields).map(([f, v]) => `${f}=${v === null ? "NULL" : String(v)}`).join(" ")}`,
304
+ supersededBy
305
+ });
306
+ }
307
+ const ordered = [...inverses].sort((a, b) => {
308
+ if (a.op !== b.op) return a.op === "put" ? -1 : 1;
309
+ const rank = a.op === "put" ? restoreRank(a.entity) - restoreRank(b.entity) : restoreRank(b.entity) - restoreRank(a.entity);
310
+ return rank;
311
+ });
312
+ const intents = [...new Set(rows.map((r) => r.intent).filter((i) => i !== null))];
313
+ return {
314
+ groupId,
315
+ intents,
316
+ when: groupWhen(db, groupId),
317
+ inverses: ordered,
318
+ superseded: ordered.some((i) => i.supersededBy.length > 0),
319
+ skipped
320
+ };
321
+ }
322
+ function priorOwner(db, key, hlc, machineId) {
323
+ const local = priorFieldValue(db, "task", key, hlc, OWNER_FIELD, machineId);
324
+ if (!local.found) return { found: true, value: null };
325
+ const tomb = db.prepare(
326
+ `SELECT 1 FROM ops d
327
+ WHERE d.entity = 'task' AND d.key = @key AND d.op = 'del' AND d.hlc < @hlc
328
+ AND d.hlc > (SELECT MAX(hlc) FROM ops
329
+ WHERE entity = 'task' AND key = @key AND op = 'put'
330
+ AND hlc < @hlc AND machine_id = @machineId
331
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
332
+ AND json_type(payload, '$.owner_id') IS NOT NULL)
333
+ AND NOT EXISTS (SELECT 1 FROM ops p
334
+ WHERE p.entity = 'task' AND p.key = @key AND p.op = 'put'
335
+ AND p.hlc > d.hlc AND p.seq < d.seq)
336
+ LIMIT 1`
337
+ ).get({ key, hlc, machineId });
338
+ return tomb === void 0 ? local : { found: true, value: null };
339
+ }
340
+ function agentExists(db, id) {
341
+ return db.prepare("SELECT 1 FROM agents WHERE id = ?").get(id) !== void 0;
342
+ }
343
+ function groupWhen(db, groupId) {
344
+ const row = db.prepare("SELECT MIN(created_at) AS when_ FROM ops WHERE group_id = ?").get(groupId);
345
+ return row?.when_ ?? "";
346
+ }
347
+ function recoverLegacyNoteContents(db, tombstoneKey, beforeHlc, seen) {
348
+ const parsed = parseNoteKey(tombstoneKey);
349
+ if (parsed === null) return [];
350
+ if (seen.has(parsed.taskKey)) return [];
351
+ seen.add(parsed.taskKey);
352
+ const rows = db.prepare(
353
+ `SELECT key, payload FROM ops
354
+ WHERE entity = 'note'
355
+ AND op = 'put'
356
+ AND hlc < @hlc
357
+ AND key LIKE @prefix
358
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
359
+ ORDER BY hlc`
360
+ ).all({ hlc: beforeHlc, prefix: `${parsed.taskKey}#%` });
361
+ const out = [];
362
+ const contents = /* @__PURE__ */ new Set();
363
+ for (const row of rows) {
364
+ const fields = payloadFields(row.payload);
365
+ const content = fields.content;
366
+ if (typeof content !== "string" || content === "") continue;
367
+ if (contents.has(content)) continue;
368
+ contents.add(content);
369
+ out.push({ key: row.key, fields });
370
+ }
371
+ return out;
372
+ }
373
+ function payloadFields(payload) {
374
+ const fields = {};
375
+ const parsed = JSON.parse(payload);
376
+ for (const [field, value] of Object.entries(parsed)) {
377
+ if (NEVER_RECONSTRUCT.has(field)) continue;
378
+ if (value === null || typeof value === "string" || typeof value === "number") {
379
+ fields[field] = value;
380
+ }
381
+ }
382
+ return fields;
383
+ }
384
+ function reconstructRow(db, entity, key, beforeHlc) {
385
+ const puts = db.prepare(
386
+ `SELECT payload FROM ops
387
+ WHERE entity = @entity AND key = @key AND op = 'put' AND hlc < @hlc
388
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
389
+ ORDER BY hlc`
390
+ ).all({ entity, key, hlc: beforeHlc });
391
+ const fields = {};
392
+ for (const put of puts) {
393
+ const parsed = JSON.parse(put.payload);
394
+ for (const [field, value] of Object.entries(parsed)) {
395
+ if (NEVER_RECONSTRUCT.has(field)) continue;
396
+ if (value === null || typeof value === "string" || typeof value === "number") {
397
+ fields[field] = value;
398
+ }
399
+ }
400
+ }
401
+ return fields;
402
+ }
403
+ function undoGroup(db, groupId, opts = {}) {
404
+ const plan = planUndo(db, groupId);
405
+ if (plan.superseded && opts.force !== true) {
406
+ const conflicts = plan.inverses.flatMap(
407
+ (inv) => inv.supersededBy.map((s) => ({ key: inv.key, field: s.field, groupId: s.groupId }))
408
+ );
409
+ throw new UndoSupersededError(groupId, conflicts);
410
+ }
411
+ let applied = 0;
412
+ let undoGroupId = "";
413
+ const run = db.transaction(() => {
414
+ withOpContext(
415
+ db,
416
+ { intent: "undo", group: "new", ...opts.actor !== void 0 ? { actor: opts.actor } : {} },
417
+ () => {
418
+ const ctx = db.prepare("SELECT group_id FROM _op_ctx").get();
419
+ undoGroupId = ctx?.group_id ?? "";
420
+ for (const inverse of plan.inverses) {
421
+ if (applyInverse(db, inverse)) applied += 1;
422
+ }
423
+ }
424
+ );
425
+ });
426
+ run.immediate();
427
+ return { plan, undoGroupId: applied > 0 && undoGroupId !== "" ? undoGroupId : null, applied };
428
+ }
429
+ function applyInverse(db, inverse) {
430
+ const table = CAPTURED_TABLE_FOR[inverse.entity];
431
+ if (table === void 0) return false;
432
+ if (inverse.op === "del") return deleteRow(db, inverse);
433
+ return restoreRow(db, inverse, table);
434
+ }
435
+ function deleteRow(db, inverse) {
436
+ switch (inverse.entity) {
437
+ case "workstream":
438
+ return db.prepare("DELETE FROM workstreams WHERE name = ?").run(inverse.key).changes > 0;
439
+ case "task": {
440
+ const id = taskIdForKey(db, inverse.key);
441
+ if (id === null) return false;
442
+ return db.prepare("DELETE FROM tasks WHERE id = ?").run(id).changes > 0;
443
+ }
444
+ case "note": {
445
+ const parsed = parseNoteKey(inverse.key);
446
+ if (parsed === null) return false;
447
+ const taskId = taskIdForKey(db, parsed.taskKey);
448
+ if (taskId === null) return false;
449
+ const note = db.prepare(
450
+ `SELECT json_extract(payload, '$.content') AS content,
451
+ json_extract(payload, '$.author') AS author,
452
+ json_extract(payload, '$.created_at') AS created_at FROM ops
453
+ WHERE entity = 'note' AND key = ? AND op = 'put'
454
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
455
+ ORDER BY hlc DESC LIMIT 1`
456
+ ).get(inverse.key);
457
+ if (note?.content === void 0 || note.content === null) return false;
458
+ return db.prepare(
459
+ `DELETE FROM task_notes
460
+ WHERE task_id = @taskId AND content = @content
461
+ AND COALESCE(author, '') = COALESCE(@author, '')
462
+ AND (@createdAt IS NULL OR created_at = @createdAt)`
463
+ ).run({
464
+ taskId,
465
+ content: note.content,
466
+ author: note.author,
467
+ createdAt: note.created_at
468
+ }).changes > 0;
469
+ }
470
+ case "edge": {
471
+ const parsed = parseEdgeKey(inverse.key);
472
+ if (parsed === null) return false;
473
+ const from = taskIdForKey(db, parsed.blocker);
474
+ const to = taskIdForKey(db, parsed.blocked);
475
+ if (from === null || to === null) return false;
476
+ return db.prepare("DELETE FROM task_edges WHERE from_task_id = ? AND to_task_id = ?").run(from, to).changes > 0;
477
+ }
478
+ default:
479
+ return false;
480
+ }
481
+ }
482
+ function restoreRow(db, inverse, table) {
483
+ switch (inverse.entity) {
484
+ case "workstream": {
485
+ const exists = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(inverse.key);
486
+ if (!exists) {
487
+ db.prepare("INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(
488
+ inverse.key,
489
+ String(inverse.fields.created_at ?? (/* @__PURE__ */ new Date()).toISOString())
490
+ );
491
+ return true;
492
+ }
493
+ return updateFields(db, "workstreams", "id", exists.id, inverse.fields);
494
+ }
495
+ case "task": {
496
+ const parsed = parseTaskKey(inverse.key);
497
+ if (parsed === null) return false;
498
+ const id = taskIdForKey(db, inverse.key);
499
+ if (id === null) {
500
+ const wsId = ensureWorkstream(db, parsed.workstream);
501
+ const now = (/* @__PURE__ */ new Date()).toISOString();
502
+ const pair = resolvePair(
503
+ String(inverse.fields.status ?? "OPEN"),
504
+ inverse.fields.substate
505
+ ) ?? {
506
+ status: "OPEN",
507
+ substate: "todo"
508
+ };
509
+ db.prepare(
510
+ `INSERT INTO tasks (workstream_id, local_id, title, status, substate, impact,
511
+ effort_days, owner_id, created_at, updated_at)
512
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`
513
+ ).run(
514
+ wsId,
515
+ parsed.localId,
516
+ String(inverse.fields.title ?? parsed.localId),
517
+ pair.status,
518
+ pair.substate,
519
+ Number(inverse.fields.impact ?? 50),
520
+ Number(inverse.fields.effort_days ?? 1),
521
+ String(inverse.fields.created_at ?? now),
522
+ String(inverse.fields.updated_at ?? now)
523
+ );
524
+ return true;
525
+ }
526
+ const updated = updateFields(db, "tasks", "id", id, inverse.fields);
527
+ return repairTaskPair(db, id) || updated;
528
+ }
529
+ case "note": {
530
+ const parsed = parseNoteKey(inverse.key);
531
+ if (parsed === null) return false;
532
+ const taskId = taskIdForKey(db, parsed.taskKey);
533
+ if (taskId === null) return false;
534
+ const content = inverse.fields.content;
535
+ if (content === void 0 || content === null) return false;
536
+ const author = inverse.fields.author;
537
+ const createdAt = inverse.fields.created_at;
538
+ const exists = db.prepare(
539
+ `SELECT id FROM task_notes
540
+ WHERE task_id = @taskId AND content = @content
541
+ AND COALESCE(author, '') = COALESCE(@author, '')
542
+ AND (@createdAt IS NULL OR created_at = @createdAt)`
543
+ ).get({
544
+ taskId,
545
+ content: String(content),
546
+ author: author === void 0 || author === null ? null : String(author),
547
+ createdAt: createdAt === void 0 || createdAt === null ? null : String(createdAt)
548
+ });
549
+ if (exists) return false;
550
+ db.prepare(
551
+ "INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)"
552
+ ).run(
553
+ taskId,
554
+ inverse.fields.author === void 0 || inverse.fields.author === null ? null : String(inverse.fields.author),
555
+ String(content),
556
+ String(inverse.fields.created_at ?? (/* @__PURE__ */ new Date()).toISOString())
557
+ );
558
+ return true;
559
+ }
560
+ case "edge": {
561
+ const parsed = parseEdgeKey(inverse.key);
562
+ if (parsed === null) return false;
563
+ const from = taskIdForKey(db, parsed.blocker);
564
+ const to = taskIdForKey(db, parsed.blocked);
565
+ if (from === null || to === null) return false;
566
+ const exists = db.prepare("SELECT 1 AS x FROM task_edges WHERE from_task_id = ? AND to_task_id = ?").get(from, to);
567
+ if (exists) return false;
568
+ db.prepare(
569
+ "INSERT INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)"
570
+ ).run(from, to, String(inverse.fields.created_at ?? (/* @__PURE__ */ new Date()).toISOString()));
571
+ return true;
572
+ }
573
+ default:
574
+ void table;
575
+ return false;
576
+ }
577
+ }
578
+ function updateFields(db, table, idColumn, id, fields) {
579
+ const names = Object.keys(fields).filter((f) => !NEVER_RESTORE.has(f));
580
+ if (names.length === 0) return false;
581
+ const current = db.prepare(`SELECT * FROM ${table} WHERE ${idColumn} = ?`).get(id);
582
+ const pending = names.filter((name) => {
583
+ const target = fields[name] ?? null;
584
+ if (current === void 0) return true;
585
+ return current[name] !== target;
586
+ });
587
+ if (pending.length === 0) return false;
588
+ const setClause = pending.map((name) => `${name} = @${name}`).join(", ");
589
+ const params = { id };
590
+ for (const name of pending) params[name] = fields[name] ?? null;
591
+ db.prepare(`UPDATE ${table} SET ${setClause} WHERE ${idColumn} = @id`).run(params);
592
+ return true;
593
+ }
594
+ function ensureWorkstream(db, name) {
595
+ const existing = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(name);
596
+ if (existing) return existing.id;
597
+ db.prepare("INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(
598
+ name,
599
+ (/* @__PURE__ */ new Date()).toISOString()
600
+ );
601
+ return resolveWorkstreamId(db, name);
602
+ }
603
+ function parseTaskKey(key) {
604
+ const slash = key.indexOf("/");
605
+ if (slash <= 0 || slash === key.length - 1) return null;
606
+ return { workstream: key.slice(0, slash), localId: key.slice(slash + 1) };
607
+ }
608
+ function parseNoteKey(key) {
609
+ const hash = key.lastIndexOf("#");
610
+ if (hash <= 0) return null;
611
+ return { taskKey: key.slice(0, hash) };
612
+ }
613
+ function parseEdgeKey(key) {
614
+ const arrow = key.indexOf("->");
615
+ if (arrow <= 0) return null;
616
+ return { blocker: key.slice(0, arrow), blocked: key.slice(arrow + 2) };
617
+ }
618
+ function taskIdForKey(db, key) {
619
+ const parsed = parseTaskKey(key);
620
+ if (parsed === null) return null;
621
+ const row = db.prepare(
622
+ `SELECT t.id AS id FROM tasks t
623
+ JOIN workstreams w ON w.id = t.workstream_id
624
+ WHERE w.name = ? AND t.local_id = ?`
625
+ ).get(parsed.workstream, parsed.localId);
626
+ return row?.id ?? null;
627
+ }
628
+
629
+ export {
630
+ UndoGroupNotFoundError,
631
+ NothingToUndoError,
632
+ UndoSupersededError,
633
+ priorFieldValue,
634
+ listRecentGroups,
635
+ mostRecentGroup,
636
+ resolveGroupId,
637
+ planUndo,
638
+ undoGroup
639
+ };
640
+ //# sourceMappingURL=chunk-XXK7FR37.js.map