trekoon 0.3.5 → 0.3.7

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) {
@@ -525,8 +538,24 @@ export class TrackerDomain {
525
538
  }
526
539
 
527
540
  deleteTask(id: string): void {
528
- this.getTaskOrThrow(id);
529
- this.#db.query("DELETE FROM tasks WHERE id = ?;").run(id);
541
+ const normalizedTaskId: string = assertNonEmpty("id", id);
542
+ this.getTaskOrThrow(normalizedTaskId);
543
+
544
+ const plan = this.planTaskDeletion(normalizedTaskId);
545
+ for (const dependencyIdChunk of chunkValues(
546
+ plan.touchingDependencies.map((dependency) => dependency.id),
547
+ SQLITE_MAX_VARIABLES,
548
+ )) {
549
+ const placeholders = dependencyIdChunk.map(() => "?").join(", ");
550
+ this.#db.query(`DELETE FROM dependencies WHERE id IN (${placeholders});`).run(...dependencyIdChunk);
551
+ }
552
+
553
+ for (const subtaskIdChunk of chunkValues(plan.subtaskIds, SQLITE_MAX_VARIABLES)) {
554
+ const placeholders = subtaskIdChunk.map(() => "?").join(", ");
555
+ this.#db.query(`DELETE FROM subtasks WHERE id IN (${placeholders});`).run(...subtaskIdChunk);
556
+ }
557
+
558
+ this.#db.query("DELETE FROM tasks WHERE id = ?;").run(normalizedTaskId);
530
559
  }
531
560
 
532
561
  createSubtask(
@@ -761,9 +790,57 @@ export class TrackerDomain {
761
790
 
762
791
  deleteSubtask(id: string): void {
763
792
  this.getSubtaskOrThrow(id);
793
+ this.#db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(id, id);
764
794
  this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
765
795
  }
766
796
 
797
+ listDependenciesTouchingNode(nodeId: string): readonly DependencyRecord[] {
798
+ const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
799
+ this.resolveNodeKind(normalizedNodeId);
800
+
801
+ const rows = this.#db
802
+ .query(
803
+ "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;",
804
+ )
805
+ .all(normalizedNodeId, normalizedNodeId) as DependencyRow[];
806
+
807
+ return rows.map(mapDependency);
808
+ }
809
+
810
+ planTaskDeletion(taskId: string): TaskDeletionPlan {
811
+ const normalizedTaskId: string = assertNonEmpty("taskId", taskId);
812
+ this.getTaskOrThrow(normalizedTaskId);
813
+
814
+ const subtaskRows = this.#db
815
+ .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
816
+ .all(normalizedTaskId) as Array<{ id: string }>;
817
+ const subtaskIds: string[] = subtaskRows.map((row) => row.id);
818
+ const nodeIds: string[] = [normalizedTaskId, ...subtaskIds];
819
+ const dependencyRows: DependencyRow[] = [];
820
+ const nodeIdChunks = chunkValues(nodeIds, Math.floor(SQLITE_MAX_VARIABLES / 2));
821
+ for (const nodeIdChunk of nodeIdChunks) {
822
+ const placeholders = nodeIdChunk.map(() => "?").join(", ");
823
+ const rows = this.#db
824
+ .query(
825
+ `SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at
826
+ FROM dependencies
827
+ WHERE source_id IN (${placeholders}) OR depends_on_id IN (${placeholders})
828
+ ORDER BY created_at ASC, id ASC;`,
829
+ )
830
+ .all(...nodeIdChunk, ...nodeIdChunk) as DependencyRow[];
831
+ dependencyRows.push(...rows);
832
+ }
833
+
834
+ const rows: DependencyRow[] = [...new Map(dependencyRows.map((row) => [row.id, row])).values()].sort(
835
+ (left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id),
836
+ );
837
+
838
+ return {
839
+ subtaskIds,
840
+ touchingDependencies: rows.map(mapDependency),
841
+ };
842
+ }
843
+
767
844
  buildEpicTree(epicId: string): EpicTree {
768
845
  const epic: EpicRecord = this.getEpicOrThrow(epicId);
769
846
  const tasks: readonly TaskRecord[] = this.listTasks(epicId);
@@ -804,7 +881,7 @@ export class TrackerDomain {
804
881
 
805
882
  buildTaskTreeDetailed(taskId: string): TaskTreeDetailed {
806
883
  const task: TaskRecord = this.getTaskOrThrow(taskId);
807
- const subtasks: readonly SubtaskRecord[] = this.listSubtasks(task.id);
884
+ const subtasks: readonly SubtaskRecord[] = this.listSubtasksByTaskIds([task.id]).get(task.id) ?? [];
808
885
 
809
886
  return {
810
887
  id: task.id,
@@ -825,13 +902,27 @@ export class TrackerDomain {
825
902
  buildEpicTreeDetailed(epicId: string): EpicTreeDetailed {
826
903
  const epic: EpicRecord = this.getEpicOrThrow(epicId);
827
904
  const tasks: readonly TaskRecord[] = this.listTasks(epic.id);
905
+ const subtasksByTaskId = this.listSubtasksByTaskIds(tasks.map((task) => task.id));
828
906
 
829
907
  return {
830
908
  id: epic.id,
831
909
  title: epic.title,
832
910
  description: epic.description,
833
911
  status: epic.status,
834
- tasks: tasks.map((task) => this.buildTaskTreeDetailed(task.id)),
912
+ tasks: tasks.map((task) => ({
913
+ id: task.id,
914
+ epicId: task.epicId,
915
+ title: task.title,
916
+ description: task.description,
917
+ status: task.status,
918
+ subtasks: (subtasksByTaskId.get(task.id) ?? []).map((subtask) => ({
919
+ id: subtask.id,
920
+ taskId: subtask.taskId,
921
+ title: subtask.title,
922
+ description: subtask.description,
923
+ status: subtask.status,
924
+ })),
925
+ })),
835
926
  };
836
927
  }
837
928
 
@@ -1124,6 +1215,76 @@ export class TrackerDomain {
1124
1215
  return rows.map(mapDependency);
1125
1216
  }
1126
1217
 
1218
+ listDependenciesBySourceIds(sourceIds: readonly string[]): Map<string, readonly DependencyRecord[]> {
1219
+ const dependencyMap = new Map<string, DependencyRecord[]>();
1220
+
1221
+ if (sourceIds.length === 0) {
1222
+ return dependencyMap;
1223
+ }
1224
+
1225
+ const normalizedIds = sourceIds.map((sourceId) => assertNonEmpty("sourceId", sourceId));
1226
+ for (const sourceId of normalizedIds) {
1227
+ this.resolveNodeKind(sourceId);
1228
+ dependencyMap.set(sourceId, []);
1229
+ }
1230
+
1231
+ for (let offset = 0; offset < normalizedIds.length; offset += SQLITE_MAX_VARIABLES) {
1232
+ const chunkIds = normalizedIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1233
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1234
+ const rows = this.#db
1235
+ .query(
1236
+ `SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at
1237
+ FROM dependencies
1238
+ WHERE source_id IN (${inPlaceholders})
1239
+ ORDER BY created_at ASC, id ASC;`,
1240
+ )
1241
+ .all(...chunkIds) as DependencyRow[];
1242
+
1243
+ for (const row of rows) {
1244
+ const existing = dependencyMap.get(row.source_id) ?? [];
1245
+ existing.push(mapDependency(row));
1246
+ dependencyMap.set(row.source_id, existing);
1247
+ }
1248
+ }
1249
+
1250
+ return dependencyMap;
1251
+ }
1252
+
1253
+ listSubtasksByTaskIds(taskIds: readonly string[]): Map<string, readonly SubtaskRecord[]> {
1254
+ const subtaskMap = new Map<string, SubtaskRecord[]>();
1255
+
1256
+ if (taskIds.length === 0) {
1257
+ return subtaskMap;
1258
+ }
1259
+
1260
+ const normalizedIds = taskIds.map((taskId) => assertNonEmpty("taskId", taskId));
1261
+ for (const taskId of normalizedIds) {
1262
+ this.getTaskOrThrow(taskId);
1263
+ subtaskMap.set(taskId, []);
1264
+ }
1265
+
1266
+ for (let offset = 0; offset < normalizedIds.length; offset += SQLITE_MAX_VARIABLES) {
1267
+ const chunkIds = normalizedIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1268
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1269
+ const rows = this.#db
1270
+ .query(
1271
+ `SELECT id, task_id, title, description, status, owner, created_at, updated_at
1272
+ FROM subtasks
1273
+ WHERE task_id IN (${inPlaceholders})
1274
+ ORDER BY created_at ASC, id ASC;`,
1275
+ )
1276
+ .all(...chunkIds) as SubtaskRow[];
1277
+
1278
+ for (const row of rows) {
1279
+ const existing = subtaskMap.get(row.task_id) ?? [];
1280
+ existing.push(mapSubtask(row));
1281
+ subtaskMap.set(row.task_id, existing);
1282
+ }
1283
+ }
1284
+
1285
+ return subtaskMap;
1286
+ }
1287
+
1127
1288
  /**
1128
1289
  * Resolves dependency statuses for multiple tasks using a single prepared
1129
1290
  * statement executed once per task ID. This avoids the previous N+1 pattern
@@ -1646,6 +1807,9 @@ export class TrackerDomain {
1646
1807
  const dependencyTargetsBySource = new Map<string, Set<string>>();
1647
1808
  const dependents = new Map<string, Set<string>>();
1648
1809
  const indegree = new Map<string, number>();
1810
+ const dependencyMap = this.listDependenciesBySourceIds(
1811
+ changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
1812
+ );
1649
1813
 
1650
1814
  changes.forEach((change, index) => {
1651
1815
  indexById.set(change.id, index);
@@ -1656,7 +1820,7 @@ export class TrackerDomain {
1656
1820
  return;
1657
1821
  }
1658
1822
 
1659
- const dependencyTargets = new Set(this.listDependencies(change.id).map((dependency) => dependency.dependsOnId));
1823
+ const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
1660
1824
  dependencyTargetsBySource.set(change.id, dependencyTargets);
1661
1825
  });
1662
1826
 
@@ -72,11 +72,51 @@ 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
+
75
108
  function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
76
109
  const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
77
110
  return columns.some((column) => column.name === columnName);
78
111
  }
79
112
 
113
+ function tableExists(db: Database, tableName: string): boolean {
114
+ const row = db
115
+ .query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?;")
116
+ .get(tableName) as { count: number } | null;
117
+ return (row?.count ?? 0) > 0;
118
+ }
119
+
80
120
  function migrateWorktreeScopedSyncMetadata(db: Database): void {
81
121
  if (!tableHasColumn(db, "git_context", "metadata_scope")) {
82
122
  db.exec("ALTER TABLE git_context ADD COLUMN metadata_scope TEXT NOT NULL DEFAULT 'worktree';");
@@ -109,6 +149,32 @@ function migrateWorktreeScopedSyncMetadata(db: Database): void {
109
149
  );
110
150
  }
111
151
 
152
+ function migrateBoardIdempotencyState(db: Database): void {
153
+ if (!tableExists(db, "board_idempotency_keys")) {
154
+ return;
155
+ }
156
+
157
+ if (!tableHasColumn(db, "board_idempotency_keys", "state")) {
158
+ db.exec("ALTER TABLE board_idempotency_keys ADD COLUMN state TEXT NOT NULL DEFAULT 'completed';");
159
+ db.exec("UPDATE board_idempotency_keys SET state = 'completed' WHERE state IS NULL OR state = ''; ");
160
+ return;
161
+ }
162
+
163
+ const row = db
164
+ .query(
165
+ `
166
+ SELECT COUNT(*) AS count
167
+ FROM board_idempotency_keys
168
+ WHERE state IS NULL OR state = '';
169
+ `,
170
+ )
171
+ .get() as { count: number } | null;
172
+
173
+ if ((row?.count ?? 0) > 0) {
174
+ db.exec("UPDATE board_idempotency_keys SET state = 'completed' WHERE state IS NULL OR state = ''; ");
175
+ }
176
+ }
177
+
112
178
  interface Migration {
113
179
  readonly version: number;
114
180
  readonly name: string;
@@ -265,6 +331,34 @@ const MIGRATIONS: readonly Migration[] = [
265
331
  }
266
332
  },
267
333
  },
334
+ {
335
+ version: 8,
336
+ name: "0008_sync_scaling_indexes",
337
+ up(db: Database): void {
338
+ for (const statement of SYNC_SCALING_MIGRATION_UP_STATEMENTS) {
339
+ db.exec(statement);
340
+ }
341
+ },
342
+ down(db: Database): void {
343
+ for (const statement of SYNC_SCALING_MIGRATION_DOWN_STATEMENTS) {
344
+ db.exec(statement);
345
+ }
346
+ },
347
+ },
348
+ {
349
+ version: 9,
350
+ name: "0009_board_idempotency_storage",
351
+ up(db: Database): void {
352
+ for (const statement of BOARD_IDEMPOTENCY_MIGRATION_UP_STATEMENTS) {
353
+ db.exec(statement);
354
+ }
355
+ },
356
+ down(db: Database): void {
357
+ for (const statement of BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS) {
358
+ db.exec(statement);
359
+ }
360
+ },
361
+ },
268
362
  ];
269
363
 
270
364
  function migrationTableExists(db: Database): boolean {
@@ -455,6 +549,7 @@ export function migrateDatabase(db: Database): void {
455
549
  // This reduces startup lock contention while keeping the explicit
456
550
  // transactional migration path for non-current/legacy schemas.
457
551
  if (isSchemaCurrentFastPath(db, latestVersion)) {
552
+ migrateBoardIdempotencyState(db);
458
553
  return;
459
554
  }
460
555
 
@@ -472,12 +567,15 @@ export function migrateDatabase(db: Database): void {
472
567
  migration.up(db);
473
568
  recordMigration(db, migration);
474
569
  }
570
+
571
+ migrateBoardIdempotencyState(db);
475
572
  });
476
573
  }
477
574
 
478
575
  export function describeMigrations(db: Database): MigrationStatus {
479
576
  ensureMigrationTable(db);
480
577
  ensureMigrationVersionColumn(db);
578
+ migrateBoardIdempotencyState(db);
481
579
  validateMigrationPlan();
482
580
 
483
581
  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 = 2;
2
2
 
3
3
  export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
4
4
  `PRAGMA foreign_keys = ON;`,
@@ -115,10 +115,26 @@ 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);`,
124
140
  ];
@@ -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,43 @@ 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 withTransactionEventContext<T>(db: Database, cwd: string, fn: () => T): T {
42
+ const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
43
+ if (existingContext) {
44
+ return fn();
45
+ }
46
+
47
+ const nextTimestamp: number = nextEventTimestamp(db);
48
+ const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
49
+ const context: EventWriteContext = {
50
+ git,
51
+ nextTimestamp,
52
+ };
53
+ transactionEventContexts.set(db, context);
54
+
55
+ try {
56
+ return fn();
57
+ } finally {
58
+ transactionEventContexts.delete(db);
59
+ }
60
+ }
61
+
62
+ /** Append a single event to the events table with git context. Returns the event ID. */
35
63
  export function appendEventWithGitContext(
36
64
  db: Database,
37
65
  cwd: string,
38
66
  input: EventRecordInput,
39
- ): void {
40
- const git = resolveGitContext(cwd);
41
- persistGitContext(db, git);
67
+ ): string {
68
+ const context: EventWriteContext | undefined = transactionEventContexts.get(db);
69
+ const now: number = context?.nextTimestamp ?? nextEventTimestamp(db);
70
+ const git: ResolvedGitContext = context?.git ?? resolveGitContext(cwd, now);
71
+ const eventId: string = randomUUID();
42
72
 
43
- const now: number = nextEventTimestamp(db);
73
+ persistGitContext(db, git, now);
74
+
75
+ if (context) {
76
+ context.nextTimestamp += 1;
77
+ }
44
78
 
45
79
  db.query(
46
80
  `
@@ -58,7 +92,7 @@ export function appendEventWithGitContext(
58
92
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
59
93
  `,
60
94
  ).run(
61
- randomUUID(),
95
+ eventId,
62
96
  input.entityKind,
63
97
  input.entityId,
64
98
  input.operation,
@@ -68,4 +102,6 @@ export function appendEventWithGitContext(
68
102
  now,
69
103
  now,
70
104
  );
105
+
106
+ return eventId;
71
107
  }
@@ -3,6 +3,10 @@ import { type Database } from "bun:sqlite";
3
3
  import { resolveStoragePaths } from "../storage/path";
4
4
  import { type GitContextSnapshot } from "./types";
5
5
 
6
+ export interface ResolvedGitContext extends GitContextSnapshot {
7
+ readonly persistedAt: number;
8
+ }
9
+
6
10
  function runGit(args: readonly string[], cwd: string): string | null {
7
11
  const command = Bun.spawnSync({
8
12
  cmd: ["git", ...args],
@@ -19,7 +23,7 @@ function runGit(args: readonly string[], cwd: string): string | null {
19
23
  return output.length > 0 ? output : null;
20
24
  }
21
25
 
22
- export function resolveGitContext(cwd: string): GitContextSnapshot {
26
+ export function resolveGitContext(cwd: string, persistedAt: number = Date.now()): ResolvedGitContext {
23
27
  const storagePaths = resolveStoragePaths(cwd);
24
28
  const branchName: string | null = runGit(["branch", "--show-current"], cwd);
25
29
  const headSha: string | null = runGit(["rev-parse", "HEAD"], cwd);
@@ -28,11 +32,11 @@ export function resolveGitContext(cwd: string): GitContextSnapshot {
28
32
  worktreePath: storagePaths.worktreeRoot,
29
33
  branchName,
30
34
  headSha,
35
+ persistedAt,
31
36
  };
32
37
  }
33
38
 
34
- export function persistGitContext(db: Database, git: GitContextSnapshot): void {
35
- const now: number = Date.now();
39
+ export function persistGitContext(db: Database, git: GitContextSnapshot, persistedAt: number = Date.now()): void {
36
40
 
37
41
  db.query(
38
42
  `
@@ -51,8 +55,8 @@ export function persistGitContext(db: Database, git: GitContextSnapshot): void {
51
55
  @worktreePath,
52
56
  @branchName,
53
57
  @headSha,
54
- @now,
55
- @now,
58
+ @persistedAt,
59
+ @persistedAt,
56
60
  1
57
61
  )
58
62
  ON CONFLICT(id) DO UPDATE SET
@@ -67,6 +71,6 @@ export function persistGitContext(db: Database, git: GitContextSnapshot): void {
67
71
  "@worktreePath": git.worktreePath,
68
72
  "@branchName": git.branchName,
69
73
  "@headSha": git.headSha,
70
- "@now": now,
74
+ "@persistedAt": persistedAt,
71
75
  });
72
76
  }