trekoon 0.3.6 → 0.3.8

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.
@@ -123,6 +123,19 @@ interface ResolvedCompactEntity {
123
123
  readonly kind: "task" | "subtask";
124
124
  }
125
125
 
126
+ interface TaskDeletionPlan {
127
+ readonly subtaskIds: readonly string[];
128
+ readonly touchingDependencies: readonly DependencyRecord[];
129
+ }
130
+
131
+ function chunkValues<T>(values: readonly T[], chunkSize: number): T[][] {
132
+ const chunks: T[][] = [];
133
+ for (let offset = 0; offset < values.length; offset += chunkSize) {
134
+ chunks.push([...values.slice(offset, offset + chunkSize)]);
135
+ }
136
+ return chunks;
137
+ }
138
+
126
139
  function assertNonEmpty(field: string, value: string | undefined | null): string {
127
140
  const normalized: string = (value ?? "").trim();
128
141
  if (!normalized) {
@@ -152,6 +165,20 @@ function normalizeSubtaskDescription(value: string | undefined): string {
152
165
  return value.trim();
153
166
  }
154
167
 
168
+ function normalizeOwner(value: string | null | undefined): string | null | undefined {
169
+ if (value === undefined) {
170
+ return undefined;
171
+ }
172
+
173
+ if (value === null) {
174
+ return null;
175
+ }
176
+
177
+ const normalized = value.trim()
178
+ ;
179
+ return normalized.length > 0 ? normalized : null;
180
+ }
181
+
155
182
  function isValidStatus(status: string): status is ValidStatus {
156
183
  return (VALID_STATUSES as readonly string[]).includes(status);
157
184
  }
@@ -513,7 +540,7 @@ export class TrackerDomain {
513
540
  const nextDescription: string =
514
541
  input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
515
542
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
516
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
543
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
517
544
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
518
545
  const now: number = Date.now();
519
546
 
@@ -525,8 +552,24 @@ export class TrackerDomain {
525
552
  }
526
553
 
527
554
  deleteTask(id: string): void {
528
- this.getTaskOrThrow(id);
529
- this.#db.query("DELETE FROM tasks WHERE id = ?;").run(id);
555
+ const normalizedTaskId: string = assertNonEmpty("id", id);
556
+ this.getTaskOrThrow(normalizedTaskId);
557
+
558
+ const plan = this.planTaskDeletion(normalizedTaskId);
559
+ for (const dependencyIdChunk of chunkValues(
560
+ plan.touchingDependencies.map((dependency) => dependency.id),
561
+ SQLITE_MAX_VARIABLES,
562
+ )) {
563
+ const placeholders = dependencyIdChunk.map(() => "?").join(", ");
564
+ this.#db.query(`DELETE FROM dependencies WHERE id IN (${placeholders});`).run(...dependencyIdChunk);
565
+ }
566
+
567
+ for (const subtaskIdChunk of chunkValues(plan.subtaskIds, SQLITE_MAX_VARIABLES)) {
568
+ const placeholders = subtaskIdChunk.map(() => "?").join(", ");
569
+ this.#db.query(`DELETE FROM subtasks WHERE id IN (${placeholders});`).run(...subtaskIdChunk);
570
+ }
571
+
572
+ this.#db.query("DELETE FROM tasks WHERE id = ?;").run(normalizedTaskId);
530
573
  }
531
574
 
532
575
  createSubtask(
@@ -748,7 +791,7 @@ export class TrackerDomain {
748
791
  const nextDescription: string =
749
792
  input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
750
793
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
751
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
794
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
752
795
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
753
796
  const now: number = Date.now();
754
797
 
@@ -761,9 +804,57 @@ export class TrackerDomain {
761
804
 
762
805
  deleteSubtask(id: string): void {
763
806
  this.getSubtaskOrThrow(id);
807
+ this.#db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(id, id);
764
808
  this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
765
809
  }
766
810
 
811
+ listDependenciesTouchingNode(nodeId: string): readonly DependencyRecord[] {
812
+ const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
813
+ this.resolveNodeKind(normalizedNodeId);
814
+
815
+ const rows = this.#db
816
+ .query(
817
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? OR depends_on_id = ? ORDER BY created_at ASC, id ASC;",
818
+ )
819
+ .all(normalizedNodeId, normalizedNodeId) as DependencyRow[];
820
+
821
+ return rows.map(mapDependency);
822
+ }
823
+
824
+ planTaskDeletion(taskId: string): TaskDeletionPlan {
825
+ const normalizedTaskId: string = assertNonEmpty("taskId", taskId);
826
+ this.getTaskOrThrow(normalizedTaskId);
827
+
828
+ const subtaskRows = this.#db
829
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
830
+ .all(normalizedTaskId) as Array<{ id: string }>;
831
+ const subtaskIds: string[] = subtaskRows.map((row) => row.id);
832
+ const nodeIds: string[] = [normalizedTaskId, ...subtaskIds];
833
+ const dependencyRows: DependencyRow[] = [];
834
+ const nodeIdChunks = chunkValues(nodeIds, Math.floor(SQLITE_MAX_VARIABLES / 2));
835
+ for (const nodeIdChunk of nodeIdChunks) {
836
+ const placeholders = nodeIdChunk.map(() => "?").join(", ");
837
+ const rows = this.#db
838
+ .query(
839
+ `SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at
840
+ FROM dependencies
841
+ WHERE source_id IN (${placeholders}) OR depends_on_id IN (${placeholders})
842
+ ORDER BY created_at ASC, id ASC;`,
843
+ )
844
+ .all(...nodeIdChunk, ...nodeIdChunk) as DependencyRow[];
845
+ dependencyRows.push(...rows);
846
+ }
847
+
848
+ const rows: DependencyRow[] = [...new Map(dependencyRows.map((row) => [row.id, row])).values()].sort(
849
+ (left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id),
850
+ );
851
+
852
+ return {
853
+ subtaskIds,
854
+ touchingDependencies: rows.map(mapDependency),
855
+ };
856
+ }
857
+
767
858
  buildEpicTree(epicId: string): EpicTree {
768
859
  const epic: EpicRecord = this.getEpicOrThrow(epicId);
769
860
  const tasks: readonly TaskRecord[] = this.listTasks(epicId);
@@ -804,7 +895,7 @@ export class TrackerDomain {
804
895
 
805
896
  buildTaskTreeDetailed(taskId: string): TaskTreeDetailed {
806
897
  const task: TaskRecord = this.getTaskOrThrow(taskId);
807
- const subtasks: readonly SubtaskRecord[] = this.listSubtasks(task.id);
898
+ const subtasks: readonly SubtaskRecord[] = this.listSubtasksByTaskIds([task.id]).get(task.id) ?? [];
808
899
 
809
900
  return {
810
901
  id: task.id,
@@ -825,13 +916,27 @@ export class TrackerDomain {
825
916
  buildEpicTreeDetailed(epicId: string): EpicTreeDetailed {
826
917
  const epic: EpicRecord = this.getEpicOrThrow(epicId);
827
918
  const tasks: readonly TaskRecord[] = this.listTasks(epic.id);
919
+ const subtasksByTaskId = this.listSubtasksByTaskIds(tasks.map((task) => task.id));
828
920
 
829
921
  return {
830
922
  id: epic.id,
831
923
  title: epic.title,
832
924
  description: epic.description,
833
925
  status: epic.status,
834
- tasks: tasks.map((task) => this.buildTaskTreeDetailed(task.id)),
926
+ tasks: tasks.map((task) => ({
927
+ id: task.id,
928
+ epicId: task.epicId,
929
+ title: task.title,
930
+ description: task.description,
931
+ status: task.status,
932
+ subtasks: (subtasksByTaskId.get(task.id) ?? []).map((subtask) => ({
933
+ id: subtask.id,
934
+ taskId: subtask.taskId,
935
+ title: subtask.title,
936
+ description: subtask.description,
937
+ status: subtask.status,
938
+ })),
939
+ })),
835
940
  };
836
941
  }
837
942
 
@@ -1124,6 +1229,76 @@ export class TrackerDomain {
1124
1229
  return rows.map(mapDependency);
1125
1230
  }
1126
1231
 
1232
+ listDependenciesBySourceIds(sourceIds: readonly string[]): Map<string, readonly DependencyRecord[]> {
1233
+ const dependencyMap = new Map<string, DependencyRecord[]>();
1234
+
1235
+ if (sourceIds.length === 0) {
1236
+ return dependencyMap;
1237
+ }
1238
+
1239
+ const normalizedIds = sourceIds.map((sourceId) => assertNonEmpty("sourceId", sourceId));
1240
+ for (const sourceId of normalizedIds) {
1241
+ this.resolveNodeKind(sourceId);
1242
+ dependencyMap.set(sourceId, []);
1243
+ }
1244
+
1245
+ for (let offset = 0; offset < normalizedIds.length; offset += SQLITE_MAX_VARIABLES) {
1246
+ const chunkIds = normalizedIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1247
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1248
+ const rows = this.#db
1249
+ .query(
1250
+ `SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at
1251
+ FROM dependencies
1252
+ WHERE source_id IN (${inPlaceholders})
1253
+ ORDER BY created_at ASC, id ASC;`,
1254
+ )
1255
+ .all(...chunkIds) as DependencyRow[];
1256
+
1257
+ for (const row of rows) {
1258
+ const existing = dependencyMap.get(row.source_id) ?? [];
1259
+ existing.push(mapDependency(row));
1260
+ dependencyMap.set(row.source_id, existing);
1261
+ }
1262
+ }
1263
+
1264
+ return dependencyMap;
1265
+ }
1266
+
1267
+ listSubtasksByTaskIds(taskIds: readonly string[]): Map<string, readonly SubtaskRecord[]> {
1268
+ const subtaskMap = new Map<string, SubtaskRecord[]>();
1269
+
1270
+ if (taskIds.length === 0) {
1271
+ return subtaskMap;
1272
+ }
1273
+
1274
+ const normalizedIds = taskIds.map((taskId) => assertNonEmpty("taskId", taskId));
1275
+ for (const taskId of normalizedIds) {
1276
+ this.getTaskOrThrow(taskId);
1277
+ subtaskMap.set(taskId, []);
1278
+ }
1279
+
1280
+ for (let offset = 0; offset < normalizedIds.length; offset += SQLITE_MAX_VARIABLES) {
1281
+ const chunkIds = normalizedIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1282
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1283
+ const rows = this.#db
1284
+ .query(
1285
+ `SELECT id, task_id, title, description, status, owner, created_at, updated_at
1286
+ FROM subtasks
1287
+ WHERE task_id IN (${inPlaceholders})
1288
+ ORDER BY created_at ASC, id ASC;`,
1289
+ )
1290
+ .all(...chunkIds) as SubtaskRow[];
1291
+
1292
+ for (const row of rows) {
1293
+ const existing = subtaskMap.get(row.task_id) ?? [];
1294
+ existing.push(mapSubtask(row));
1295
+ subtaskMap.set(row.task_id, existing);
1296
+ }
1297
+ }
1298
+
1299
+ return subtaskMap;
1300
+ }
1301
+
1127
1302
  /**
1128
1303
  * Resolves dependency statuses for multiple tasks using a single prepared
1129
1304
  * statement executed once per task ID. This avoids the previous N+1 pattern
@@ -1646,6 +1821,9 @@ export class TrackerDomain {
1646
1821
  const dependencyTargetsBySource = new Map<string, Set<string>>();
1647
1822
  const dependents = new Map<string, Set<string>>();
1648
1823
  const indegree = new Map<string, number>();
1824
+ const dependencyMap = this.listDependenciesBySourceIds(
1825
+ changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
1826
+ );
1649
1827
 
1650
1828
  changes.forEach((change, index) => {
1651
1829
  indexById.set(change.id, index);
@@ -1656,7 +1834,7 @@ export class TrackerDomain {
1656
1834
  return;
1657
1835
  }
1658
1836
 
1659
- const dependencyTargets = new Set(this.listDependencies(change.id).map((dependency) => dependency.dependsOnId));
1837
+ const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
1660
1838
  dependencyTargetsBySource.set(change.id, dependencyTargets);
1661
1839
  });
1662
1840
 
@@ -72,11 +72,59 @@ const LOOKUP_INDEX_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
72
72
  "DROP INDEX IF EXISTS idx_dependencies_depends_on_kind;",
73
73
  ];
74
74
 
75
+ const SYNC_SCALING_MIGRATION_UP_STATEMENTS: readonly string[] = [
76
+ "CREATE INDEX IF NOT EXISTS idx_events_branch_cursor ON events(git_branch, created_at, id);",
77
+ "CREATE INDEX IF NOT EXISTS idx_events_entity_branch_cursor ON events(entity_kind, entity_id, git_branch, created_at, id);",
78
+ "CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);",
79
+ ];
80
+
81
+ const SYNC_SCALING_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
82
+ "DROP INDEX IF EXISTS idx_conflicts_resolution_entity_field_id;",
83
+ "DROP INDEX IF EXISTS idx_events_entity_branch_cursor;",
84
+ "DROP INDEX IF EXISTS idx_events_branch_cursor;",
85
+ ];
86
+
87
+ const BOARD_IDEMPOTENCY_MIGRATION_UP_STATEMENTS: readonly string[] = [
88
+ `
89
+ CREATE TABLE IF NOT EXISTS board_idempotency_keys (
90
+ scope TEXT NOT NULL,
91
+ idempotency_key TEXT NOT NULL,
92
+ request_fingerprint TEXT NOT NULL,
93
+ state TEXT NOT NULL DEFAULT 'completed',
94
+ response_status INTEGER NOT NULL,
95
+ response_body TEXT NOT NULL,
96
+ created_at INTEGER NOT NULL,
97
+ PRIMARY KEY (scope, idempotency_key)
98
+ );
99
+ `,
100
+ "CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);",
101
+ ];
102
+
103
+ const BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
104
+ "DROP INDEX IF EXISTS idx_board_idempotency_created_at;",
105
+ "DROP TABLE IF EXISTS board_idempotency_keys;",
106
+ ];
107
+
108
+ const BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS: readonly string[] = [
109
+ "CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);",
110
+ ];
111
+
112
+ const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
113
+ "DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
114
+ ];
115
+
75
116
  function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
76
117
  const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
77
118
  return columns.some((column) => column.name === columnName);
78
119
  }
79
120
 
121
+ function tableExists(db: Database, tableName: string): boolean {
122
+ const row = db
123
+ .query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?;")
124
+ .get(tableName) as { count: number } | null;
125
+ return (row?.count ?? 0) > 0;
126
+ }
127
+
80
128
  function migrateWorktreeScopedSyncMetadata(db: Database): void {
81
129
  if (!tableHasColumn(db, "git_context", "metadata_scope")) {
82
130
  db.exec("ALTER TABLE git_context ADD COLUMN metadata_scope TEXT NOT NULL DEFAULT 'worktree';");
@@ -109,6 +157,32 @@ function migrateWorktreeScopedSyncMetadata(db: Database): void {
109
157
  );
110
158
  }
111
159
 
160
+ function migrateBoardIdempotencyState(db: Database): void {
161
+ if (!tableExists(db, "board_idempotency_keys")) {
162
+ return;
163
+ }
164
+
165
+ if (!tableHasColumn(db, "board_idempotency_keys", "state")) {
166
+ db.exec("ALTER TABLE board_idempotency_keys ADD COLUMN state TEXT NOT NULL DEFAULT 'completed';");
167
+ db.exec("UPDATE board_idempotency_keys SET state = 'completed' WHERE state IS NULL OR state = ''; ");
168
+ return;
169
+ }
170
+
171
+ const row = db
172
+ .query(
173
+ `
174
+ SELECT COUNT(*) AS count
175
+ FROM board_idempotency_keys
176
+ WHERE state IS NULL OR state = '';
177
+ `,
178
+ )
179
+ .get() as { count: number } | null;
180
+
181
+ if ((row?.count ?? 0) > 0) {
182
+ db.exec("UPDATE board_idempotency_keys SET state = 'completed' WHERE state IS NULL OR state = ''; ");
183
+ }
184
+ }
185
+
112
186
  interface Migration {
113
187
  readonly version: number;
114
188
  readonly name: string;
@@ -265,6 +339,48 @@ const MIGRATIONS: readonly Migration[] = [
265
339
  }
266
340
  },
267
341
  },
342
+ {
343
+ version: 8,
344
+ name: "0008_sync_scaling_indexes",
345
+ up(db: Database): void {
346
+ for (const statement of SYNC_SCALING_MIGRATION_UP_STATEMENTS) {
347
+ db.exec(statement);
348
+ }
349
+ },
350
+ down(db: Database): void {
351
+ for (const statement of SYNC_SCALING_MIGRATION_DOWN_STATEMENTS) {
352
+ db.exec(statement);
353
+ }
354
+ },
355
+ },
356
+ {
357
+ version: 9,
358
+ name: "0009_board_idempotency_storage",
359
+ up(db: Database): void {
360
+ for (const statement of BOARD_IDEMPOTENCY_MIGRATION_UP_STATEMENTS) {
361
+ db.exec(statement);
362
+ }
363
+ },
364
+ down(db: Database): void {
365
+ for (const statement of BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS) {
366
+ db.exec(statement);
367
+ }
368
+ },
369
+ },
370
+ {
371
+ version: 10,
372
+ name: "0010_board_idempotency_retention_index",
373
+ up(db: Database): void {
374
+ for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS) {
375
+ db.exec(statement);
376
+ }
377
+ },
378
+ down(db: Database): void {
379
+ for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS) {
380
+ db.exec(statement);
381
+ }
382
+ },
383
+ },
268
384
  ];
269
385
 
270
386
  function migrationTableExists(db: Database): boolean {
@@ -455,6 +571,7 @@ export function migrateDatabase(db: Database): void {
455
571
  // This reduces startup lock contention while keeping the explicit
456
572
  // transactional migration path for non-current/legacy schemas.
457
573
  if (isSchemaCurrentFastPath(db, latestVersion)) {
574
+ migrateBoardIdempotencyState(db);
458
575
  return;
459
576
  }
460
577
 
@@ -462,6 +579,11 @@ export function migrateDatabase(db: Database): void {
462
579
  ensureMigrationTable(db);
463
580
  ensureMigrationVersionColumn(db);
464
581
 
582
+ // Backfill the legacy board_idempotency_keys.state column before running
583
+ // any migrations so that later migrations (e.g. 0010's state-scoped index)
584
+ // can assume the column exists on databases whose 0009 predates it.
585
+ migrateBoardIdempotencyState(db);
586
+
465
587
  const version: number = currentVersion(db);
466
588
 
467
589
  for (const migration of MIGRATIONS) {
@@ -478,6 +600,7 @@ export function migrateDatabase(db: Database): void {
478
600
  export function describeMigrations(db: Database): MigrationStatus {
479
601
  ensureMigrationTable(db);
480
602
  ensureMigrationVersionColumn(db);
603
+ migrateBoardIdempotencyState(db);
481
604
  validateMigrationPlan();
482
605
 
483
606
  const appliedRows: AppliedMigrationRow[] = listAppliedMigrations(db);
@@ -72,6 +72,8 @@ export interface StoragePathDiagnostics {
72
72
  readonly errors: readonly StoragePathIssue[];
73
73
  }
74
74
 
75
+ const storagePathCache: Map<string, StoragePaths> = new Map();
76
+
75
77
  function resolveGitPath(workingDirectory: string, argument: "--git-common-dir" | "--show-toplevel"): string | null {
76
78
  const result = spawnSync("git", ["rev-parse", argument], {
77
79
  cwd: workingDirectory,
@@ -93,6 +95,11 @@ function resolveGitPath(workingDirectory: string, argument: "--git-common-dir" |
93
95
 
94
96
  export function resolveStoragePaths(workingDirectory: string = process.cwd()): StoragePaths {
95
97
  const invocationCwd: string = resolve(workingDirectory);
98
+ const cachedPaths: StoragePaths | undefined = storagePathCache.get(invocationCwd);
99
+ if (cachedPaths) {
100
+ return cachedPaths;
101
+ }
102
+
96
103
  const worktreeRoot: string = resolveGitPath(invocationCwd, "--show-toplevel") ?? invocationCwd;
97
104
  const repoCommonDirRaw: string | null = resolveGitPath(invocationCwd, "--git-common-dir");
98
105
  const repoCommonDir: string | null = repoCommonDirRaw ? realpathSync(repoCommonDirRaw) : null;
@@ -148,7 +155,7 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
148
155
  errors: [],
149
156
  };
150
157
 
151
- return {
158
+ const storagePaths: StoragePaths = {
152
159
  invocationCwd,
153
160
  storageMode,
154
161
  repoCommonDir,
@@ -161,4 +168,8 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
161
168
  boardManifestFile,
162
169
  diagnostics,
163
170
  };
171
+
172
+ storagePathCache.set(invocationCwd, storagePaths);
173
+
174
+ return storagePaths;
164
175
  }
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 1;
1
+ export const SCHEMA_VERSION = 3;
2
2
 
3
3
  export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
4
4
  `PRAGMA foreign_keys = ON;`,
@@ -115,10 +115,27 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
115
115
  version INTEGER NOT NULL DEFAULT 1
116
116
  );
117
117
  `,
118
+ `
119
+ CREATE TABLE IF NOT EXISTS board_idempotency_keys (
120
+ scope TEXT NOT NULL,
121
+ idempotency_key TEXT NOT NULL,
122
+ request_fingerprint TEXT NOT NULL,
123
+ state TEXT NOT NULL DEFAULT 'completed',
124
+ response_status INTEGER NOT NULL,
125
+ response_body TEXT NOT NULL,
126
+ created_at INTEGER NOT NULL,
127
+ PRIMARY KEY (scope, idempotency_key)
128
+ );
129
+ `,
118
130
  `CREATE INDEX IF NOT EXISTS idx_tasks_epic_id ON tasks(epic_id);`,
119
131
  `CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);`,
120
132
  `CREATE INDEX IF NOT EXISTS idx_events_entity ON events(entity_kind, entity_id);`,
133
+ `CREATE INDEX IF NOT EXISTS idx_events_branch_cursor ON events(git_branch, created_at, id);`,
134
+ `CREATE INDEX IF NOT EXISTS idx_events_entity_branch_cursor ON events(entity_kind, entity_id, git_branch, created_at, id);`,
121
135
  `CREATE INDEX IF NOT EXISTS idx_git_context_scope_path ON git_context(metadata_scope, worktree_path);`,
122
136
  `CREATE INDEX IF NOT EXISTS idx_sync_cursors_owner ON sync_cursors(owner_scope, owner_worktree_path, source_branch);`,
123
137
  `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
138
+ `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
139
+ `CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
140
+ `CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
124
141
  ];
@@ -36,6 +36,7 @@ export interface WorktreeRecoveryDiagnostics {
36
36
 
37
37
  interface WorktreeRecoveryOptions {
38
38
  readonly applyRecovery?: boolean;
39
+ readonly worktreeRoots?: readonly string[];
39
40
  }
40
41
 
41
42
  function readGitLines(workingDirectory: string, args: readonly string[]): string[] {
@@ -89,14 +90,18 @@ function listWorktreeRoots(paths: StoragePaths): string[] {
89
90
  return [...worktreeRoots];
90
91
  }
91
92
 
92
- function listTrackedStorageFiles(paths: StoragePaths): string[] {
93
+ function resolveRecoveryWorktreeRoots(paths: StoragePaths, options: WorktreeRecoveryOptions): readonly string[] {
94
+ return options.worktreeRoots ?? listWorktreeRoots(paths);
95
+ }
96
+
97
+ function listTrackedStorageFiles(paths: StoragePaths, worktreeRoots: readonly string[]): string[] {
93
98
  if (paths.repoCommonDir === null) {
94
99
  return [];
95
100
  }
96
101
 
97
102
  const trackedFiles = new Set<string>();
98
103
 
99
- for (const worktreeRoot of listWorktreeRoots(paths)) {
104
+ for (const worktreeRoot of worktreeRoots) {
100
105
  for (const entry of readGitLines(worktreeRoot, ["ls-files", "--cached", "--", ".trekoon"])) {
101
106
  trackedFiles.add(resolve(worktreeRoot, entry));
102
107
  }
@@ -105,10 +110,10 @@ function listTrackedStorageFiles(paths: StoragePaths): string[] {
105
110
  return [...trackedFiles].sort();
106
111
  }
107
112
 
108
- function listLegacyDatabaseFiles(paths: StoragePaths): string[] {
113
+ function listLegacyDatabaseFiles(paths: StoragePaths, worktreeRoots: readonly string[]): string[] {
109
114
  const files = new Set<string>();
110
115
 
111
- for (const worktreeRoot of listWorktreeRoots(paths)) {
116
+ for (const worktreeRoot of worktreeRoots) {
112
117
  const legacyDatabaseFile: string = resolveLegacyWorktreeDatabaseFile(worktreeRoot);
113
118
  if (legacyDatabaseFile === paths.databaseFile || !existsSync(legacyDatabaseFile)) {
114
119
  continue;
@@ -278,8 +283,9 @@ export function inspectWorktreeDatabaseState(
278
283
  options: WorktreeRecoveryOptions = {},
279
284
  ): WorktreeRecoveryDiagnostics {
280
285
  const applyRecovery: boolean = options.applyRecovery ?? false;
281
- const trackedStorageFiles: string[] = listTrackedStorageFiles(paths);
282
- const legacyDatabaseFiles: string[] = listLegacyDatabaseFiles(paths);
286
+ const worktreeRoots: readonly string[] = resolveRecoveryWorktreeRoots(paths, options);
287
+ const trackedStorageFiles: string[] = listTrackedStorageFiles(paths, worktreeRoots);
288
+ const legacyDatabaseFiles: string[] = listLegacyDatabaseFiles(paths, worktreeRoots);
283
289
 
284
290
  if (trackedStorageFiles.length > 0) {
285
291
  throw new DomainError({
@@ -55,6 +55,15 @@ export function assertValidSourceRef(workingDirectory: string, sourceRef: string
55
55
  }
56
56
 
57
57
  export function queryBranchEventsSince(db: Database, branch: string, cursorToken: string): BranchEventRow[] {
58
+ return queryBranchEventsSinceBatch(db, branch, cursorToken);
59
+ }
60
+
61
+ export function queryBranchEventsSinceBatch(
62
+ db: Database,
63
+ branch: string,
64
+ cursorToken: string,
65
+ limit: number = 250,
66
+ ): BranchEventRow[] {
58
67
  const cursor = parseCursorToken(cursorToken);
59
68
 
60
69
  return db
@@ -67,13 +76,15 @@ export function queryBranchEventsSince(db: Database, branch: string, cursorToken
67
76
  created_at > @createdAt
68
77
  OR (created_at = @createdAt AND id > @id)
69
78
  )
70
- ORDER BY created_at ASC, id ASC;
79
+ ORDER BY created_at ASC, id ASC
80
+ LIMIT @limit;
71
81
  `,
72
82
  )
73
83
  .all({
74
84
  "@branch": branch,
75
85
  "@createdAt": cursor.createdAt,
76
86
  "@id": cursor.id ?? "",
87
+ "@limit": limit,
77
88
  }) as BranchEventRow[];
78
89
  }
79
90
 
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
- import { persistGitContext, resolveGitContext } from "./git-context";
5
+ import { persistGitContext, resolveGitContext, type ResolvedGitContext } from "./git-context";
6
6
 
7
7
  interface EventRecordInput {
8
8
  readonly entityKind: string;
@@ -11,6 +11,13 @@ interface EventRecordInput {
11
11
  readonly fields: Record<string, unknown>;
12
12
  }
13
13
 
14
+ interface EventWriteContext {
15
+ readonly git: ResolvedGitContext;
16
+ nextTimestamp: number;
17
+ }
18
+
19
+ const transactionEventContexts: WeakMap<Database, EventWriteContext> = new WeakMap();
20
+
14
21
  export function nextEventTimestamp(db: Database): number {
15
22
  const now: number = Date.now();
16
23
  const latestEvent = db
@@ -31,16 +38,47 @@ export function nextEventTimestamp(db: Database): number {
31
38
  return Math.max(now, latestEvent.created_at + 1);
32
39
  }
33
40
 
34
- /** Append a single event to the events table with git context. Returns void (event ID is not exposed). */
41
+ export function prepareEventWriteContext(db: Database, cwd: string): EventWriteContext {
42
+ const nextTimestamp: number = nextEventTimestamp(db);
43
+ const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
44
+
45
+ return {
46
+ git,
47
+ nextTimestamp,
48
+ };
49
+ }
50
+
51
+ export function withTransactionEventContext<T>(db: Database, context: EventWriteContext, fn: () => T): T {
52
+ const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
53
+ if (existingContext) {
54
+ return fn();
55
+ }
56
+
57
+ transactionEventContexts.set(db, context);
58
+
59
+ try {
60
+ return fn();
61
+ } finally {
62
+ transactionEventContexts.delete(db);
63
+ }
64
+ }
65
+
66
+ /** Append a single event to the events table with git context. Returns the event ID. */
35
67
  export function appendEventWithGitContext(
36
68
  db: Database,
37
69
  cwd: string,
38
70
  input: EventRecordInput,
39
- ): void {
40
- const git = resolveGitContext(cwd);
41
- persistGitContext(db, git);
71
+ ): string {
72
+ const context: EventWriteContext | undefined = transactionEventContexts.get(db);
73
+ const now: number = context?.nextTimestamp ?? nextEventTimestamp(db);
74
+ const git: ResolvedGitContext = context?.git ?? resolveGitContext(cwd, now);
75
+ const eventId: string = randomUUID();
42
76
 
43
- const now: number = nextEventTimestamp(db);
77
+ persistGitContext(db, git, now);
78
+
79
+ if (context) {
80
+ context.nextTimestamp += 1;
81
+ }
44
82
 
45
83
  db.query(
46
84
  `
@@ -58,7 +96,7 @@ export function appendEventWithGitContext(
58
96
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
59
97
  `,
60
98
  ).run(
61
- randomUUID(),
99
+ eventId,
62
100
  input.entityKind,
63
101
  input.entityId,
64
102
  input.operation,
@@ -68,4 +106,6 @@ export function appendEventWithGitContext(
68
106
  now,
69
107
  now,
70
108
  );
109
+
110
+ return eventId;
71
111
  }