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.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +19 -25
- package/src/board/assets/components/Notice.js +18 -4
- package/src/board/assets/state/actions.js +6 -6
- package/src/board/assets/state/api.js +155 -31
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +123 -30
- package/src/board/routes.ts +397 -54
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +205 -173
- package/src/commands/board.ts +1 -1
- package/src/commands/events.ts +17 -11
- package/src/commands/quickstart.ts +10 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +452 -54
- package/src/domain/tracker-domain.ts +185 -7
- package/src/storage/migrations.ts +123 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +18 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +47 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +759 -151
|
@@ -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
|
-
|
|
529
|
-
this
|
|
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.
|
|
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) =>
|
|
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(
|
|
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);
|
package/src/storage/path.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/storage/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SCHEMA_VERSION =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
282
|
-
const
|
|
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({
|
package/src/sync/branch-db.ts
CHANGED
|
@@ -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
|
|
package/src/sync/event-writes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|