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/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
- export function addStatus(db, value, defaultSource = "human", completes = []) {
203
- const input = validateStatusEntry(value, defaultSource);
204
- return db
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 addValidatedStatus(db, input, recordedAt) {
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
- if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
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(() => inputs.map((input) => addValidatedStatus(db, input)))();
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
- return { version: 2, entries, completions: rows.map(completionFromRow) };
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, ["version", "entries", "completions"]);
479
- if (bundle.version !== 2 ||
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
- results.push(addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt")));
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;