project-mgr 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +64 -14
- package/README_CN.md +52 -12
- package/dist/cli.js +111 -11
- package/dist/db.d.ts +37 -3
- package/dist/db.js +476 -52
- package/dist/domain.d.ts +13 -3
- package/dist/domain.js +26 -4
- package/dist/server.js +23 -14
- package/dist/web/script.js +21 -2
- package/dist/web/styles.d.ts +1 -1
- package/dist/web/styles.js +2 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +38 -1
package/dist/db.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import Database from "better-sqlite3";
|
|
@@ -88,6 +89,10 @@ export function openLedger(path = databasePath()) {
|
|
|
88
89
|
CREATE INDEX idx_todos_project ON todos(project);
|
|
89
90
|
INSERT INTO schema_migrations(version) VALUES (4);`);
|
|
90
91
|
}
|
|
92
|
+
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 5").get()) {
|
|
93
|
+
db.exec(`ALTER TABLE status_entries ADD COLUMN next_todo_uids_json TEXT;
|
|
94
|
+
INSERT INTO schema_migrations(version) VALUES (5);`);
|
|
95
|
+
}
|
|
91
96
|
}).immediate();
|
|
92
97
|
return db;
|
|
93
98
|
}
|
|
@@ -101,6 +106,12 @@ function entryFromRow(row) {
|
|
|
101
106
|
...(row.progress ? { progress: row.progress } : {}),
|
|
102
107
|
blockers: JSON.parse(row.blockers_json),
|
|
103
108
|
nextSteps: JSON.parse(row.next_steps_json),
|
|
109
|
+
// NULL means legacy (field absent). An explicit JSON array, including [], is new format.
|
|
110
|
+
...(row.next_todo_uids_json == null
|
|
111
|
+
? {}
|
|
112
|
+
: {
|
|
113
|
+
nextTodoUids: JSON.parse(row.next_todo_uids_json),
|
|
114
|
+
}),
|
|
104
115
|
...(row.next_steps_source_uid
|
|
105
116
|
? { nextStepsSourceUid: row.next_steps_source_uid }
|
|
106
117
|
: {}),
|
|
@@ -215,20 +226,11 @@ export function listProjects(db, options = {}) {
|
|
|
215
226
|
return false;
|
|
216
227
|
});
|
|
217
228
|
}
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
.transaction(() => {
|
|
222
|
-
const result = addValidatedStatus(db, input);
|
|
223
|
-
if (result.inserted && !result.entry.nextStepsSourceUid)
|
|
224
|
-
result.entry.nextSteps.forEach((_, index) => convertNextStep(db, result.entry.uid, index));
|
|
225
|
-
for (const target of completes)
|
|
226
|
-
changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
|
|
227
|
-
return result;
|
|
228
|
-
})
|
|
229
|
-
.immediate();
|
|
229
|
+
function linkCompletions(db, targets, byUid, source) {
|
|
230
|
+
for (const target of targets)
|
|
231
|
+
changeCompletion(db, target, byUid, "complete", undefined, source);
|
|
230
232
|
}
|
|
231
|
-
function
|
|
233
|
+
function existingIdentity(db, input, recordedAt, textRetry = false, carry = false) {
|
|
232
234
|
if (input.uid) {
|
|
233
235
|
const existing = one(db, `${select} WHERE e.uid = ?`, input.uid);
|
|
234
236
|
if (existing) {
|
|
@@ -237,7 +239,10 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
237
239
|
id: existing.id,
|
|
238
240
|
recordedAt: existing.recordedAt,
|
|
239
241
|
});
|
|
240
|
-
|
|
242
|
+
// Text/ref equivalence is only for addStatus sugar retries, not imports.
|
|
243
|
+
const sameWrite = isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
|
|
244
|
+
(textRetry && textRefEquivalent(db, existing, input, carry));
|
|
245
|
+
if (!sameWrite ||
|
|
241
246
|
(recordedAt !== undefined && recordedAt !== existing.recordedAt))
|
|
242
247
|
throw new ValidationError("conflicting entry uid");
|
|
243
248
|
return { entry: existing, inserted: false };
|
|
@@ -253,6 +258,161 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
253
258
|
return { entry: existing, inserted: false };
|
|
254
259
|
}
|
|
255
260
|
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
function textRefEquivalent(db, existing, input, carry = false) {
|
|
264
|
+
// Retry against stored pins, never today's open status or latest snapshot.
|
|
265
|
+
if (existing.nextTodoUids === undefined) {
|
|
266
|
+
if (!carry || input.nextTodoUids !== undefined || input.nextSteps?.length)
|
|
267
|
+
return false;
|
|
268
|
+
return isDeepStrictEqual(toPortableStatusEntry(existing), toPortableStatusEntry({
|
|
269
|
+
...input,
|
|
270
|
+
nextSteps: existing.nextSteps,
|
|
271
|
+
nextStepsSourceUid: existing.nextStepsSourceUid,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
if (!carry && input.nextTodoUids !== undefined)
|
|
275
|
+
return false;
|
|
276
|
+
assert.ok(input.nextSteps);
|
|
277
|
+
const titles = input.nextSteps;
|
|
278
|
+
const size = input.nextTodoUids?.length ?? titles.length;
|
|
279
|
+
if (carry
|
|
280
|
+
? size > existing.nextTodoUids.length
|
|
281
|
+
: size !== existing.nextTodoUids.length)
|
|
282
|
+
return false;
|
|
283
|
+
const candidate = toPortableStatusEntry({
|
|
284
|
+
...input,
|
|
285
|
+
nextSteps: [],
|
|
286
|
+
nextTodoUids: existing.nextTodoUids,
|
|
287
|
+
id: existing.id,
|
|
288
|
+
recordedAt: existing.recordedAt,
|
|
289
|
+
});
|
|
290
|
+
if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate))
|
|
291
|
+
return false;
|
|
292
|
+
const suffix = existing.nextTodoUids.slice(existing.nextTodoUids.length - size);
|
|
293
|
+
return suffix.every((uid, index) => {
|
|
294
|
+
if (input.nextTodoUids !== undefined)
|
|
295
|
+
return uid === input.nextTodoUids[index];
|
|
296
|
+
const todo = todoByUid(db, uid);
|
|
297
|
+
return todo !== undefined && todo.title === titles[index];
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export function addStatus(db, value, defaultSource = "human", completes = []) {
|
|
301
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
302
|
+
throw new ValidationError("entry must be an object");
|
|
303
|
+
const { nextMode = "replace", closeTodos = [], ...fields } = value;
|
|
304
|
+
if (nextMode !== "carry" && nextMode !== "replace")
|
|
305
|
+
throw new ValidationError("invalid nextMode");
|
|
306
|
+
if (!Array.isArray(closeTodos))
|
|
307
|
+
throw new ValidationError("closeTodos must be an array");
|
|
308
|
+
const input = validateStatusEntry(fields, defaultSource);
|
|
309
|
+
if (input.nextStepsSourceUid)
|
|
310
|
+
throw new ValidationError("new snapshots cannot set nextStepsSourceUid");
|
|
311
|
+
return db
|
|
312
|
+
.transaction(() => {
|
|
313
|
+
const replay = existingIdentity(db, input, undefined, true, nextMode === "carry");
|
|
314
|
+
if (replay) {
|
|
315
|
+
linkCompletions(db, completes, replay.entry.uid, input.source);
|
|
316
|
+
return replay;
|
|
317
|
+
}
|
|
318
|
+
const latest = current(db, input.project)[0];
|
|
319
|
+
const preserved = latest
|
|
320
|
+
? preservedNextWork(db, latest)
|
|
321
|
+
: { nextSteps: [], nextTodoUids: [] };
|
|
322
|
+
// Without refs, latest is a pending legacy snapshot: pin its converted indices.
|
|
323
|
+
const legacy = preserved.nextTodoUids === undefined
|
|
324
|
+
? legacySource(db, latest)
|
|
325
|
+
: undefined;
|
|
326
|
+
const pins = preserved.nextTodoUids ??
|
|
327
|
+
legacy.nextSteps.flatMap((_, index) => {
|
|
328
|
+
const todo = stepTodo(db, legacy.uid, index);
|
|
329
|
+
return todo ? [todo.uid] : [];
|
|
330
|
+
});
|
|
331
|
+
const closing = new Map();
|
|
332
|
+
for (const value of closeTodos) {
|
|
333
|
+
const close = record(value, ["uid", "status"]);
|
|
334
|
+
const uid = validateUid(close.uid);
|
|
335
|
+
if (close.status !== "completed" && close.status !== "cancelled")
|
|
336
|
+
throw new ValidationError("invalid closeTodos status");
|
|
337
|
+
if ((closing.has(uid) && closing.get(uid) !== close.status) ||
|
|
338
|
+
input.nextTodoUids?.includes(uid))
|
|
339
|
+
throw new ValidationError("cannot close a Todo in both statuses or also pin it");
|
|
340
|
+
const todo = todoByUid(db, uid);
|
|
341
|
+
if (!todo || (todo.project !== input.project && !pins.includes(uid)))
|
|
342
|
+
throw new ValidationError(`todo ${uid} does not belong to project ${input.project} or its current pins`);
|
|
343
|
+
closing.set(uid, close.status);
|
|
344
|
+
}
|
|
345
|
+
for (const [uid, status] of closing)
|
|
346
|
+
updateTodo(db, uid, { status });
|
|
347
|
+
if (nextMode === "carry" && preserved.nextTodoUids === undefined) {
|
|
348
|
+
if (input.nextSteps.length || input.nextTodoUids !== undefined)
|
|
349
|
+
throw new ValidationError(`project ${input.project} has unconverted legacy next steps; run pmgr todo-convert --project ${input.project} or pass --replace-next`);
|
|
350
|
+
const result = addValidatedStatus(db, { ...input, ...preserved });
|
|
351
|
+
linkCompletions(db, completes, result.entry.uid, input.source);
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
if (input.nextTodoUids !== undefined)
|
|
355
|
+
assertSnapshotRefs(db, input.project, input.nextTodoUids);
|
|
356
|
+
// Freeze candidates before creating this write's Todos: repeated new titles stay separate.
|
|
357
|
+
// Same-project open Todos plus unassigned open Todos already pinned here.
|
|
358
|
+
const candidates = [
|
|
359
|
+
...listTodos(db, input.project, { status: "open" }),
|
|
360
|
+
...pins.flatMap((pin) => {
|
|
361
|
+
const todo = todoByUid(db, pin);
|
|
362
|
+
return todo?.project === null && todo.status === "open" ? [todo] : [];
|
|
363
|
+
}),
|
|
364
|
+
];
|
|
365
|
+
candidates.sort((a, b) => Number(pins.includes(b.uid)) - Number(pins.includes(a.uid)) ||
|
|
366
|
+
a.createdAt.localeCompare(b.createdAt) ||
|
|
367
|
+
a.uid.localeCompare(b.uid));
|
|
368
|
+
const uid = input.uid ?? randomUUID();
|
|
369
|
+
const newTodos = [];
|
|
370
|
+
const resolved = input.nextTodoUids ??
|
|
371
|
+
input.nextSteps.map((title, index) => {
|
|
372
|
+
const match = input.nextSteps.indexOf(title) === index
|
|
373
|
+
? candidates.find((todo) => todo.title === title)
|
|
374
|
+
: undefined;
|
|
375
|
+
if (match)
|
|
376
|
+
return match.uid;
|
|
377
|
+
const uid = randomUUID();
|
|
378
|
+
newTodos.push({ uid, title });
|
|
379
|
+
return uid;
|
|
380
|
+
});
|
|
381
|
+
const nextTodoUids = [
|
|
382
|
+
...new Set([
|
|
383
|
+
...(nextMode === "carry"
|
|
384
|
+
? pins.filter((uid) => todoByUid(db, uid)?.status === "open")
|
|
385
|
+
: []),
|
|
386
|
+
...resolved,
|
|
387
|
+
]),
|
|
388
|
+
];
|
|
389
|
+
if (nextTodoUids.length > 100)
|
|
390
|
+
throw new ValidationError("nextTodoUids must be an array of at most 100 uuids");
|
|
391
|
+
const recordedAt = new Date().toISOString();
|
|
392
|
+
const result = addValidatedStatus(db, { ...input, uid, nextSteps: [], nextTodoUids }, recordedAt);
|
|
393
|
+
assert.equal(result.inserted, true);
|
|
394
|
+
newTodos.forEach(({ uid, title }) => {
|
|
395
|
+
insertTodo(db, {
|
|
396
|
+
uid,
|
|
397
|
+
title,
|
|
398
|
+
project: result.entry.project,
|
|
399
|
+
entryUid: result.entry.uid,
|
|
400
|
+
// Provenance indexes the final snapshot, including carried/reused pins.
|
|
401
|
+
stepIndex: nextTodoUids.indexOf(uid),
|
|
402
|
+
status: "open",
|
|
403
|
+
createdAt: recordedAt,
|
|
404
|
+
completedAt: null,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
linkCompletions(db, completes, result.entry.uid, input.source);
|
|
408
|
+
return result;
|
|
409
|
+
})
|
|
410
|
+
.immediate();
|
|
411
|
+
}
|
|
412
|
+
function addValidatedStatus(db, input, recordedAt) {
|
|
413
|
+
const existing = existingIdentity(db, input, recordedAt);
|
|
414
|
+
if (existing)
|
|
415
|
+
return existing;
|
|
256
416
|
if (input.nextStepsSourceUid) {
|
|
257
417
|
const origin = getEntry(db, input.nextStepsSourceUid);
|
|
258
418
|
if (!origin ||
|
|
@@ -268,9 +428,11 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
268
428
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
269
429
|
.get(input.project);
|
|
270
430
|
const result = db
|
|
271
|
-
.prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid, next_steps_source_uid)
|
|
272
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
273
|
-
.run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID(), input.nextStepsSourceUid ?? null
|
|
431
|
+
.prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid, next_steps_source_uid, next_todo_uids_json)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
433
|
+
.run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID(), input.nextStepsSourceUid ?? null, input.nextTodoUids === undefined
|
|
434
|
+
? null
|
|
435
|
+
: JSON.stringify(input.nextTodoUids));
|
|
274
436
|
return {
|
|
275
437
|
entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
|
|
276
438
|
inserted: true,
|
|
@@ -278,7 +440,14 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
278
440
|
}
|
|
279
441
|
export function importStatuses(db, values) {
|
|
280
442
|
const inputs = values.map((value) => validateStatusEntry(value, "import"));
|
|
281
|
-
return db.transaction(() =>
|
|
443
|
+
return db.transaction(() => {
|
|
444
|
+
const results = inputs.map((input) => addValidatedStatus(db, input));
|
|
445
|
+
// Entry-only imports declare no Todos, so every ref is an omitted dependency.
|
|
446
|
+
const payloadTodos = new Set();
|
|
447
|
+
for (const result of results)
|
|
448
|
+
assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
|
|
449
|
+
return results;
|
|
450
|
+
})();
|
|
282
451
|
}
|
|
283
452
|
export function current(db, project) {
|
|
284
453
|
const where = project ? " AND p.slug = ?" : "";
|
|
@@ -287,6 +456,109 @@ export function current(db, project) {
|
|
|
287
456
|
.all(...(project ? [project] : []));
|
|
288
457
|
return rows.map((row) => annotateEntry(db, entryFromRow(row)));
|
|
289
458
|
}
|
|
459
|
+
/** Per-project open work from one current() and one Todo scan; no cross-project dedupe. */
|
|
460
|
+
export function openWorkByProject(db, project) {
|
|
461
|
+
const latest = current(db, project);
|
|
462
|
+
const byProject = new Map();
|
|
463
|
+
const unassigned = [];
|
|
464
|
+
for (const todo of listTodos(db, project)) {
|
|
465
|
+
if (todo.project === null)
|
|
466
|
+
unassigned.push(todo);
|
|
467
|
+
else
|
|
468
|
+
byProject.set(todo.project, [
|
|
469
|
+
...(byProject.get(todo.project) ?? []),
|
|
470
|
+
todo,
|
|
471
|
+
]);
|
|
472
|
+
}
|
|
473
|
+
const names = project !== undefined
|
|
474
|
+
? [project]
|
|
475
|
+
: [
|
|
476
|
+
...latest.map((entry) => entry.project),
|
|
477
|
+
...[...byProject.keys()]
|
|
478
|
+
.filter((name) => !latest.some((entry) => entry.project === name))
|
|
479
|
+
.sort(),
|
|
480
|
+
];
|
|
481
|
+
const groups = new Map();
|
|
482
|
+
for (const name of names) {
|
|
483
|
+
const items = [];
|
|
484
|
+
const seen = new Set();
|
|
485
|
+
const add = (todo, pinIndex) => {
|
|
486
|
+
if (todo.status !== "open" || seen.has(todo.uid))
|
|
487
|
+
return;
|
|
488
|
+
seen.add(todo.uid);
|
|
489
|
+
items.push({
|
|
490
|
+
kind: "todo",
|
|
491
|
+
uid: todo.uid,
|
|
492
|
+
title: todo.title,
|
|
493
|
+
project: name,
|
|
494
|
+
pinned: pinIndex !== null,
|
|
495
|
+
pinIndex,
|
|
496
|
+
createdAt: todo.createdAt,
|
|
497
|
+
entryUid: todo.entryUid,
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
const entry = latest.find((entry) => entry.project === name);
|
|
501
|
+
if (entry?.nextTodoUids !== undefined) {
|
|
502
|
+
entry.nextTodoUids.forEach((uid, index) => {
|
|
503
|
+
const todo = todoByUid(db, uid);
|
|
504
|
+
if (todo)
|
|
505
|
+
add(todo, index);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else if (entry) {
|
|
509
|
+
const source = legacySource(db, entry);
|
|
510
|
+
source.nextSteps.forEach((title, index) => {
|
|
511
|
+
const todo = stepTodo(db, source.uid, index);
|
|
512
|
+
if (todo)
|
|
513
|
+
add(todo, index);
|
|
514
|
+
else
|
|
515
|
+
items.push({
|
|
516
|
+
kind: "legacy-step",
|
|
517
|
+
project: name,
|
|
518
|
+
entryUid: source.uid,
|
|
519
|
+
stepIndex: index,
|
|
520
|
+
title,
|
|
521
|
+
pinned: true,
|
|
522
|
+
pinIndex: index,
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
for (const todo of byProject.get(name) ?? [])
|
|
527
|
+
add(todo, null);
|
|
528
|
+
groups.set(name, items);
|
|
529
|
+
}
|
|
530
|
+
return { groups, unassigned };
|
|
531
|
+
}
|
|
532
|
+
export function openWork(db, project) {
|
|
533
|
+
const { groups, unassigned } = openWorkByProject(db, project);
|
|
534
|
+
if (project !== undefined)
|
|
535
|
+
return groups.get(project);
|
|
536
|
+
// Globally, a Todo pinned by several projects appears only under the first.
|
|
537
|
+
const items = [];
|
|
538
|
+
const seen = new Set();
|
|
539
|
+
for (const group of groups.values())
|
|
540
|
+
for (const item of group) {
|
|
541
|
+
if (item.kind === "todo") {
|
|
542
|
+
if (seen.has(item.uid))
|
|
543
|
+
continue;
|
|
544
|
+
seen.add(item.uid);
|
|
545
|
+
}
|
|
546
|
+
items.push(item);
|
|
547
|
+
}
|
|
548
|
+
for (const todo of unassigned)
|
|
549
|
+
if (todo.status === "open" && !seen.has(todo.uid))
|
|
550
|
+
items.push({
|
|
551
|
+
kind: "todo",
|
|
552
|
+
uid: todo.uid,
|
|
553
|
+
title: todo.title,
|
|
554
|
+
project: null,
|
|
555
|
+
pinned: false,
|
|
556
|
+
pinIndex: null,
|
|
557
|
+
createdAt: todo.createdAt,
|
|
558
|
+
entryUid: todo.entryUid,
|
|
559
|
+
});
|
|
560
|
+
return items;
|
|
561
|
+
}
|
|
290
562
|
export function history(db, project, options = {}) {
|
|
291
563
|
const clauses = [];
|
|
292
564
|
const params = [];
|
|
@@ -310,6 +582,7 @@ export function history(db, project, options = {}) {
|
|
|
310
582
|
return rows.map((row) => annotateEntry(db, entryFromRow(row)));
|
|
311
583
|
}
|
|
312
584
|
export function at(db, project, occurredAt) {
|
|
585
|
+
// Stored snapshot only. Refs may be present; live Todo titles are not historical.
|
|
313
586
|
return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
|
|
314
587
|
}
|
|
315
588
|
export function countEntries(db, project) {
|
|
@@ -358,6 +631,9 @@ function annotateEntry(db, entry) {
|
|
|
358
631
|
...entry,
|
|
359
632
|
effectiveStatus: followUp ? "completed" : entry.status,
|
|
360
633
|
isCurrent: newest?.id === entry.id,
|
|
634
|
+
...(entry.nextTodoUids !== undefined
|
|
635
|
+
? { nextTodos: entry.nextTodoUids.map((uid) => resolveNextTodo(db, uid)) }
|
|
636
|
+
: {}),
|
|
361
637
|
...(followUp && latest
|
|
362
638
|
? {
|
|
363
639
|
completedBy: {
|
|
@@ -450,16 +726,13 @@ export function exportLedger(db, project) {
|
|
|
450
726
|
const rows = db
|
|
451
727
|
.prepare(`SELECT c.* FROM completion_events c JOIN status_entries e ON e.uid = c.target_uid JOIN projects p ON p.id = e.project_id ${project ? "WHERE p.slug = ?" : ""} ORDER BY c.id`)
|
|
452
728
|
.all(...(project ? [project] : []));
|
|
453
|
-
const todos =
|
|
729
|
+
const todos = project ? todosVisibleToProject(db, project) : listTodos(db);
|
|
730
|
+
const version = ledgerVersion(entries, todos);
|
|
454
731
|
return {
|
|
455
|
-
version
|
|
456
|
-
? 3
|
|
457
|
-
: 2,
|
|
732
|
+
version,
|
|
458
733
|
entries,
|
|
459
734
|
completions: rows.map(completionFromRow),
|
|
460
|
-
...(todos
|
|
461
|
-
? { todos }
|
|
462
|
-
: {}),
|
|
735
|
+
...(version >= 3 ? { todos } : {}),
|
|
463
736
|
};
|
|
464
737
|
})();
|
|
465
738
|
}
|
|
@@ -505,11 +778,31 @@ function validateCompletionEvent(value) {
|
|
|
505
778
|
source: row.source,
|
|
506
779
|
};
|
|
507
780
|
}
|
|
781
|
+
function declaredTodoUids(values) {
|
|
782
|
+
const uids = new Set();
|
|
783
|
+
for (const value of values) {
|
|
784
|
+
if (!value || typeof value !== "object" || !("todos" in value))
|
|
785
|
+
continue;
|
|
786
|
+
const todos = value.todos;
|
|
787
|
+
if (!Array.isArray(todos))
|
|
788
|
+
continue;
|
|
789
|
+
for (const todo of todos) {
|
|
790
|
+
if (todo &&
|
|
791
|
+
typeof todo === "object" &&
|
|
792
|
+
"uid" in todo &&
|
|
793
|
+
typeof todo.uid === "string")
|
|
794
|
+
uids.add(todo.uid);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return uids;
|
|
798
|
+
}
|
|
508
799
|
export function importLedger(db, values) {
|
|
509
800
|
// Both legacy entries and versioned bundles are accepted atomically, including JSONL.
|
|
510
801
|
return db
|
|
511
802
|
.transaction(() => {
|
|
512
803
|
const results = [];
|
|
804
|
+
// Refs must name Todos declared in this payload, not only rows already stored.
|
|
805
|
+
const payloadTodos = declaredTodoUids(values);
|
|
513
806
|
for (const value of values) {
|
|
514
807
|
if (value && typeof value === "object" && "version" in value) {
|
|
515
808
|
const bundle = record(value, [
|
|
@@ -518,27 +811,37 @@ export function importLedger(db, values) {
|
|
|
518
811
|
"completions",
|
|
519
812
|
"todos",
|
|
520
813
|
]);
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
(
|
|
814
|
+
const version = bundle.version;
|
|
815
|
+
if ((version !== 2 && version !== 3 && version !== 4) ||
|
|
816
|
+
((version === 3 || version === 4) &&
|
|
817
|
+
!Array.isArray(bundle.todos)) ||
|
|
818
|
+
(version === 2 && bundle.todos !== undefined) ||
|
|
524
819
|
!Array.isArray(bundle.entries) ||
|
|
525
820
|
!Array.isArray(bundle.completions))
|
|
526
821
|
throw new ValidationError("invalid ledger bundle");
|
|
822
|
+
const imported = [];
|
|
527
823
|
for (const item of bundle.entries) {
|
|
528
824
|
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
529
825
|
throw new ValidationError("entry must be an object");
|
|
530
826
|
const { recordedAt, ...input } = item;
|
|
531
827
|
validateUid(input.uid);
|
|
532
|
-
|
|
828
|
+
const result = addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt"));
|
|
829
|
+
imported.push(result.entry);
|
|
830
|
+
results.push(result);
|
|
533
831
|
}
|
|
534
832
|
for (const event of bundle.completions)
|
|
535
833
|
insertCompletion(db, validateCompletionEvent(event));
|
|
536
|
-
if (
|
|
834
|
+
if (version === 3 || version === 4)
|
|
537
835
|
for (const todo of bundle.todos)
|
|
538
836
|
importTodo(db, todo);
|
|
837
|
+
for (const entry of imported)
|
|
838
|
+
assertSnapshotRefs(db, entry.project, entry.nextTodoUids, payloadTodos);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const result = addValidatedStatus(db, validateStatusEntry(value, "import"));
|
|
842
|
+
results.push(result);
|
|
843
|
+
assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
|
|
539
844
|
}
|
|
540
|
-
else
|
|
541
|
-
results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
|
|
542
845
|
}
|
|
543
846
|
return results;
|
|
544
847
|
})
|
|
@@ -546,12 +849,121 @@ export function importLedger(db, values) {
|
|
|
546
849
|
}
|
|
547
850
|
const todoSelect = `SELECT uid, title, project, entry_uid AS entryUid,
|
|
548
851
|
step_index AS stepIndex, status, created_at AS createdAt, completed_at AS completedAt FROM todos`;
|
|
549
|
-
|
|
852
|
+
function ledgerVersion(entries, todos) {
|
|
853
|
+
if (entries.some((entry) => entry.nextTodoUids !== undefined))
|
|
854
|
+
return 4;
|
|
855
|
+
if (todos.length > 0 ||
|
|
856
|
+
entries.some((entry) => entry.nextStepsSourceUid !== undefined))
|
|
857
|
+
return 3;
|
|
858
|
+
return 2;
|
|
859
|
+
}
|
|
860
|
+
function todosVisibleToProject(db, project) {
|
|
861
|
+
return db
|
|
862
|
+
.prepare(`${todoSelect} WHERE project = ?
|
|
863
|
+
OR uid IN (
|
|
864
|
+
SELECT j.value FROM status_entries e
|
|
865
|
+
JOIN projects p ON p.id = e.project_id
|
|
866
|
+
JOIN json_each(e.next_todo_uids_json) j
|
|
867
|
+
WHERE p.slug = ? AND e.next_todo_uids_json IS NOT NULL
|
|
868
|
+
)
|
|
869
|
+
ORDER BY created_at, uid`)
|
|
870
|
+
.all(project, project);
|
|
871
|
+
}
|
|
872
|
+
function todoByUid(db, uid) {
|
|
873
|
+
return db.prepare(todoSelect + " WHERE uid = ?").get(uid);
|
|
874
|
+
}
|
|
875
|
+
function resolveNextTodo(db, uid) {
|
|
876
|
+
const todo = todoByUid(db, uid);
|
|
877
|
+
if (!todo)
|
|
878
|
+
return { uid, title: uid, status: "open", project: null };
|
|
879
|
+
return {
|
|
880
|
+
uid: todo.uid,
|
|
881
|
+
title: todo.title,
|
|
882
|
+
status: todo.status,
|
|
883
|
+
project: todo.project,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function assertSnapshotRefs(db, project, uids, payloadTodos) {
|
|
887
|
+
if (uids === undefined)
|
|
888
|
+
return;
|
|
889
|
+
for (const uid of uids) {
|
|
890
|
+
const todo = todoByUid(db, uid);
|
|
891
|
+
if (!todo)
|
|
892
|
+
throw new ValidationError("dangling next todo uid");
|
|
893
|
+
if (todo.project !== null && todo.project !== project)
|
|
894
|
+
throw new ValidationError("cross-project next todo ref");
|
|
895
|
+
if (payloadTodos && !payloadTodos.has(uid))
|
|
896
|
+
throw new ValidationError("dangling next todo uid");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function todoIsReferenced(db, uid) {
|
|
900
|
+
return (db
|
|
901
|
+
.prepare(`SELECT 1 AS found FROM status_entries, json_each(next_todo_uids_json)
|
|
902
|
+
WHERE next_todo_uids_json IS NOT NULL AND json_each.value = ? LIMIT 1`)
|
|
903
|
+
.get(uid) !== undefined);
|
|
904
|
+
}
|
|
905
|
+
function stepTodo(db, entryUid, index) {
|
|
550
906
|
return db
|
|
907
|
+
.prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
|
|
908
|
+
.get(entryUid, index);
|
|
909
|
+
}
|
|
910
|
+
function legacySource(db, entry) {
|
|
911
|
+
if (!entry.nextStepsSourceUid)
|
|
912
|
+
return entry;
|
|
913
|
+
const origin = getEntry(db, entry.nextStepsSourceUid);
|
|
914
|
+
assert.ok(origin);
|
|
915
|
+
assert.equal(origin.nextStepsSourceUid, undefined);
|
|
916
|
+
return origin;
|
|
917
|
+
}
|
|
918
|
+
function preservedNextWork(db, latest) {
|
|
919
|
+
if (latest.nextTodoUids !== undefined)
|
|
920
|
+
return { nextSteps: [], nextTodoUids: [...latest.nextTodoUids] };
|
|
921
|
+
const source = legacySource(db, latest);
|
|
922
|
+
const pending = source.nextSteps.some((_, index) => stepTodo(db, source.uid, index) === undefined);
|
|
923
|
+
if (pending)
|
|
924
|
+
return {
|
|
925
|
+
nextSteps: [...source.nextSteps],
|
|
926
|
+
nextStepsSourceUid: source.uid,
|
|
927
|
+
};
|
|
928
|
+
return {
|
|
929
|
+
nextSteps: [],
|
|
930
|
+
nextTodoUids: source.nextSteps.map((_, index) => {
|
|
931
|
+
const todo = stepTodo(db, source.uid, index);
|
|
932
|
+
assert.ok(todo);
|
|
933
|
+
return todo.uid;
|
|
934
|
+
}),
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
export function listTodos(db, project, options = {}) {
|
|
938
|
+
const clauses = [];
|
|
939
|
+
const params = [];
|
|
940
|
+
if (project) {
|
|
941
|
+
clauses.push("project = ?");
|
|
942
|
+
params.push(project);
|
|
943
|
+
}
|
|
944
|
+
if (options.status !== undefined) {
|
|
945
|
+
if (!["open", "completed", "cancelled"].includes(options.status))
|
|
946
|
+
throw new ValidationError("invalid todo status");
|
|
947
|
+
clauses.push("status = ?");
|
|
948
|
+
params.push(options.status);
|
|
949
|
+
}
|
|
950
|
+
const rows = db
|
|
551
951
|
.prepare(todoSelect +
|
|
552
|
-
(
|
|
952
|
+
(clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "") +
|
|
553
953
|
" ORDER BY created_at, uid")
|
|
554
|
-
.all(...
|
|
954
|
+
.all(...params);
|
|
955
|
+
const pins = options.status === "open" && project
|
|
956
|
+
? current(db, project)[0]?.nextTodoUids
|
|
957
|
+
: undefined;
|
|
958
|
+
if (pins?.length) {
|
|
959
|
+
const rank = new Map(pins.map((uid, index) => [uid, index]));
|
|
960
|
+
rows.sort((left, right) => {
|
|
961
|
+
const leftRank = rank.get(left.uid) ?? pins.length;
|
|
962
|
+
const rightRank = rank.get(right.uid) ?? pins.length;
|
|
963
|
+
return leftRank - rightRank;
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
return rows;
|
|
555
967
|
}
|
|
556
968
|
function insertTodo(db, todo) {
|
|
557
969
|
db.prepare(`INSERT INTO todos(uid,title,project,entry_uid,step_index,status,created_at,completed_at)
|
|
@@ -638,15 +1050,21 @@ export function updateTodo(db, uid, value) {
|
|
|
638
1050
|
}
|
|
639
1051
|
}
|
|
640
1052
|
if (input.project !== undefined) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1053
|
+
const nextProject = input.project === null
|
|
1054
|
+
? null
|
|
1055
|
+
: validateProjectMetadataInput({ project: input.project }).project;
|
|
1056
|
+
if (nextProject !== todo.project) {
|
|
1057
|
+
if (todo.entryUid || todoIsReferenced(db, todo.uid))
|
|
1058
|
+
throw new ValidationError(todo.entryUid
|
|
1059
|
+
? "source-linked todo project cannot change"
|
|
1060
|
+
: "referenced todo project cannot change");
|
|
1061
|
+
if (nextProject &&
|
|
1062
|
+
!db
|
|
1063
|
+
.prepare("SELECT 1 FROM projects WHERE slug = ?")
|
|
1064
|
+
.get(nextProject))
|
|
1065
|
+
throw new ValidationError("project not found");
|
|
1066
|
+
todo.project = nextProject;
|
|
1067
|
+
}
|
|
650
1068
|
}
|
|
651
1069
|
db.prepare("UPDATE todos SET status=?, completed_at=?, project=? WHERE uid=?").run(todo.status, todo.completedAt, todo.project, uid);
|
|
652
1070
|
return todo;
|
|
@@ -684,11 +1102,17 @@ function importTodo(db, value) {
|
|
|
684
1102
|
const entry = getEntry(db, validateUid(input.entryUid));
|
|
685
1103
|
if (!entry || entry.project !== input.project)
|
|
686
1104
|
throw new ValidationError("invalid todo source");
|
|
687
|
-
if (input.stepIndex !== null
|
|
688
|
-
(!Number.isInteger(input.stepIndex) ||
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1105
|
+
if (input.stepIndex !== null) {
|
|
1106
|
+
if (!Number.isInteger(input.stepIndex) || Number(input.stepIndex) < 0)
|
|
1107
|
+
throw new ValidationError("invalid next step index");
|
|
1108
|
+
const index = Number(input.stepIndex);
|
|
1109
|
+
if (entry.nextTodoUids !== undefined) {
|
|
1110
|
+
if (entry.nextTodoUids[index] !== input.uid)
|
|
1111
|
+
throw new ValidationError("invalid next step index");
|
|
1112
|
+
}
|
|
1113
|
+
else if (entry.nextSteps[index] !== input.title)
|
|
1114
|
+
throw new ValidationError("invalid next step index");
|
|
1115
|
+
}
|
|
692
1116
|
}
|
|
693
1117
|
else if (input.stepIndex !== null)
|
|
694
1118
|
throw new ValidationError("next step requires a source");
|
|
@@ -711,18 +1135,18 @@ export function completeActivity(db, ref) {
|
|
|
711
1135
|
return entry;
|
|
712
1136
|
const latest = current(db, entry.project)[0];
|
|
713
1137
|
const occurredAt = new Date(Math.max(Date.now(), Date.parse(latest.occurredAt) + 1)).toISOString();
|
|
714
|
-
//
|
|
1138
|
+
// Copy refs, or keep the full legacy string list until every index is converted.
|
|
1139
|
+
const preserved = preservedNextWork(db, latest);
|
|
715
1140
|
const followUp = addValidatedStatus(db, validateStatusEntry({
|
|
716
1141
|
project: entry.project,
|
|
717
1142
|
status: latest.status,
|
|
718
1143
|
summary: `Completed activity #${entry.id}`,
|
|
719
1144
|
progress: latest.progress,
|
|
720
1145
|
blockers: latest.blockers,
|
|
721
|
-
nextSteps: latest.nextSteps,
|
|
722
|
-
nextStepsSourceUid: latest.nextStepsSourceUid ?? latest.uid,
|
|
723
1146
|
decisions: latest.decisions,
|
|
724
1147
|
tags: latest.tags,
|
|
725
1148
|
occurredAt,
|
|
1149
|
+
...preserved,
|
|
726
1150
|
}));
|
|
727
1151
|
changeCompletion(db, entry.uid, followUp.entry.uid);
|
|
728
1152
|
return getEntry(db, entry.uid);
|