trekoon 0.3.6 → 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.
- package/package.json +1 -1
- package/src/board/assets/app.js +11 -0
- 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 +151 -26
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +73 -13
- package/src/board/routes.ts +392 -52
- package/src/board/snapshot.ts +151 -168
- package/src/commands/events.ts +17 -11
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +310 -26
- package/src/domain/tracker-domain.ts +169 -5
- package/src/storage/migrations.ts +98 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +17 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +43 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +578 -149
|
@@ -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
|
-
|
|
529
|
-
this
|
|
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.
|
|
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) =>
|
|
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(
|
|
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);
|
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 = 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
|
|
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,43 @@ export function nextEventTimestamp(db: Database): number {
|
|
|
31
38
|
return Math.max(now, latestEvent.created_at + 1);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
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
|
-
):
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/sync/git-context.ts
CHANGED
|
@@ -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):
|
|
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
|
-
@
|
|
55
|
-
@
|
|
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
|
-
"@
|
|
74
|
+
"@persistedAt": persistedAt,
|
|
71
75
|
});
|
|
72
76
|
}
|