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/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
- export function addStatus(db, value, defaultSource = "human", completes = []) {
219
- const input = validateStatusEntry(value, defaultSource);
220
- return db
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 addValidatedStatus(db, input, recordedAt) {
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
- 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, 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(() => inputs.map((input) => addValidatedStatus(db, input)))();
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 = listTodos(db, project);
729
+ const todos = project ? todosVisibleToProject(db, project) : listTodos(db);
730
+ const version = ledgerVersion(entries, todos);
454
731
  return {
455
- version: todos.length || entries.some((entry) => entry.nextStepsSourceUid)
456
- ? 3
457
- : 2,
732
+ version,
458
733
  entries,
459
734
  completions: rows.map(completionFromRow),
460
- ...(todos.length || entries.some((entry) => entry.nextStepsSourceUid)
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
- if ((bundle.version !== 2 && bundle.version !== 3) ||
522
- (bundle.version === 3 && !Array.isArray(bundle.todos)) ||
523
- (bundle.version === 2 && bundle.todos !== undefined) ||
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
- results.push(addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt")));
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 (bundle.version === 3)
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
- export function listTodos(db, project) {
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
- (project ? " WHERE project = ?" : "") +
952
+ (clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "") +
553
953
  " ORDER BY created_at, uid")
554
- .all(...(project ? [project] : []));
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
- if (todo.entryUid)
642
- throw new ValidationError("source-linked todo project cannot change");
643
- todo.project =
644
- input.project === null
645
- ? null
646
- : validateProjectMetadataInput({ project: input.project }).project;
647
- if (todo.project &&
648
- !db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(todo.project))
649
- throw new ValidationError("project not found");
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
- Number(input.stepIndex) < 0 ||
690
- entry.nextSteps[Number(input.stepIndex)] !== input.title))
691
- throw new ValidationError("invalid next step index");
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
- // Preserve current context without generating another set of next-step Todos.
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);