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.
@@ -1,7 +1,8 @@
1
1
  import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
2
 
3
- import { DomainError, type SubtaskRecord } from "../domain/types";
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 = domain.createSubtask({
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
- domain.updateSubtask(target.id, {
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 = domain.updateSubtask(subtaskId, { title, description: nextDescription, status });
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
- domain.deleteSubtask(subtaskId);
385
+ mutations.deleteSubtask(subtaskId);
384
386
 
385
387
  return okResult({
386
388
  command: "subtask.delete",
@@ -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) {
@@ -1,7 +1,8 @@
1
1
  import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
2
 
3
- import { DomainError, type TaskRecord } from "../domain/types";
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 = domain.createTask({
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
- domain.updateTask(target.id, {
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 = domain.updateTask(taskId, { title, description: nextDescription, status });
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
- domain.deleteTask(taskId);
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