trekoon 0.1.5 → 0.1.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/README.md +24 -2
- package/package.json +1 -1
- package/src/commands/dep.ts +5 -3
- package/src/commands/epic.ts +7 -5
- package/src/commands/help.ts +1 -1
- package/src/commands/skills.ts +383 -39
- package/src/commands/subtask.ts +7 -5
- package/src/commands/sync.ts +102 -3
- package/src/commands/task.ts +7 -5
- package/src/domain/mutation-operations.ts +27 -0
- package/src/domain/mutation-service.ts +169 -0
- package/src/storage/migrations.ts +48 -1
- package/src/sync/service.ts +417 -73
- package/src/sync/types.ts +44 -0
package/src/commands/subtask.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { MutationService } from "../domain/mutation-service";
|
|
4
4
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
5
|
+
import { DomainError, type SubtaskRecord } from "../domain/types";
|
|
5
6
|
import { formatHumanTable } from "../io/human-table";
|
|
6
7
|
import { failResult, okResult } from "../io/output";
|
|
7
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -126,6 +127,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
126
127
|
const parsed = parseArgs(context.args);
|
|
127
128
|
const subcommand: string | undefined = parsed.positional[0];
|
|
128
129
|
const domain = new TrackerDomain(database.db);
|
|
130
|
+
const mutations = new MutationService(database.db, context.cwd);
|
|
129
131
|
|
|
130
132
|
switch (subcommand) {
|
|
131
133
|
case "create": {
|
|
@@ -141,7 +143,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
141
143
|
const title: string | undefined = readOption(parsed.options, "title") ?? parsed.positional[2];
|
|
142
144
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
143
145
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
144
|
-
const subtask =
|
|
146
|
+
const subtask = mutations.createSubtask({
|
|
145
147
|
taskId: taskId ?? "",
|
|
146
148
|
title: title ?? "",
|
|
147
149
|
description,
|
|
@@ -337,7 +339,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
337
339
|
|
|
338
340
|
const targets = updateAll ? [...domain.listSubtasks()] : ids.map((id) => domain.getSubtaskOrThrow(id));
|
|
339
341
|
const subtasks = targets.map((target) =>
|
|
340
|
-
|
|
342
|
+
mutations.updateSubtask(target.id, {
|
|
341
343
|
status,
|
|
342
344
|
description: append === undefined ? undefined : appendLine(target.description, append),
|
|
343
345
|
}),
|
|
@@ -370,7 +372,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
370
372
|
append === undefined
|
|
371
373
|
? description
|
|
372
374
|
: appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
|
|
373
|
-
const subtask =
|
|
375
|
+
const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status });
|
|
374
376
|
|
|
375
377
|
return okResult({
|
|
376
378
|
command: "subtask.update",
|
|
@@ -380,7 +382,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
380
382
|
}
|
|
381
383
|
case "delete": {
|
|
382
384
|
const subtaskId: string = parsed.positional[1] ?? "";
|
|
383
|
-
|
|
385
|
+
mutations.deleteSubtask(subtaskId);
|
|
384
386
|
|
|
385
387
|
return okResult({
|
|
386
388
|
command: "subtask.delete",
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { failResult, okResult } from "../io/output";
|
|
2
2
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
3
3
|
import { MissingBranchDatabaseError } from "../sync/branch-db";
|
|
4
|
-
import { syncPull, syncResolve, syncStatus } from "../sync/service";
|
|
5
|
-
import { type SyncResolution } from "../sync/types";
|
|
4
|
+
import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
|
|
5
|
+
import { type SyncConflictMode, type SyncResolution } from "../sync/types";
|
|
6
6
|
|
|
7
7
|
function parseOption(args: readonly string[], option: string): string | null {
|
|
8
8
|
const index: number = args.indexOf(option);
|
|
@@ -17,7 +17,7 @@ function parseOption(args: readonly string[], option: string): string | null {
|
|
|
17
17
|
function usage(message: string): CliResult {
|
|
18
18
|
return failResult({
|
|
19
19
|
command: "sync",
|
|
20
|
-
human: `${message}\nUsage: trekoon sync <status|pull|resolve> [options]`,
|
|
20
|
+
human: `${message}\nUsage: trekoon sync <status|pull|resolve|conflicts> [options]`,
|
|
21
21
|
data: { message },
|
|
22
22
|
error: {
|
|
23
23
|
code: "invalid_args",
|
|
@@ -35,6 +35,50 @@ function statusMessage(sourceBranch: string, ahead: number, behind: number, conf
|
|
|
35
35
|
].join("\n");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function formatConflictList(
|
|
39
|
+
conflicts: ReadonlyArray<{
|
|
40
|
+
id: string;
|
|
41
|
+
entity_kind: string;
|
|
42
|
+
entity_id: string;
|
|
43
|
+
field_name: string;
|
|
44
|
+
resolution: string;
|
|
45
|
+
}>,
|
|
46
|
+
): string {
|
|
47
|
+
if (conflicts.length === 0) {
|
|
48
|
+
return "No conflicts found.";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return conflicts
|
|
52
|
+
.map((conflict) =>
|
|
53
|
+
[
|
|
54
|
+
conflict.id,
|
|
55
|
+
conflict.entity_kind,
|
|
56
|
+
conflict.entity_id,
|
|
57
|
+
conflict.field_name,
|
|
58
|
+
conflict.resolution,
|
|
59
|
+
].join(" | "),
|
|
60
|
+
)
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseConflictMode(args: readonly string[]): SyncConflictMode | null {
|
|
65
|
+
const modeIndex = args.indexOf("--mode");
|
|
66
|
+
if (modeIndex < 0) {
|
|
67
|
+
return "pending";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const explicitMode = args[modeIndex + 1];
|
|
71
|
+
if (!explicitMode || explicitMode.startsWith("--")) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (explicitMode === "pending" || explicitMode === "all") {
|
|
76
|
+
return explicitMode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
38
82
|
export async function runSync(context: CliContext): Promise<CliResult> {
|
|
39
83
|
const subcommand: string | undefined = context.args[0];
|
|
40
84
|
|
|
@@ -69,6 +113,10 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
69
113
|
`Scanned events: ${summary.scannedEvents}`,
|
|
70
114
|
`Applied events: ${summary.appliedEvents}`,
|
|
71
115
|
`Created conflicts: ${summary.createdConflicts}`,
|
|
116
|
+
`Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
|
|
117
|
+
`Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
|
|
118
|
+
`Conflict events: ${summary.diagnostics.conflictEvents}`,
|
|
119
|
+
...summary.diagnostics.errorHints,
|
|
72
120
|
].join("\n"),
|
|
73
121
|
data: summary,
|
|
74
122
|
});
|
|
@@ -95,6 +143,57 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
95
143
|
});
|
|
96
144
|
}
|
|
97
145
|
|
|
146
|
+
if (subcommand === "conflicts") {
|
|
147
|
+
const conflictsCommand: string | undefined = context.args[1];
|
|
148
|
+
if (!conflictsCommand) {
|
|
149
|
+
return usage("sync conflicts requires list|show.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (conflictsCommand === "list") {
|
|
153
|
+
const mode = parseConflictMode(context.args);
|
|
154
|
+
if (!mode) {
|
|
155
|
+
return usage("sync conflicts list --mode only accepts pending|all.");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const conflicts = listSyncConflicts(context.cwd, mode);
|
|
159
|
+
|
|
160
|
+
return okResult({
|
|
161
|
+
command: "sync conflicts list",
|
|
162
|
+
human: formatConflictList(conflicts),
|
|
163
|
+
data: {
|
|
164
|
+
mode,
|
|
165
|
+
conflicts,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (conflictsCommand === "show") {
|
|
171
|
+
const conflictId: string | undefined = context.args[2];
|
|
172
|
+
if (!conflictId) {
|
|
173
|
+
return usage("sync conflicts show requires <conflict-id>.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const conflict = getSyncConflict(context.cwd, conflictId);
|
|
177
|
+
|
|
178
|
+
return okResult({
|
|
179
|
+
command: "sync conflicts show",
|
|
180
|
+
human: [
|
|
181
|
+
`Conflict: ${conflict.id}`,
|
|
182
|
+
`Entity: ${conflict.entityKind} ${conflict.entityId}`,
|
|
183
|
+
`Field: ${conflict.fieldName}`,
|
|
184
|
+
`Resolution: ${conflict.resolution}`,
|
|
185
|
+
`Ours: ${JSON.stringify(conflict.oursValue)}`,
|
|
186
|
+
`Theirs: ${JSON.stringify(conflict.theirsValue)}`,
|
|
187
|
+
].join("\n"),
|
|
188
|
+
data: {
|
|
189
|
+
conflict,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
98
197
|
return usage(`Unknown sync subcommand '${subcommand}'.`);
|
|
99
198
|
} catch (error) {
|
|
100
199
|
if (error instanceof MissingBranchDatabaseError) {
|
package/src/commands/task.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { MutationService } from "../domain/mutation-service";
|
|
4
4
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
5
|
+
import { DomainError, type TaskRecord } from "../domain/types";
|
|
5
6
|
import { formatHumanTable } from "../io/human-table";
|
|
6
7
|
import { failResult, okResult } from "../io/output";
|
|
7
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -192,6 +193,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
192
193
|
const parsed = parseArgs(context.args);
|
|
193
194
|
const subcommand: string | undefined = parsed.positional[0];
|
|
194
195
|
const domain = new TrackerDomain(database.db);
|
|
196
|
+
const mutations = new MutationService(database.db, context.cwd);
|
|
195
197
|
|
|
196
198
|
switch (subcommand) {
|
|
197
199
|
case "create": {
|
|
@@ -207,7 +209,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
207
209
|
const title: string | undefined = readOption(parsed.options, "title", "t");
|
|
208
210
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
209
211
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
210
|
-
const task =
|
|
212
|
+
const task = mutations.createTask({
|
|
211
213
|
epicId: epicId ?? "",
|
|
212
214
|
title: title ?? "",
|
|
213
215
|
description: description ?? "",
|
|
@@ -482,7 +484,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
482
484
|
|
|
483
485
|
const targets = updateAll ? [...domain.listTasks()] : ids.map((id) => domain.getTaskOrThrow(id));
|
|
484
486
|
const tasks = targets.map((target) =>
|
|
485
|
-
|
|
487
|
+
mutations.updateTask(target.id, {
|
|
486
488
|
status,
|
|
487
489
|
description: append === undefined ? undefined : appendLine(target.description, append),
|
|
488
490
|
}),
|
|
@@ -515,7 +517,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
515
517
|
append === undefined
|
|
516
518
|
? description
|
|
517
519
|
: appendLine(domain.getTaskOrThrow(taskId).description, append);
|
|
518
|
-
const task =
|
|
520
|
+
const task = mutations.updateTask(taskId, { title, description: nextDescription, status });
|
|
519
521
|
|
|
520
522
|
return okResult({
|
|
521
523
|
command: "task.update",
|
|
@@ -525,7 +527,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
525
527
|
}
|
|
526
528
|
case "delete": {
|
|
527
529
|
const taskId: string = parsed.positional[1] ?? "";
|
|
528
|
-
|
|
530
|
+
mutations.deleteTask(taskId);
|
|
529
531
|
|
|
530
532
|
return okResult({
|
|
531
533
|
command: "task.delete",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const ENTITY_OPERATIONS = {
|
|
2
|
+
epic: {
|
|
3
|
+
created: "epic.created",
|
|
4
|
+
updated: "epic.updated",
|
|
5
|
+
deleted: "epic.deleted",
|
|
6
|
+
},
|
|
7
|
+
task: {
|
|
8
|
+
created: "task.created",
|
|
9
|
+
updated: "task.updated",
|
|
10
|
+
deleted: "task.deleted",
|
|
11
|
+
},
|
|
12
|
+
subtask: {
|
|
13
|
+
created: "subtask.created",
|
|
14
|
+
updated: "subtask.updated",
|
|
15
|
+
deleted: "subtask.deleted",
|
|
16
|
+
},
|
|
17
|
+
dependency: {
|
|
18
|
+
added: "dependency.added",
|
|
19
|
+
removed: "dependency.removed",
|
|
20
|
+
},
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type MutationOperation =
|
|
24
|
+
| (typeof ENTITY_OPERATIONS)["epic"][keyof (typeof ENTITY_OPERATIONS)["epic"]]
|
|
25
|
+
| (typeof ENTITY_OPERATIONS)["task"][keyof (typeof ENTITY_OPERATIONS)["task"]]
|
|
26
|
+
| (typeof ENTITY_OPERATIONS)["subtask"][keyof (typeof ENTITY_OPERATIONS)["subtask"]]
|
|
27
|
+
| (typeof ENTITY_OPERATIONS)["dependency"][keyof (typeof ENTITY_OPERATIONS)["dependency"]];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { type Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import { appendEventWithGitContext } from "../sync/event-writes";
|
|
4
|
+
import { ENTITY_OPERATIONS } from "./mutation-operations";
|
|
5
|
+
import { TrackerDomain } from "./tracker-domain";
|
|
6
|
+
import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "./types";
|
|
7
|
+
|
|
8
|
+
export class MutationService {
|
|
9
|
+
readonly #db: Database;
|
|
10
|
+
readonly #cwd: string;
|
|
11
|
+
readonly #domain: TrackerDomain;
|
|
12
|
+
|
|
13
|
+
constructor(db: Database, cwd: string) {
|
|
14
|
+
this.#db = db;
|
|
15
|
+
this.#cwd = cwd;
|
|
16
|
+
this.#domain = new TrackerDomain(db);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
20
|
+
return this.#db.transaction((): EpicRecord => {
|
|
21
|
+
const epic = this.#domain.createEpic(input);
|
|
22
|
+
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
|
|
23
|
+
title: epic.title,
|
|
24
|
+
description: epic.description,
|
|
25
|
+
status: epic.status,
|
|
26
|
+
});
|
|
27
|
+
return epic;
|
|
28
|
+
})();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
updateEpic(
|
|
32
|
+
id: string,
|
|
33
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
34
|
+
): EpicRecord {
|
|
35
|
+
return this.#db.transaction((): EpicRecord => {
|
|
36
|
+
const epic = this.#domain.updateEpic(id, input);
|
|
37
|
+
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
38
|
+
title: epic.title,
|
|
39
|
+
description: epic.description,
|
|
40
|
+
status: epic.status,
|
|
41
|
+
});
|
|
42
|
+
return epic;
|
|
43
|
+
})();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
deleteEpic(id: string): void {
|
|
47
|
+
this.#db.transaction((): void => {
|
|
48
|
+
this.#domain.deleteEpic(id);
|
|
49
|
+
this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
|
|
54
|
+
return this.#db.transaction((): TaskRecord => {
|
|
55
|
+
const task = this.#domain.createTask(input);
|
|
56
|
+
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
57
|
+
epic_id: task.epicId,
|
|
58
|
+
title: task.title,
|
|
59
|
+
description: task.description,
|
|
60
|
+
status: task.status,
|
|
61
|
+
});
|
|
62
|
+
return task;
|
|
63
|
+
})();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
updateTask(
|
|
67
|
+
id: string,
|
|
68
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
69
|
+
): TaskRecord {
|
|
70
|
+
return this.#db.transaction((): TaskRecord => {
|
|
71
|
+
const task = this.#domain.updateTask(id, input);
|
|
72
|
+
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
73
|
+
epic_id: task.epicId,
|
|
74
|
+
title: task.title,
|
|
75
|
+
description: task.description,
|
|
76
|
+
status: task.status,
|
|
77
|
+
});
|
|
78
|
+
return task;
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
deleteTask(id: string): void {
|
|
83
|
+
this.#db.transaction((): void => {
|
|
84
|
+
this.#domain.deleteTask(id);
|
|
85
|
+
this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
|
|
86
|
+
})();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
createSubtask(input: {
|
|
90
|
+
taskId: string;
|
|
91
|
+
title: string;
|
|
92
|
+
description?: string | undefined;
|
|
93
|
+
status?: string | undefined;
|
|
94
|
+
}): SubtaskRecord {
|
|
95
|
+
return this.#db.transaction((): SubtaskRecord => {
|
|
96
|
+
const subtask = this.#domain.createSubtask(input);
|
|
97
|
+
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
|
|
98
|
+
task_id: subtask.taskId,
|
|
99
|
+
title: subtask.title,
|
|
100
|
+
description: subtask.description,
|
|
101
|
+
status: subtask.status,
|
|
102
|
+
});
|
|
103
|
+
return subtask;
|
|
104
|
+
})();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
updateSubtask(
|
|
108
|
+
id: string,
|
|
109
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
110
|
+
): SubtaskRecord {
|
|
111
|
+
return this.#db.transaction((): SubtaskRecord => {
|
|
112
|
+
const subtask = this.#domain.updateSubtask(id, input);
|
|
113
|
+
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
114
|
+
task_id: subtask.taskId,
|
|
115
|
+
title: subtask.title,
|
|
116
|
+
description: subtask.description,
|
|
117
|
+
status: subtask.status,
|
|
118
|
+
});
|
|
119
|
+
return subtask;
|
|
120
|
+
})();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deleteSubtask(id: string): void {
|
|
124
|
+
this.#db.transaction((): void => {
|
|
125
|
+
this.#domain.deleteSubtask(id);
|
|
126
|
+
this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
|
|
127
|
+
})();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
|
|
131
|
+
return this.#db.transaction((): DependencyRecord => {
|
|
132
|
+
const dependency = this.#domain.addDependency(sourceId, dependsOnId);
|
|
133
|
+
this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
|
|
134
|
+
source_id: dependency.sourceId,
|
|
135
|
+
source_kind: dependency.sourceKind,
|
|
136
|
+
depends_on_id: dependency.dependsOnId,
|
|
137
|
+
depends_on_kind: dependency.dependsOnKind,
|
|
138
|
+
});
|
|
139
|
+
return dependency;
|
|
140
|
+
})();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
144
|
+
return this.#db.transaction((): number => {
|
|
145
|
+
const removed = this.#domain.removeDependency(sourceId, dependsOnId);
|
|
146
|
+
if (removed > 0) {
|
|
147
|
+
this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
|
|
148
|
+
source_id: sourceId,
|
|
149
|
+
depends_on_id: dependsOnId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return removed;
|
|
153
|
+
})();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#appendEntityEvent(
|
|
157
|
+
entityKind: "epic" | "task" | "subtask" | "dependency",
|
|
158
|
+
entityId: string,
|
|
159
|
+
operation: string,
|
|
160
|
+
fields: Record<string, unknown>,
|
|
161
|
+
): void {
|
|
162
|
+
appendEventWithGitContext(this.#db, this.#cwd, {
|
|
163
|
+
entityKind,
|
|
164
|
+
entityId,
|
|
165
|
+
operation,
|
|
166
|
+
fields,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -278,11 +278,58 @@ function recordMigration(db: Database, migration: Migration): void {
|
|
|
278
278
|
);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
function isSchemaCurrentFastPath(db: Database, latestVersion: number): boolean {
|
|
282
|
+
if (latestVersion === 0 || !migrationTableExists(db) || !hasMigrationVersionColumn(db)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const row = db
|
|
287
|
+
.query(
|
|
288
|
+
`
|
|
289
|
+
SELECT
|
|
290
|
+
COALESCE(MIN(version), 0) AS min_version,
|
|
291
|
+
COALESCE(MAX(version), 0) AS max_version,
|
|
292
|
+
COUNT(DISTINCT version) AS distinct_versions,
|
|
293
|
+
SUM(CASE WHEN version IS NULL THEN 1 ELSE 0 END) AS null_versions
|
|
294
|
+
FROM schema_migrations;
|
|
295
|
+
`,
|
|
296
|
+
)
|
|
297
|
+
.get() as
|
|
298
|
+
| {
|
|
299
|
+
min_version: number;
|
|
300
|
+
max_version: number;
|
|
301
|
+
distinct_versions: number;
|
|
302
|
+
null_versions: number;
|
|
303
|
+
}
|
|
304
|
+
| null;
|
|
305
|
+
|
|
306
|
+
if (!row) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
row.null_versions === 0 &&
|
|
312
|
+
row.min_version === 1 &&
|
|
313
|
+
row.max_version === latestVersion &&
|
|
314
|
+
row.distinct_versions === latestVersion
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
281
318
|
export function migrateDatabase(db: Database): void {
|
|
319
|
+
validateMigrationPlan();
|
|
320
|
+
|
|
321
|
+
const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
322
|
+
|
|
323
|
+
// Fast path: avoid BEGIN EXCLUSIVE when schema is already current.
|
|
324
|
+
// This reduces startup lock contention while keeping the explicit
|
|
325
|
+
// transactional migration path for non-current/legacy schemas.
|
|
326
|
+
if (isSchemaCurrentFastPath(db, latestVersion)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
282
330
|
runExclusive(db, (): void => {
|
|
283
331
|
ensureMigrationTable(db);
|
|
284
332
|
ensureMigrationVersionColumn(db);
|
|
285
|
-
validateMigrationPlan();
|
|
286
333
|
|
|
287
334
|
const version: number = currentVersion(db);
|
|
288
335
|
|