trekoon 0.3.3 → 0.3.5

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.
@@ -39,6 +39,7 @@ import {
39
39
 
40
40
  const DEFAULT_STATUS = "todo";
41
41
  const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
42
+ const SQLITE_MAX_VARIABLES = 999;
42
43
 
43
44
  interface EpicRow {
44
45
  id: string;
@@ -391,20 +392,69 @@ export class TrackerDomain {
391
392
  status: normalizeStatus(spec.status),
392
393
  }));
393
394
 
394
- const tasks: TaskRecord[] = [];
395
- for (const spec of validatedSpecs) {
396
- const now: number = Date.now();
397
- const id: string = randomUUID();
395
+ if (validatedSpecs.length === 0) {
396
+ return {
397
+ tasks: [],
398
+ result: {
399
+ mappings: [],
400
+ },
401
+ };
402
+ }
403
+
404
+ if (!this.#db.inTransaction) {
405
+ throw new DomainError({
406
+ code: "invalid_state",
407
+ message: "createTaskBatch must be called inside a writeTransaction",
408
+ details: { entity: "task" },
409
+ });
410
+ }
411
+
412
+ const TASK_COLS_PER_ROW = 7; // id, epic_id, title, description, status, created_at, updated_at (version is literal 1)
413
+ const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / TASK_COLS_PER_ROW);
414
+ const batchTimestamp: number = Date.now();
398
415
 
416
+ const prepared: Array<{ id: string; now: number; spec: ValidatedTaskBatchSpec }> = validatedSpecs.map((spec) => ({
417
+ id: randomUUID(),
418
+ now: batchTimestamp,
419
+ spec,
420
+ }));
421
+
422
+ for (let offset = 0; offset < prepared.length; offset += WRITE_CHUNK_SIZE) {
423
+ const chunk = prepared.slice(offset, offset + WRITE_CHUNK_SIZE);
424
+ const placeholders: string = chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, 1)").join(", ");
425
+ const params: Array<string | number> = [];
426
+ for (const item of chunk) {
427
+ params.push(item.id, epicId, item.spec.title, item.spec.description, item.spec.status, item.now, item.now);
428
+ }
399
429
  this.#db
400
430
  .query(
401
- "INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
431
+ `INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES ${placeholders};`,
402
432
  )
403
- .run(id, epicId, spec.title, spec.description, spec.status, now, now);
433
+ .run(...params);
434
+ }
404
435
 
405
- tasks.push(this.getTaskOrThrow(id));
436
+ const allIds: string[] = prepared.map((p) => p.id);
437
+ const fetchedRows: TaskRow[] = [];
438
+ for (let offset = 0; offset < allIds.length; offset += SQLITE_MAX_VARIABLES) {
439
+ const chunkIds = allIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
440
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
441
+ const chunkRows = this.#db
442
+ .query(
443
+ `SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id IN (${inPlaceholders});`,
444
+ )
445
+ .all(...chunkIds) as TaskRow[];
446
+ fetchedRows.push(...chunkRows);
406
447
  }
407
448
 
449
+ const rowById = new Map<string, TaskRow>(fetchedRows.map((row) => [row.id, row]));
450
+ const tasks: TaskRecord[] = allIds.map((id) => {
451
+ const row = rowById.get(id);
452
+ if (!row) {
453
+ throw new DomainError({ code: "not_found", message: `task not found: ${id}`, details: { entity: "task", id } });
454
+ }
455
+ return mapTask(row);
456
+ });
457
+
408
458
  return {
409
459
  tasks,
410
460
  result: {
@@ -504,9 +554,13 @@ export class TrackerDomain {
504
554
  const defaultTaskId: string = assertNonEmpty("taskId", input.taskId);
505
555
  this.getTaskOrThrow(defaultTaskId);
506
556
 
557
+ const validatedTaskIds = new Set<string>([defaultTaskId]);
507
558
  const validatedSpecs: ValidatedSubtaskBatchSpec[] = input.specs.map((spec) => {
508
559
  const taskId = spec.parent.kind === "id" ? assertNonEmpty("taskId", spec.parent.id) : defaultTaskId;
509
- this.getTaskOrThrow(taskId);
560
+ if (!validatedTaskIds.has(taskId)) {
561
+ this.getTaskOrThrow(taskId);
562
+ validatedTaskIds.add(taskId);
563
+ }
510
564
 
511
565
  return {
512
566
  tempKey: assertNonEmpty("tempKey", spec.tempKey),
@@ -517,20 +571,73 @@ export class TrackerDomain {
517
571
  };
518
572
  });
519
573
 
520
- const subtasks: SubtaskRecord[] = [];
521
- for (const spec of validatedSpecs) {
522
- const now: number = Date.now();
523
- const id: string = randomUUID();
574
+ if (validatedSpecs.length === 0) {
575
+ return {
576
+ subtasks: [],
577
+ result: {
578
+ mappings: [],
579
+ },
580
+ };
581
+ }
582
+
583
+ if (!this.#db.inTransaction) {
584
+ throw new DomainError({
585
+ code: "invalid_state",
586
+ message: "createSubtaskBatch must be called inside a writeTransaction",
587
+ details: { entity: "subtask" },
588
+ });
589
+ }
590
+
591
+ const SUBTASK_COLS_PER_ROW = 7; // id, task_id, title, description, status, created_at, updated_at (version is literal 1)
592
+ const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / SUBTASK_COLS_PER_ROW);
593
+ const batchTimestamp: number = Date.now();
594
+
595
+ const prepared: Array<{ id: string; now: number; spec: ValidatedSubtaskBatchSpec }> = validatedSpecs.map((spec) => ({
596
+ id: randomUUID(),
597
+ now: batchTimestamp,
598
+ spec,
599
+ }));
524
600
 
601
+ for (let offset = 0; offset < prepared.length; offset += WRITE_CHUNK_SIZE) {
602
+ const chunk = prepared.slice(offset, offset + WRITE_CHUNK_SIZE);
603
+ const placeholders: string = chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, 1)").join(", ");
604
+ const params: Array<string | number> = [];
605
+ for (const item of chunk) {
606
+ params.push(item.id, item.spec.taskId, item.spec.title, item.spec.description, item.spec.status, item.now, item.now);
607
+ }
525
608
  this.#db
526
609
  .query(
527
- "INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
610
+ `INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES ${placeholders};`,
528
611
  )
529
- .run(id, spec.taskId, spec.title, spec.description, spec.status, now, now);
612
+ .run(...params);
613
+ }
530
614
 
531
- subtasks.push(this.getSubtaskOrThrow(id));
615
+ const allIds: string[] = prepared.map((p) => p.id);
616
+ const fetchedRows: SubtaskRow[] = [];
617
+ for (let offset = 0; offset < allIds.length; offset += SQLITE_MAX_VARIABLES) {
618
+ const chunkIds = allIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
619
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
620
+ const chunkRows = this.#db
621
+ .query(
622
+ `SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id IN (${inPlaceholders});`,
623
+ )
624
+ .all(...chunkIds) as SubtaskRow[];
625
+ fetchedRows.push(...chunkRows);
532
626
  }
533
627
 
628
+ const rowById = new Map<string, SubtaskRow>(fetchedRows.map((row) => [row.id, row]));
629
+ const subtasks: SubtaskRecord[] = allIds.map((id) => {
630
+ const row = rowById.get(id);
631
+ if (!row) {
632
+ throw new DomainError({
633
+ code: "not_found",
634
+ message: `subtask not found: ${id}`,
635
+ details: { entity: "subtask", id },
636
+ });
637
+ }
638
+ return mapSubtask(row);
639
+ });
640
+
534
641
  return {
535
642
  subtasks,
536
643
  result: {
@@ -930,24 +1037,62 @@ export class TrackerDomain {
930
1037
  });
931
1038
  }
932
1039
 
1040
+ // Batch-fetch all existing edges with a single prepared statement instead
1041
+ // of one #getDependencyByEdge call per spec (eliminates N+1 pattern).
1042
+ const edgeLookupStmt = this.#db.query(
1043
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
1044
+ );
1045
+ const existingEdgeMap = new Map<string, DependencyRecord>();
1046
+ for (const spec of resolvedSpecs) {
1047
+ const edgeKey = `${spec.sourceId}\0${spec.dependsOnId}`;
1048
+ if (!existingEdgeMap.has(edgeKey)) {
1049
+ const row = edgeLookupStmt.get(spec.sourceId, spec.dependsOnId) as DependencyRow | null;
1050
+ if (row) {
1051
+ existingEdgeMap.set(edgeKey, mapDependency(row));
1052
+ }
1053
+ }
1054
+ }
1055
+
933
1056
  const dependencies: DependencyRecord[] = [];
1057
+ const newIds: string[] = [];
1058
+ const batchNow: number = Date.now();
1059
+
934
1060
  for (const spec of resolvedSpecs) {
935
- const existing = this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId);
1061
+ const edgeKey = `${spec.sourceId}\0${spec.dependsOnId}`;
1062
+ const existing = existingEdgeMap.get(edgeKey);
936
1063
  if (existing) {
937
1064
  dependencies.push(existing);
938
1065
  continue;
939
1066
  }
940
1067
 
941
1068
  const id: string = randomUUID();
942
- const now: number = Date.now();
943
1069
 
944
1070
  this.#db
945
1071
  .query(
946
1072
  "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
947
1073
  )
948
- .run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, now, now);
1074
+ .run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, batchNow, batchNow);
949
1075
 
950
- dependencies.push(this.getDependencyOrThrow(id));
1076
+ newIds.push(id);
1077
+ }
1078
+
1079
+ // Batch-fetch all newly inserted dependencies instead of one getDependencyOrThrow per row.
1080
+ for (let offset = 0; offset < newIds.length; offset += SQLITE_MAX_VARIABLES) {
1081
+ const chunkIds = newIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1082
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1083
+ const rows = this.#db
1084
+ .query(
1085
+ `SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE id IN (${inPlaceholders});`,
1086
+ )
1087
+ .all(...chunkIds) as DependencyRow[];
1088
+ const rowMap = new Map(rows.map((row) => [row.id, row]));
1089
+ for (const id of chunkIds) {
1090
+ const row = rowMap.get(id);
1091
+ if (!row) {
1092
+ throw new DomainError({ code: "not_found", message: `dependency not found: ${id}`, details: { entity: "dependency", id } });
1093
+ }
1094
+ dependencies.push(mapDependency(row));
1095
+ }
951
1096
  }
952
1097
 
953
1098
  return {
@@ -1602,36 +1747,64 @@ export class TrackerDomain {
1602
1747
  return [];
1603
1748
  }
1604
1749
 
1605
- const blockers: StatusCascadeBlocker[] = [];
1750
+ // Collect all dependency-eligible change IDs upfront.
1751
+ const eligibleIds: string[] = [];
1606
1752
  for (const change of changes) {
1607
- if (change.kind !== "task" && change.kind !== "subtask") {
1608
- continue;
1753
+ if (change.kind === "task" || change.kind === "subtask") {
1754
+ eligibleIds.push(change.id);
1609
1755
  }
1756
+ }
1757
+
1758
+ if (eligibleIds.length === 0) {
1759
+ return [];
1760
+ }
1761
+
1762
+ // Batch-fetch all dependency rows with their target statuses using a
1763
+ // chunked IN query with JOINs. This replaces the previous per-ID
1764
+ // prepared statement approach, reducing N queries to ceil(N/999).
1765
+ type DepStatusRow = {
1766
+ source_id: string;
1767
+ source_kind: "task" | "subtask";
1768
+ depends_on_id: string;
1769
+ depends_on_kind: "task" | "subtask";
1770
+ dep_status: string | null;
1771
+ };
1610
1772
 
1611
- for (const dependency of this.listDependencies(change.id)) {
1612
- const dependencyNode =
1613
- dependency.dependsOnKind === "task"
1614
- ? this.getTask(dependency.dependsOnId)
1615
- : this.getSubtask(dependency.dependsOnId);
1773
+ const blockers: StatusCascadeBlocker[] = [];
1616
1774
 
1775
+ for (let offset = 0; offset < eligibleIds.length; offset += SQLITE_MAX_VARIABLES) {
1776
+ const chunkIds = eligibleIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1777
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1778
+ const rows = this.#db
1779
+ .query(
1780
+ `SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
1781
+ COALESCE(t.status, s.status) AS dep_status
1782
+ FROM dependencies d
1783
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1784
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1785
+ WHERE d.source_id IN (${inPlaceholders})
1786
+ ORDER BY d.created_at ASC, d.id ASC;`,
1787
+ )
1788
+ .all(...chunkIds) as DepStatusRow[];
1789
+
1790
+ for (const row of rows) {
1617
1791
  // Skip orphaned dependency rows where the referenced node no longer exists.
1618
- if (!dependencyNode) {
1792
+ if (row.dep_status === null) {
1619
1793
  continue;
1620
1794
  }
1621
1795
 
1622
- const dependencyStatus = dependencyNode.status;
1623
- const inScope = scopeIdSet.has(dependency.dependsOnId);
1624
- const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
1625
- if (dependencyStatus === "done" || willCascade) {
1796
+ const inScope = scopeIdSet.has(row.depends_on_id);
1797
+ const willCascade = targetStatus === "done" && changedIdSet.has(row.depends_on_id);
1798
+ if (row.dep_status === "done" || willCascade) {
1626
1799
  continue;
1627
1800
  }
1628
1801
 
1629
1802
  blockers.push({
1630
- sourceId: dependency.sourceId,
1631
- sourceKind: dependency.sourceKind,
1632
- dependsOnId: dependency.dependsOnId,
1633
- dependsOnKind: dependency.dependsOnKind,
1634
- dependsOnStatus: dependencyStatus,
1803
+ sourceId: row.source_id,
1804
+ sourceKind: row.source_kind,
1805
+ dependsOnId: row.depends_on_id,
1806
+ dependsOnKind: row.depends_on_kind,
1807
+ dependsOnStatus: row.dep_status,
1635
1808
  inScope,
1636
1809
  willCascade,
1637
1810
  });
@@ -3,6 +3,7 @@ import { type Database } from "bun:sqlite";
3
3
  import { writeTransaction } from "./database";
4
4
 
5
5
  export const DEFAULT_EVENT_RETENTION_DAYS = 90;
6
+ export const DEFAULT_CONFLICT_RETENTION_DAYS = 30;
6
7
  const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
7
8
 
8
9
  export interface EventPruneOptions {
@@ -92,6 +93,20 @@ function countStaleCursors(db: Database): number {
92
93
  return row?.count ?? 0;
93
94
  }
94
95
 
96
+ export interface ConflictPruneOptions {
97
+ readonly retentionDays?: number;
98
+ readonly dryRun?: boolean;
99
+ readonly now?: number;
100
+ }
101
+
102
+ export interface ConflictPruneSummary {
103
+ readonly retentionDays: number;
104
+ readonly cutoffTimestamp: number;
105
+ readonly dryRun: boolean;
106
+ readonly candidateCount: number;
107
+ readonly deletedCount: number;
108
+ }
109
+
95
110
  export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
96
111
  const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
97
112
  const dryRun: boolean = options.dryRun ?? false;
@@ -185,3 +200,60 @@ export function pruneEvents(db: Database, options: EventPruneOptions = {}): Even
185
200
  };
186
201
  });
187
202
  }
203
+
204
+ export function pruneResolvedConflicts(db: Database, options: ConflictPruneOptions = {}): ConflictPruneSummary {
205
+ const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_CONFLICT_RETENTION_DAYS);
206
+ const dryRun: boolean = options.dryRun ?? false;
207
+ const now: number = options.now ?? Date.now();
208
+ const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
209
+
210
+ if (dryRun) {
211
+ const candidateRow = db
212
+ .query(
213
+ "SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
214
+ )
215
+ .get(cutoffTimestamp) as { count: number } | null;
216
+
217
+ return {
218
+ retentionDays,
219
+ cutoffTimestamp,
220
+ dryRun,
221
+ candidateCount: candidateRow?.count ?? 0,
222
+ deletedCount: 0,
223
+ };
224
+ }
225
+
226
+ return writeTransaction(db, (): ConflictPruneSummary => {
227
+ const candidateRow = db
228
+ .query(
229
+ "SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
230
+ )
231
+ .get(cutoffTimestamp) as { count: number } | null;
232
+
233
+ const candidateCount: number = candidateRow?.count ?? 0;
234
+
235
+ if (candidateCount === 0) {
236
+ return {
237
+ retentionDays,
238
+ cutoffTimestamp,
239
+ dryRun,
240
+ candidateCount,
241
+ deletedCount: 0,
242
+ };
243
+ }
244
+
245
+ const deleted = db
246
+ .query(
247
+ "DELETE FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
248
+ )
249
+ .run(cutoffTimestamp);
250
+
251
+ return {
252
+ retentionDays,
253
+ cutoffTimestamp,
254
+ dryRun,
255
+ candidateCount,
256
+ deletedCount: deleted.changes,
257
+ };
258
+ });
259
+ }
@@ -58,6 +58,20 @@ const EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
58
58
  "DROP TABLE IF EXISTS event_archive;",
59
59
  ];
60
60
 
61
+ const LOOKUP_INDEX_MIGRATION_UP_STATEMENTS: readonly string[] = [
62
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_kind ON dependencies(depends_on_id, depends_on_kind);",
63
+ "CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);",
64
+ "CREATE INDEX IF NOT EXISTS idx_subtasks_owner ON subtasks(owner);",
65
+ "CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_updated_at ON sync_conflicts(resolution, updated_at);",
66
+ ];
67
+
68
+ const LOOKUP_INDEX_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
69
+ "DROP INDEX IF EXISTS idx_conflicts_resolution_updated_at;",
70
+ "DROP INDEX IF EXISTS idx_subtasks_owner;",
71
+ "DROP INDEX IF EXISTS idx_tasks_owner;",
72
+ "DROP INDEX IF EXISTS idx_dependencies_depends_on_kind;",
73
+ ];
74
+
61
75
  function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
62
76
  const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
63
77
  return columns.some((column) => column.name === columnName);
@@ -237,6 +251,20 @@ const MIGRATIONS: readonly Migration[] = [
237
251
  );
238
252
  },
239
253
  },
254
+ {
255
+ version: 7,
256
+ name: "0007_add_lookup_indexes",
257
+ up(db: Database): void {
258
+ for (const statement of LOOKUP_INDEX_MIGRATION_UP_STATEMENTS) {
259
+ db.exec(statement);
260
+ }
261
+ },
262
+ down(db: Database): void {
263
+ for (const statement of LOOKUP_INDEX_MIGRATION_DOWN_STATEMENTS) {
264
+ db.exec(statement);
265
+ }
266
+ },
267
+ },
240
268
  ];
241
269
 
242
270
  function migrationTableExists(db: Database): boolean {
@@ -11,7 +11,7 @@ interface EventRecordInput {
11
11
  readonly fields: Record<string, unknown>;
12
12
  }
13
13
 
14
- function nextEventTimestamp(db: Database): number {
14
+ export function nextEventTimestamp(db: Database): number {
15
15
  const now: number = Date.now();
16
16
  const latestEvent = db
17
17
  .query(
@@ -31,12 +31,16 @@ function nextEventTimestamp(db: Database): number {
31
31
  return Math.max(now, latestEvent.created_at + 1);
32
32
  }
33
33
 
34
- export function appendEventWithGitContext(db: Database, cwd: string, input: EventRecordInput): string {
34
+ /** Append a single event to the events table with git context. Returns void (event ID is not exposed). */
35
+ export function appendEventWithGitContext(
36
+ db: Database,
37
+ cwd: string,
38
+ input: EventRecordInput,
39
+ ): void {
35
40
  const git = resolveGitContext(cwd);
36
41
  persistGitContext(db, git);
37
42
 
38
43
  const now: number = nextEventTimestamp(db);
39
- const eventId: string = randomUUID();
40
44
 
41
45
  db.query(
42
46
  `
@@ -54,7 +58,7 @@ export function appendEventWithGitContext(db: Database, cwd: string, input: Even
54
58
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
55
59
  `,
56
60
  ).run(
57
- eventId,
61
+ randomUUID(),
58
62
  input.entityKind,
59
63
  input.entityId,
60
64
  input.operation,
@@ -64,6 +68,4 @@ export function appendEventWithGitContext(db: Database, cwd: string, input: Even
64
68
  now,
65
69
  now,
66
70
  );
67
-
68
- return eventId;
69
71
  }