project-mgr 0.3.2 → 0.5.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/CHANGELOG.md +34 -0
- package/README.md +50 -1
- package/README_CN.md +79 -2
- package/dist/cli.js +63 -4
- package/dist/db.d.ts +21 -1
- package/dist/db.js +488 -22
- package/dist/domain.d.ts +15 -3
- package/dist/domain.js +29 -1
- package/dist/server.js +98 -14
- package/dist/web/locales/en.js +16 -0
- package/dist/web/locales/zh.d.ts +16 -0
- package/dist/web/locales/zh.js +16 -0
- package/dist/web/script.js +109 -6
- package/dist/web/styles.d.ts +1 -1
- package/dist/web/styles.js +2 -0
- package/dist/web/ui.js +12 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +23 -3
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";
|
|
@@ -75,6 +76,23 @@ export function openLedger(path = databasePath()) {
|
|
|
75
76
|
BEGIN SELECT RAISE(ABORT, 'entry uid required; upgrade the writer'); END;`);
|
|
76
77
|
db.prepare("INSERT INTO schema_migrations(version) VALUES (3)").run();
|
|
77
78
|
}
|
|
79
|
+
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 4").get()) {
|
|
80
|
+
db.exec(`ALTER TABLE status_entries ADD COLUMN next_steps_source_uid TEXT REFERENCES status_entries(uid);
|
|
81
|
+
CREATE TABLE todos (
|
|
82
|
+
uid TEXT PRIMARY KEY, title TEXT NOT NULL,
|
|
83
|
+
project TEXT REFERENCES projects(slug),
|
|
84
|
+
entry_uid TEXT REFERENCES status_entries(uid),
|
|
85
|
+
step_index INTEGER, status TEXT NOT NULL CHECK(status IN ('open','completed','cancelled')),
|
|
86
|
+
created_at TEXT NOT NULL, completed_at TEXT,
|
|
87
|
+
UNIQUE(entry_uid, step_index)
|
|
88
|
+
);
|
|
89
|
+
CREATE INDEX idx_todos_project ON todos(project);
|
|
90
|
+
INSERT INTO schema_migrations(version) VALUES (4);`);
|
|
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
|
+
}
|
|
78
96
|
}).immediate();
|
|
79
97
|
return db;
|
|
80
98
|
}
|
|
@@ -88,6 +106,15 @@ function entryFromRow(row) {
|
|
|
88
106
|
...(row.progress ? { progress: row.progress } : {}),
|
|
89
107
|
blockers: JSON.parse(row.blockers_json),
|
|
90
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
|
+
}),
|
|
115
|
+
...(row.next_steps_source_uid
|
|
116
|
+
? { nextStepsSourceUid: row.next_steps_source_uid }
|
|
117
|
+
: {}),
|
|
91
118
|
decisions: JSON.parse(row.decisions_json),
|
|
92
119
|
tags: JSON.parse(row.tags_json),
|
|
93
120
|
occurredAt: row.occurred_at,
|
|
@@ -199,18 +226,11 @@ export function listProjects(db, options = {}) {
|
|
|
199
226
|
return false;
|
|
200
227
|
});
|
|
201
228
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
.transaction(() => {
|
|
206
|
-
const result = addValidatedStatus(db, input);
|
|
207
|
-
for (const target of completes)
|
|
208
|
-
changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
|
|
209
|
-
return result;
|
|
210
|
-
})
|
|
211
|
-
.immediate();
|
|
229
|
+
function linkCompletions(db, targets, byUid, source) {
|
|
230
|
+
for (const target of targets)
|
|
231
|
+
changeCompletion(db, target, byUid, "complete", undefined, source);
|
|
212
232
|
}
|
|
213
|
-
function
|
|
233
|
+
function existingIdentity(db, input, recordedAt, textRetry = false) {
|
|
214
234
|
if (input.uid) {
|
|
215
235
|
const existing = one(db, `${select} WHERE e.uid = ?`, input.uid);
|
|
216
236
|
if (existing) {
|
|
@@ -219,7 +239,10 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
219
239
|
id: existing.id,
|
|
220
240
|
recordedAt: existing.recordedAt,
|
|
221
241
|
});
|
|
222
|
-
|
|
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));
|
|
245
|
+
if (!sameWrite ||
|
|
223
246
|
(recordedAt !== undefined && recordedAt !== existing.recordedAt))
|
|
224
247
|
throw new ValidationError("conflicting entry uid");
|
|
225
248
|
return { entry: existing, inserted: false };
|
|
@@ -235,15 +258,94 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
235
258
|
return { entry: existing, inserted: false };
|
|
236
259
|
}
|
|
237
260
|
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
function textRefEquivalent(db, existing, input) {
|
|
264
|
+
// A text --next retry is the same write as the refs snapshot it produced.
|
|
265
|
+
if (existing.nextTodoUids === undefined || input.nextTodoUids !== undefined)
|
|
266
|
+
return false;
|
|
267
|
+
assert.ok(input.nextSteps);
|
|
268
|
+
const titles = input.nextSteps;
|
|
269
|
+
if (titles.length !== existing.nextTodoUids.length)
|
|
270
|
+
return false;
|
|
271
|
+
const candidate = toPortableStatusEntry({
|
|
272
|
+
...input,
|
|
273
|
+
nextSteps: [],
|
|
274
|
+
nextTodoUids: existing.nextTodoUids,
|
|
275
|
+
id: existing.id,
|
|
276
|
+
recordedAt: existing.recordedAt,
|
|
277
|
+
});
|
|
278
|
+
if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate))
|
|
279
|
+
return false;
|
|
280
|
+
return existing.nextTodoUids.every((uid, index) => {
|
|
281
|
+
const todo = todoByUid(db, uid);
|
|
282
|
+
return todo !== undefined && todo.title === titles[index];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
export function addStatus(db, value, defaultSource = "human", completes = []) {
|
|
286
|
+
const input = validateStatusEntry(value, defaultSource);
|
|
287
|
+
if (input.nextStepsSourceUid)
|
|
288
|
+
throw new ValidationError("new snapshots cannot set nextStepsSourceUid");
|
|
289
|
+
return db
|
|
290
|
+
.transaction(() => {
|
|
291
|
+
const replay = existingIdentity(db, input, undefined, true);
|
|
292
|
+
if (replay) {
|
|
293
|
+
linkCompletions(db, completes, replay.entry.uid, input.source);
|
|
294
|
+
return replay;
|
|
295
|
+
}
|
|
296
|
+
if (input.nextTodoUids !== undefined) {
|
|
297
|
+
assertSnapshotRefs(db, input.project, input.nextTodoUids);
|
|
298
|
+
const result = addValidatedStatus(db, { ...input, nextSteps: [] });
|
|
299
|
+
linkCompletions(db, completes, result.entry.uid, input.source);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
// Text --next sugar: one new Todo per title, stored only as refs.
|
|
303
|
+
const uid = input.uid ?? randomUUID();
|
|
304
|
+
const nextTodoUids = input.nextSteps.map(() => randomUUID());
|
|
305
|
+
const recordedAt = new Date().toISOString();
|
|
306
|
+
const result = addValidatedStatus(db, { ...input, uid, nextSteps: [], nextTodoUids }, recordedAt);
|
|
307
|
+
assert.equal(result.inserted, true);
|
|
308
|
+
input.nextSteps.forEach((title, index) => {
|
|
309
|
+
insertTodo(db, {
|
|
310
|
+
uid: nextTodoUids[index],
|
|
311
|
+
title,
|
|
312
|
+
project: result.entry.project,
|
|
313
|
+
entryUid: result.entry.uid,
|
|
314
|
+
stepIndex: index,
|
|
315
|
+
status: "open",
|
|
316
|
+
createdAt: recordedAt,
|
|
317
|
+
completedAt: null,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
linkCompletions(db, completes, result.entry.uid, input.source);
|
|
321
|
+
return result;
|
|
322
|
+
})
|
|
323
|
+
.immediate();
|
|
324
|
+
}
|
|
325
|
+
function addValidatedStatus(db, input, recordedAt) {
|
|
326
|
+
const existing = existingIdentity(db, input, recordedAt);
|
|
327
|
+
if (existing)
|
|
328
|
+
return existing;
|
|
329
|
+
if (input.nextStepsSourceUid) {
|
|
330
|
+
const origin = getEntry(db, input.nextStepsSourceUid);
|
|
331
|
+
if (!origin ||
|
|
332
|
+
origin.nextStepsSourceUid !== undefined ||
|
|
333
|
+
origin.project !== input.project ||
|
|
334
|
+
origin.occurredAt >= input.occurredAt ||
|
|
335
|
+
!isDeepStrictEqual(origin.nextSteps, input.nextSteps))
|
|
336
|
+
throw new ValidationError("invalid next step provenance");
|
|
337
|
+
}
|
|
238
338
|
const now = recordedAt ?? new Date().toISOString();
|
|
239
339
|
upsertProjectMetadata(db, { project: input.project });
|
|
240
340
|
const project = db
|
|
241
341
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
242
342
|
.get(input.project);
|
|
243
343
|
const result = db
|
|
244
|
-
.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)
|
|
245
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
246
|
-
.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()
|
|
344
|
+
.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)
|
|
345
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
346
|
+
.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
|
|
347
|
+
? null
|
|
348
|
+
: JSON.stringify(input.nextTodoUids));
|
|
247
349
|
return {
|
|
248
350
|
entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
|
|
249
351
|
inserted: true,
|
|
@@ -251,7 +353,14 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
251
353
|
}
|
|
252
354
|
export function importStatuses(db, values) {
|
|
253
355
|
const inputs = values.map((value) => validateStatusEntry(value, "import"));
|
|
254
|
-
return db.transaction(() =>
|
|
356
|
+
return db.transaction(() => {
|
|
357
|
+
const results = inputs.map((input) => addValidatedStatus(db, input));
|
|
358
|
+
// Entry-only imports declare no Todos, so every ref is an omitted dependency.
|
|
359
|
+
const payloadTodos = new Set();
|
|
360
|
+
for (const result of results)
|
|
361
|
+
assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
|
|
362
|
+
return results;
|
|
363
|
+
})();
|
|
255
364
|
}
|
|
256
365
|
export function current(db, project) {
|
|
257
366
|
const where = project ? " AND p.slug = ?" : "";
|
|
@@ -283,6 +392,7 @@ export function history(db, project, options = {}) {
|
|
|
283
392
|
return rows.map((row) => annotateEntry(db, entryFromRow(row)));
|
|
284
393
|
}
|
|
285
394
|
export function at(db, project, occurredAt) {
|
|
395
|
+
// Stored snapshot only. Refs may be present; live Todo titles are not historical.
|
|
286
396
|
return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
|
|
287
397
|
}
|
|
288
398
|
export function countEntries(db, project) {
|
|
@@ -331,6 +441,9 @@ function annotateEntry(db, entry) {
|
|
|
331
441
|
...entry,
|
|
332
442
|
effectiveStatus: followUp ? "completed" : entry.status,
|
|
333
443
|
isCurrent: newest?.id === entry.id,
|
|
444
|
+
...(entry.nextTodoUids !== undefined
|
|
445
|
+
? { nextTodos: entry.nextTodoUids.map((uid) => resolveNextTodo(db, uid)) }
|
|
446
|
+
: {}),
|
|
334
447
|
...(followUp && latest
|
|
335
448
|
? {
|
|
336
449
|
completedBy: {
|
|
@@ -423,7 +536,14 @@ export function exportLedger(db, project) {
|
|
|
423
536
|
const rows = db
|
|
424
537
|
.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`)
|
|
425
538
|
.all(...(project ? [project] : []));
|
|
426
|
-
|
|
539
|
+
const todos = project ? todosVisibleToProject(db, project) : listTodos(db);
|
|
540
|
+
const version = ledgerVersion(entries, todos);
|
|
541
|
+
return {
|
|
542
|
+
version,
|
|
543
|
+
entries,
|
|
544
|
+
completions: rows.map(completionFromRow),
|
|
545
|
+
...(version >= 3 ? { todos } : {}),
|
|
546
|
+
};
|
|
427
547
|
})();
|
|
428
548
|
}
|
|
429
549
|
function record(value, keys) {
|
|
@@ -468,32 +588,378 @@ function validateCompletionEvent(value) {
|
|
|
468
588
|
source: row.source,
|
|
469
589
|
};
|
|
470
590
|
}
|
|
591
|
+
function declaredTodoUids(values) {
|
|
592
|
+
const uids = new Set();
|
|
593
|
+
for (const value of values) {
|
|
594
|
+
if (!value || typeof value !== "object" || !("todos" in value))
|
|
595
|
+
continue;
|
|
596
|
+
const todos = value.todos;
|
|
597
|
+
if (!Array.isArray(todos))
|
|
598
|
+
continue;
|
|
599
|
+
for (const todo of todos) {
|
|
600
|
+
if (todo &&
|
|
601
|
+
typeof todo === "object" &&
|
|
602
|
+
"uid" in todo &&
|
|
603
|
+
typeof todo.uid === "string")
|
|
604
|
+
uids.add(todo.uid);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return uids;
|
|
608
|
+
}
|
|
471
609
|
export function importLedger(db, values) {
|
|
472
610
|
// Both legacy entries and versioned bundles are accepted atomically, including JSONL.
|
|
473
611
|
return db
|
|
474
612
|
.transaction(() => {
|
|
475
613
|
const results = [];
|
|
614
|
+
// Refs must name Todos declared in this payload, not only rows already stored.
|
|
615
|
+
const payloadTodos = declaredTodoUids(values);
|
|
476
616
|
for (const value of values) {
|
|
477
617
|
if (value && typeof value === "object" && "version" in value) {
|
|
478
|
-
const bundle = record(value, [
|
|
479
|
-
|
|
618
|
+
const bundle = record(value, [
|
|
619
|
+
"version",
|
|
620
|
+
"entries",
|
|
621
|
+
"completions",
|
|
622
|
+
"todos",
|
|
623
|
+
]);
|
|
624
|
+
const version = bundle.version;
|
|
625
|
+
if ((version !== 2 && version !== 3 && version !== 4) ||
|
|
626
|
+
((version === 3 || version === 4) &&
|
|
627
|
+
!Array.isArray(bundle.todos)) ||
|
|
628
|
+
(version === 2 && bundle.todos !== undefined) ||
|
|
480
629
|
!Array.isArray(bundle.entries) ||
|
|
481
630
|
!Array.isArray(bundle.completions))
|
|
482
631
|
throw new ValidationError("invalid ledger bundle");
|
|
632
|
+
const imported = [];
|
|
483
633
|
for (const item of bundle.entries) {
|
|
484
634
|
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
485
635
|
throw new ValidationError("entry must be an object");
|
|
486
636
|
const { recordedAt, ...input } = item;
|
|
487
637
|
validateUid(input.uid);
|
|
488
|
-
|
|
638
|
+
const result = addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt"));
|
|
639
|
+
imported.push(result.entry);
|
|
640
|
+
results.push(result);
|
|
489
641
|
}
|
|
490
642
|
for (const event of bundle.completions)
|
|
491
643
|
insertCompletion(db, validateCompletionEvent(event));
|
|
644
|
+
if (version === 3 || version === 4)
|
|
645
|
+
for (const todo of bundle.todos)
|
|
646
|
+
importTodo(db, todo);
|
|
647
|
+
for (const entry of imported)
|
|
648
|
+
assertSnapshotRefs(db, entry.project, entry.nextTodoUids, payloadTodos);
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
const result = addValidatedStatus(db, validateStatusEntry(value, "import"));
|
|
652
|
+
results.push(result);
|
|
653
|
+
assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
|
|
492
654
|
}
|
|
493
|
-
else
|
|
494
|
-
results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
|
|
495
655
|
}
|
|
496
656
|
return results;
|
|
497
657
|
})
|
|
498
658
|
.immediate();
|
|
499
659
|
}
|
|
660
|
+
const todoSelect = `SELECT uid, title, project, entry_uid AS entryUid,
|
|
661
|
+
step_index AS stepIndex, status, created_at AS createdAt, completed_at AS completedAt FROM todos`;
|
|
662
|
+
function ledgerVersion(entries, todos) {
|
|
663
|
+
if (entries.some((entry) => entry.nextTodoUids !== undefined))
|
|
664
|
+
return 4;
|
|
665
|
+
if (todos.length > 0 ||
|
|
666
|
+
entries.some((entry) => entry.nextStepsSourceUid !== undefined))
|
|
667
|
+
return 3;
|
|
668
|
+
return 2;
|
|
669
|
+
}
|
|
670
|
+
function todosVisibleToProject(db, project) {
|
|
671
|
+
return db
|
|
672
|
+
.prepare(`${todoSelect} WHERE project = ?
|
|
673
|
+
OR uid IN (
|
|
674
|
+
SELECT j.value FROM status_entries e
|
|
675
|
+
JOIN projects p ON p.id = e.project_id
|
|
676
|
+
JOIN json_each(e.next_todo_uids_json) j
|
|
677
|
+
WHERE p.slug = ? AND e.next_todo_uids_json IS NOT NULL
|
|
678
|
+
)
|
|
679
|
+
ORDER BY created_at, uid`)
|
|
680
|
+
.all(project, project);
|
|
681
|
+
}
|
|
682
|
+
function todoByUid(db, uid) {
|
|
683
|
+
return db.prepare(todoSelect + " WHERE uid = ?").get(uid);
|
|
684
|
+
}
|
|
685
|
+
function resolveNextTodo(db, uid) {
|
|
686
|
+
const todo = todoByUid(db, uid);
|
|
687
|
+
if (!todo)
|
|
688
|
+
return { uid, title: uid, status: "open", project: null };
|
|
689
|
+
return {
|
|
690
|
+
uid: todo.uid,
|
|
691
|
+
title: todo.title,
|
|
692
|
+
status: todo.status,
|
|
693
|
+
project: todo.project,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function assertSnapshotRefs(db, project, uids, payloadTodos) {
|
|
697
|
+
if (uids === undefined)
|
|
698
|
+
return;
|
|
699
|
+
for (const uid of uids) {
|
|
700
|
+
const todo = todoByUid(db, uid);
|
|
701
|
+
if (!todo)
|
|
702
|
+
throw new ValidationError("dangling next todo uid");
|
|
703
|
+
if (todo.project !== null && todo.project !== project)
|
|
704
|
+
throw new ValidationError("cross-project next todo ref");
|
|
705
|
+
if (payloadTodos && !payloadTodos.has(uid))
|
|
706
|
+
throw new ValidationError("dangling next todo uid");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function todoIsReferenced(db, uid) {
|
|
710
|
+
return (db
|
|
711
|
+
.prepare(`SELECT 1 AS found FROM status_entries, json_each(next_todo_uids_json)
|
|
712
|
+
WHERE next_todo_uids_json IS NOT NULL AND json_each.value = ? LIMIT 1`)
|
|
713
|
+
.get(uid) !== undefined);
|
|
714
|
+
}
|
|
715
|
+
function stepTodo(db, entryUid, index) {
|
|
716
|
+
return db
|
|
717
|
+
.prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
|
|
718
|
+
.get(entryUid, index);
|
|
719
|
+
}
|
|
720
|
+
function legacySource(db, entry) {
|
|
721
|
+
if (!entry.nextStepsSourceUid)
|
|
722
|
+
return entry;
|
|
723
|
+
const origin = getEntry(db, entry.nextStepsSourceUid);
|
|
724
|
+
assert.ok(origin);
|
|
725
|
+
assert.equal(origin.nextStepsSourceUid, undefined);
|
|
726
|
+
return origin;
|
|
727
|
+
}
|
|
728
|
+
function preservedNextWork(db, latest) {
|
|
729
|
+
if (latest.nextTodoUids !== undefined)
|
|
730
|
+
return { nextSteps: [], nextTodoUids: [...latest.nextTodoUids] };
|
|
731
|
+
const source = legacySource(db, latest);
|
|
732
|
+
const pending = source.nextSteps.some((_, index) => stepTodo(db, source.uid, index) === undefined);
|
|
733
|
+
if (pending)
|
|
734
|
+
return {
|
|
735
|
+
nextSteps: [...source.nextSteps],
|
|
736
|
+
nextStepsSourceUid: source.uid,
|
|
737
|
+
};
|
|
738
|
+
return {
|
|
739
|
+
nextSteps: [],
|
|
740
|
+
nextTodoUids: source.nextSteps.map((_, index) => {
|
|
741
|
+
const todo = stepTodo(db, source.uid, index);
|
|
742
|
+
assert.ok(todo);
|
|
743
|
+
return todo.uid;
|
|
744
|
+
}),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
export function listTodos(db, project, options = {}) {
|
|
748
|
+
const clauses = [];
|
|
749
|
+
const params = [];
|
|
750
|
+
if (project) {
|
|
751
|
+
clauses.push("project = ?");
|
|
752
|
+
params.push(project);
|
|
753
|
+
}
|
|
754
|
+
if (options.status !== undefined) {
|
|
755
|
+
if (!["open", "completed", "cancelled"].includes(options.status))
|
|
756
|
+
throw new ValidationError("invalid todo status");
|
|
757
|
+
clauses.push("status = ?");
|
|
758
|
+
params.push(options.status);
|
|
759
|
+
}
|
|
760
|
+
const rows = db
|
|
761
|
+
.prepare(todoSelect +
|
|
762
|
+
(clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "") +
|
|
763
|
+
" ORDER BY created_at, uid")
|
|
764
|
+
.all(...params);
|
|
765
|
+
const pins = options.status === "open" && project
|
|
766
|
+
? current(db, project)[0]?.nextTodoUids
|
|
767
|
+
: undefined;
|
|
768
|
+
if (pins?.length) {
|
|
769
|
+
const rank = new Map(pins.map((uid, index) => [uid, index]));
|
|
770
|
+
rows.sort((left, right) => {
|
|
771
|
+
const leftRank = rank.get(left.uid) ?? pins.length;
|
|
772
|
+
const rightRank = rank.get(right.uid) ?? pins.length;
|
|
773
|
+
return leftRank - rightRank;
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return rows;
|
|
777
|
+
}
|
|
778
|
+
function insertTodo(db, todo) {
|
|
779
|
+
db.prepare(`INSERT INTO todos(uid,title,project,entry_uid,step_index,status,created_at,completed_at)
|
|
780
|
+
VALUES (?,?,?,?,?,?,?,?)`).run(todo.uid, todo.title, todo.project, todo.entryUid, todo.stepIndex, todo.status, todo.createdAt, todo.completedAt);
|
|
781
|
+
return todo;
|
|
782
|
+
}
|
|
783
|
+
export function createTodo(db, value) {
|
|
784
|
+
const input = record(value, ["title", "project", "entryUid"]);
|
|
785
|
+
if (typeof input.title !== "string" ||
|
|
786
|
+
!input.title.trim() ||
|
|
787
|
+
input.title.trim().length > 2000)
|
|
788
|
+
throw new ValidationError("title must contain 1 to 2000 characters");
|
|
789
|
+
let project = null;
|
|
790
|
+
let entryUid = null;
|
|
791
|
+
if (input.project != null) {
|
|
792
|
+
project = validateProjectMetadataInput({ project: input.project }).project;
|
|
793
|
+
if (!db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(project))
|
|
794
|
+
throw new ValidationError("project not found");
|
|
795
|
+
}
|
|
796
|
+
if (input.entryUid != null) {
|
|
797
|
+
const entry = getEntry(db, validateUid(input.entryUid));
|
|
798
|
+
if (!entry)
|
|
799
|
+
throw new ValidationError("entry not found");
|
|
800
|
+
if (project && project !== entry.project)
|
|
801
|
+
throw new ValidationError("source project mismatch");
|
|
802
|
+
project = entry.project;
|
|
803
|
+
entryUid = entry.uid;
|
|
804
|
+
}
|
|
805
|
+
return insertTodo(db, {
|
|
806
|
+
uid: randomUUID(),
|
|
807
|
+
title: input.title.trim(),
|
|
808
|
+
project,
|
|
809
|
+
entryUid,
|
|
810
|
+
stepIndex: null,
|
|
811
|
+
status: "open",
|
|
812
|
+
createdAt: new Date().toISOString(),
|
|
813
|
+
completedAt: null,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
export function convertNextStep(db, ref, index) {
|
|
817
|
+
return db
|
|
818
|
+
.transaction(() => {
|
|
819
|
+
const entry = getEntry(db, ref);
|
|
820
|
+
if (!entry ||
|
|
821
|
+
!Number.isInteger(index) ||
|
|
822
|
+
index < 0 ||
|
|
823
|
+
index >= entry.nextSteps.length)
|
|
824
|
+
throw new ValidationError("next step not found");
|
|
825
|
+
if (entry.nextStepsSourceUid)
|
|
826
|
+
return convertNextStep(db, entry.nextStepsSourceUid, index);
|
|
827
|
+
const existing = db
|
|
828
|
+
.prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
|
|
829
|
+
.get(entry.uid, index);
|
|
830
|
+
if (existing)
|
|
831
|
+
return existing;
|
|
832
|
+
return insertTodo(db, {
|
|
833
|
+
uid: randomUUID(),
|
|
834
|
+
title: entry.nextSteps[index],
|
|
835
|
+
project: entry.project,
|
|
836
|
+
entryUid: entry.uid,
|
|
837
|
+
stepIndex: index,
|
|
838
|
+
status: "open",
|
|
839
|
+
createdAt: new Date().toISOString(),
|
|
840
|
+
completedAt: null,
|
|
841
|
+
});
|
|
842
|
+
})
|
|
843
|
+
.immediate();
|
|
844
|
+
}
|
|
845
|
+
export function updateTodo(db, uid, value) {
|
|
846
|
+
validateUid(uid);
|
|
847
|
+
const input = record(value, ["status", "project"]);
|
|
848
|
+
return db
|
|
849
|
+
.transaction(() => {
|
|
850
|
+
const todo = db.prepare(todoSelect + " WHERE uid = ?").get(uid);
|
|
851
|
+
if (!todo)
|
|
852
|
+
throw new ValidationError("todo not found");
|
|
853
|
+
if (input.status !== undefined) {
|
|
854
|
+
if (!["open", "completed", "cancelled"].includes(input.status))
|
|
855
|
+
throw new ValidationError("invalid todo status");
|
|
856
|
+
if (todo.status !== input.status) {
|
|
857
|
+
todo.status = input.status;
|
|
858
|
+
todo.completedAt =
|
|
859
|
+
todo.status === "completed" ? new Date().toISOString() : null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (input.project !== undefined) {
|
|
863
|
+
const nextProject = input.project === null
|
|
864
|
+
? null
|
|
865
|
+
: validateProjectMetadataInput({ project: input.project }).project;
|
|
866
|
+
if (nextProject !== todo.project) {
|
|
867
|
+
if (todo.entryUid || todoIsReferenced(db, todo.uid))
|
|
868
|
+
throw new ValidationError(todo.entryUid
|
|
869
|
+
? "source-linked todo project cannot change"
|
|
870
|
+
: "referenced todo project cannot change");
|
|
871
|
+
if (nextProject &&
|
|
872
|
+
!db
|
|
873
|
+
.prepare("SELECT 1 FROM projects WHERE slug = ?")
|
|
874
|
+
.get(nextProject))
|
|
875
|
+
throw new ValidationError("project not found");
|
|
876
|
+
todo.project = nextProject;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
db.prepare("UPDATE todos SET status=?, completed_at=?, project=? WHERE uid=?").run(todo.status, todo.completedAt, todo.project, uid);
|
|
880
|
+
return todo;
|
|
881
|
+
})
|
|
882
|
+
.immediate();
|
|
883
|
+
}
|
|
884
|
+
function importTodo(db, value) {
|
|
885
|
+
const input = record(value, [
|
|
886
|
+
"uid",
|
|
887
|
+
"title",
|
|
888
|
+
"project",
|
|
889
|
+
"entryUid",
|
|
890
|
+
"stepIndex",
|
|
891
|
+
"status",
|
|
892
|
+
"createdAt",
|
|
893
|
+
"completedAt",
|
|
894
|
+
]);
|
|
895
|
+
validateUid(input.uid);
|
|
896
|
+
if (typeof input.title !== "string" ||
|
|
897
|
+
!input.title.trim() ||
|
|
898
|
+
input.title.length > 2000)
|
|
899
|
+
throw new ValidationError("invalid todo title");
|
|
900
|
+
if (!["open", "completed", "cancelled"].includes(input.status))
|
|
901
|
+
throw new ValidationError("invalid todo status");
|
|
902
|
+
parseTime(input.createdAt, "createdAt");
|
|
903
|
+
if (input.status === "completed")
|
|
904
|
+
parseTime(input.completedAt, "completedAt");
|
|
905
|
+
else if (input.completedAt !== null)
|
|
906
|
+
throw new ValidationError("invalid completion timestamp");
|
|
907
|
+
if (input.project !== null) {
|
|
908
|
+
validateProjectMetadataInput({ project: input.project });
|
|
909
|
+
upsertProjectMetadata(db, { project: input.project });
|
|
910
|
+
}
|
|
911
|
+
if (input.entryUid !== null) {
|
|
912
|
+
const entry = getEntry(db, validateUid(input.entryUid));
|
|
913
|
+
if (!entry || entry.project !== input.project)
|
|
914
|
+
throw new ValidationError("invalid todo source");
|
|
915
|
+
if (input.stepIndex !== null) {
|
|
916
|
+
if (!Number.isInteger(input.stepIndex) || Number(input.stepIndex) < 0)
|
|
917
|
+
throw new ValidationError("invalid next step index");
|
|
918
|
+
const index = Number(input.stepIndex);
|
|
919
|
+
if (entry.nextTodoUids !== undefined) {
|
|
920
|
+
if (entry.nextTodoUids[index] !== input.uid)
|
|
921
|
+
throw new ValidationError("invalid next step index");
|
|
922
|
+
}
|
|
923
|
+
else if (entry.nextSteps[index] !== input.title)
|
|
924
|
+
throw new ValidationError("invalid next step index");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
else if (input.stepIndex !== null)
|
|
928
|
+
throw new ValidationError("next step requires a source");
|
|
929
|
+
const todo = input;
|
|
930
|
+
const existing = db.prepare(todoSelect + " WHERE uid = ?").get(todo.uid);
|
|
931
|
+
if (existing) {
|
|
932
|
+
if (!isDeepStrictEqual(existing, todo))
|
|
933
|
+
throw new ValidationError("conflicting todo uid");
|
|
934
|
+
}
|
|
935
|
+
else
|
|
936
|
+
insertTodo(db, todo);
|
|
937
|
+
}
|
|
938
|
+
export function completeActivity(db, ref) {
|
|
939
|
+
return db
|
|
940
|
+
.transaction(() => {
|
|
941
|
+
const entry = getEntry(db, ref);
|
|
942
|
+
if (!entry)
|
|
943
|
+
throw new ValidationError("entry not found");
|
|
944
|
+
if (entry.effectiveStatus === "completed")
|
|
945
|
+
return entry;
|
|
946
|
+
const latest = current(db, entry.project)[0];
|
|
947
|
+
const occurredAt = new Date(Math.max(Date.now(), Date.parse(latest.occurredAt) + 1)).toISOString();
|
|
948
|
+
// Copy refs, or keep the full legacy string list until every index is converted.
|
|
949
|
+
const preserved = preservedNextWork(db, latest);
|
|
950
|
+
const followUp = addValidatedStatus(db, validateStatusEntry({
|
|
951
|
+
project: entry.project,
|
|
952
|
+
status: latest.status,
|
|
953
|
+
summary: `Completed activity #${entry.id}`,
|
|
954
|
+
progress: latest.progress,
|
|
955
|
+
blockers: latest.blockers,
|
|
956
|
+
decisions: latest.decisions,
|
|
957
|
+
tags: latest.tags,
|
|
958
|
+
occurredAt,
|
|
959
|
+
...preserved,
|
|
960
|
+
}));
|
|
961
|
+
changeCompletion(db, entry.uid, followUp.entry.uid);
|
|
962
|
+
return getEntry(db, entry.uid);
|
|
963
|
+
})
|
|
964
|
+
.immediate();
|
|
965
|
+
}
|
package/dist/domain.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type ProjectMetadataInput = {
|
|
|
18
18
|
description?: string | null;
|
|
19
19
|
notes?: string | null;
|
|
20
20
|
};
|
|
21
|
+
export type NextTodoRef = {
|
|
22
|
+
uid: string;
|
|
23
|
+
title: string;
|
|
24
|
+
status: "open" | "completed" | "cancelled";
|
|
25
|
+
project: string | null;
|
|
26
|
+
};
|
|
21
27
|
export type StatusEntryInput = {
|
|
22
28
|
uid?: string;
|
|
23
29
|
project: string;
|
|
@@ -26,13 +32,19 @@ export type StatusEntryInput = {
|
|
|
26
32
|
progress?: string;
|
|
27
33
|
blockers?: string[];
|
|
28
34
|
nextSteps?: string[];
|
|
35
|
+
nextTodoUids?: string[];
|
|
36
|
+
nextStepsSourceUid?: string;
|
|
29
37
|
decisions?: string[];
|
|
30
38
|
tags?: string[];
|
|
31
39
|
occurredAt?: string;
|
|
32
40
|
source?: Source;
|
|
33
41
|
externalRef?: string;
|
|
34
42
|
};
|
|
35
|
-
export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef">> & {
|
|
43
|
+
export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef" | "nextStepsSourceUid" | "nextTodoUids">> & {
|
|
44
|
+
nextStepsSourceUid?: string;
|
|
45
|
+
nextTodoUids?: string[];
|
|
46
|
+
/** Live Todo rows for display. Not a historical as-of view. */
|
|
47
|
+
nextTodos?: NextTodoRef[];
|
|
36
48
|
id: number;
|
|
37
49
|
progress?: string;
|
|
38
50
|
externalRef?: string;
|
|
@@ -47,12 +59,12 @@ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "external
|
|
|
47
59
|
recordedAt: string;
|
|
48
60
|
};
|
|
49
61
|
};
|
|
50
|
-
export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt" | "effectiveStatus" | "isCurrent" | "completedBy">;
|
|
62
|
+
export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt" | "effectiveStatus" | "isCurrent" | "completedBy" | "nextTodos">;
|
|
51
63
|
export declare class ValidationError extends Error {
|
|
52
64
|
constructor(message: string);
|
|
53
65
|
}
|
|
54
66
|
export declare function parseTime(value: unknown, field: string): string;
|
|
55
67
|
export declare function validateProjectMetadataInput(value: unknown): ProjectMetadataInput;
|
|
56
|
-
export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
|
|
68
|
+
export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source" | "nextSteps" | "blockers" | "decisions" | "tags">>;
|
|
57
69
|
export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
|
|
58
70
|
export declare function validateUid(value: unknown): string;
|