trekoon 0.3.3 → 0.3.4
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 +51 -2
- package/README.md +152 -126
- package/docs/ai-agents.md +105 -104
- package/docs/commands.md +137 -167
- package/docs/machine-contracts.md +67 -68
- package/docs/quickstart.md +73 -148
- package/package.json +1 -1
- package/src/commands/help.ts +239 -252
- package/src/commands/quickstart.ts +67 -77
- package/src/commands/skills.ts +104 -19
- package/src/commands/sync.ts +93 -5
- package/src/domain/tracker-domain.ts +210 -37
- package/src/storage/events-retention.ts +72 -0
- package/src/storage/migrations.ts +28 -0
- package/src/sync/event-writes.ts +8 -6
- package/src/sync/service.ts +80 -52
- package/src/sync/types.ts +12 -0
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
|
|
40
40
|
const DEFAULT_STATUS = "todo";
|
|
41
41
|
const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
|
|
42
|
+
const SQLITE_MAX_VARIABLES = 999;
|
|
42
43
|
|
|
43
44
|
interface EpicRow {
|
|
44
45
|
id: string;
|
|
@@ -391,20 +392,69 @@ export class TrackerDomain {
|
|
|
391
392
|
status: normalizeStatus(spec.status),
|
|
392
393
|
}));
|
|
393
394
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
395
|
+
if (validatedSpecs.length === 0) {
|
|
396
|
+
return {
|
|
397
|
+
tasks: [],
|
|
398
|
+
result: {
|
|
399
|
+
mappings: [],
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!this.#db.inTransaction) {
|
|
405
|
+
throw new DomainError({
|
|
406
|
+
code: "invalid_state",
|
|
407
|
+
message: "createTaskBatch must be called inside a writeTransaction",
|
|
408
|
+
details: { entity: "task" },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const TASK_COLS_PER_ROW = 7; // id, epic_id, title, description, status, created_at, updated_at (version is literal 1)
|
|
413
|
+
const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / TASK_COLS_PER_ROW);
|
|
414
|
+
const batchTimestamp: number = Date.now();
|
|
398
415
|
|
|
416
|
+
const prepared: Array<{ id: string; now: number; spec: ValidatedTaskBatchSpec }> = validatedSpecs.map((spec) => ({
|
|
417
|
+
id: randomUUID(),
|
|
418
|
+
now: batchTimestamp,
|
|
419
|
+
spec,
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
for (let offset = 0; offset < prepared.length; offset += WRITE_CHUNK_SIZE) {
|
|
423
|
+
const chunk = prepared.slice(offset, offset + WRITE_CHUNK_SIZE);
|
|
424
|
+
const placeholders: string = chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, 1)").join(", ");
|
|
425
|
+
const params: Array<string | number> = [];
|
|
426
|
+
for (const item of chunk) {
|
|
427
|
+
params.push(item.id, epicId, item.spec.title, item.spec.description, item.spec.status, item.now, item.now);
|
|
428
|
+
}
|
|
399
429
|
this.#db
|
|
400
430
|
.query(
|
|
401
|
-
|
|
431
|
+
`INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES ${placeholders};`,
|
|
402
432
|
)
|
|
403
|
-
.run(
|
|
433
|
+
.run(...params);
|
|
434
|
+
}
|
|
404
435
|
|
|
405
|
-
|
|
436
|
+
const allIds: string[] = prepared.map((p) => p.id);
|
|
437
|
+
const fetchedRows: TaskRow[] = [];
|
|
438
|
+
for (let offset = 0; offset < allIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
439
|
+
const chunkIds = allIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
440
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
441
|
+
const chunkRows = this.#db
|
|
442
|
+
.query(
|
|
443
|
+
`SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id IN (${inPlaceholders});`,
|
|
444
|
+
)
|
|
445
|
+
.all(...chunkIds) as TaskRow[];
|
|
446
|
+
fetchedRows.push(...chunkRows);
|
|
406
447
|
}
|
|
407
448
|
|
|
449
|
+
const rowById = new Map<string, TaskRow>(fetchedRows.map((row) => [row.id, row]));
|
|
450
|
+
const tasks: TaskRecord[] = allIds.map((id) => {
|
|
451
|
+
const row = rowById.get(id);
|
|
452
|
+
if (!row) {
|
|
453
|
+
throw new DomainError({ code: "not_found", message: `task not found: ${id}`, details: { entity: "task", id } });
|
|
454
|
+
}
|
|
455
|
+
return mapTask(row);
|
|
456
|
+
});
|
|
457
|
+
|
|
408
458
|
return {
|
|
409
459
|
tasks,
|
|
410
460
|
result: {
|
|
@@ -504,9 +554,13 @@ export class TrackerDomain {
|
|
|
504
554
|
const defaultTaskId: string = assertNonEmpty("taskId", input.taskId);
|
|
505
555
|
this.getTaskOrThrow(defaultTaskId);
|
|
506
556
|
|
|
557
|
+
const validatedTaskIds = new Set<string>([defaultTaskId]);
|
|
507
558
|
const validatedSpecs: ValidatedSubtaskBatchSpec[] = input.specs.map((spec) => {
|
|
508
559
|
const taskId = spec.parent.kind === "id" ? assertNonEmpty("taskId", spec.parent.id) : defaultTaskId;
|
|
509
|
-
|
|
560
|
+
if (!validatedTaskIds.has(taskId)) {
|
|
561
|
+
this.getTaskOrThrow(taskId);
|
|
562
|
+
validatedTaskIds.add(taskId);
|
|
563
|
+
}
|
|
510
564
|
|
|
511
565
|
return {
|
|
512
566
|
tempKey: assertNonEmpty("tempKey", spec.tempKey),
|
|
@@ -517,20 +571,73 @@ export class TrackerDomain {
|
|
|
517
571
|
};
|
|
518
572
|
});
|
|
519
573
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
574
|
+
if (validatedSpecs.length === 0) {
|
|
575
|
+
return {
|
|
576
|
+
subtasks: [],
|
|
577
|
+
result: {
|
|
578
|
+
mappings: [],
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!this.#db.inTransaction) {
|
|
584
|
+
throw new DomainError({
|
|
585
|
+
code: "invalid_state",
|
|
586
|
+
message: "createSubtaskBatch must be called inside a writeTransaction",
|
|
587
|
+
details: { entity: "subtask" },
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const SUBTASK_COLS_PER_ROW = 7; // id, task_id, title, description, status, created_at, updated_at (version is literal 1)
|
|
592
|
+
const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / SUBTASK_COLS_PER_ROW);
|
|
593
|
+
const batchTimestamp: number = Date.now();
|
|
594
|
+
|
|
595
|
+
const prepared: Array<{ id: string; now: number; spec: ValidatedSubtaskBatchSpec }> = validatedSpecs.map((spec) => ({
|
|
596
|
+
id: randomUUID(),
|
|
597
|
+
now: batchTimestamp,
|
|
598
|
+
spec,
|
|
599
|
+
}));
|
|
524
600
|
|
|
601
|
+
for (let offset = 0; offset < prepared.length; offset += WRITE_CHUNK_SIZE) {
|
|
602
|
+
const chunk = prepared.slice(offset, offset + WRITE_CHUNK_SIZE);
|
|
603
|
+
const placeholders: string = chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, 1)").join(", ");
|
|
604
|
+
const params: Array<string | number> = [];
|
|
605
|
+
for (const item of chunk) {
|
|
606
|
+
params.push(item.id, item.spec.taskId, item.spec.title, item.spec.description, item.spec.status, item.now, item.now);
|
|
607
|
+
}
|
|
525
608
|
this.#db
|
|
526
609
|
.query(
|
|
527
|
-
|
|
610
|
+
`INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES ${placeholders};`,
|
|
528
611
|
)
|
|
529
|
-
.run(
|
|
612
|
+
.run(...params);
|
|
613
|
+
}
|
|
530
614
|
|
|
531
|
-
|
|
615
|
+
const allIds: string[] = prepared.map((p) => p.id);
|
|
616
|
+
const fetchedRows: SubtaskRow[] = [];
|
|
617
|
+
for (let offset = 0; offset < allIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
618
|
+
const chunkIds = allIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
619
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
620
|
+
const chunkRows = this.#db
|
|
621
|
+
.query(
|
|
622
|
+
`SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id IN (${inPlaceholders});`,
|
|
623
|
+
)
|
|
624
|
+
.all(...chunkIds) as SubtaskRow[];
|
|
625
|
+
fetchedRows.push(...chunkRows);
|
|
532
626
|
}
|
|
533
627
|
|
|
628
|
+
const rowById = new Map<string, SubtaskRow>(fetchedRows.map((row) => [row.id, row]));
|
|
629
|
+
const subtasks: SubtaskRecord[] = allIds.map((id) => {
|
|
630
|
+
const row = rowById.get(id);
|
|
631
|
+
if (!row) {
|
|
632
|
+
throw new DomainError({
|
|
633
|
+
code: "not_found",
|
|
634
|
+
message: `subtask not found: ${id}`,
|
|
635
|
+
details: { entity: "subtask", id },
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return mapSubtask(row);
|
|
639
|
+
});
|
|
640
|
+
|
|
534
641
|
return {
|
|
535
642
|
subtasks,
|
|
536
643
|
result: {
|
|
@@ -930,24 +1037,62 @@ export class TrackerDomain {
|
|
|
930
1037
|
});
|
|
931
1038
|
}
|
|
932
1039
|
|
|
1040
|
+
// Batch-fetch all existing edges with a single prepared statement instead
|
|
1041
|
+
// of one #getDependencyByEdge call per spec (eliminates N+1 pattern).
|
|
1042
|
+
const edgeLookupStmt = this.#db.query(
|
|
1043
|
+
"SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
|
|
1044
|
+
);
|
|
1045
|
+
const existingEdgeMap = new Map<string, DependencyRecord>();
|
|
1046
|
+
for (const spec of resolvedSpecs) {
|
|
1047
|
+
const edgeKey = `${spec.sourceId}\0${spec.dependsOnId}`;
|
|
1048
|
+
if (!existingEdgeMap.has(edgeKey)) {
|
|
1049
|
+
const row = edgeLookupStmt.get(spec.sourceId, spec.dependsOnId) as DependencyRow | null;
|
|
1050
|
+
if (row) {
|
|
1051
|
+
existingEdgeMap.set(edgeKey, mapDependency(row));
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
933
1056
|
const dependencies: DependencyRecord[] = [];
|
|
1057
|
+
const newIds: string[] = [];
|
|
1058
|
+
const batchNow: number = Date.now();
|
|
1059
|
+
|
|
934
1060
|
for (const spec of resolvedSpecs) {
|
|
935
|
-
const
|
|
1061
|
+
const edgeKey = `${spec.sourceId}\0${spec.dependsOnId}`;
|
|
1062
|
+
const existing = existingEdgeMap.get(edgeKey);
|
|
936
1063
|
if (existing) {
|
|
937
1064
|
dependencies.push(existing);
|
|
938
1065
|
continue;
|
|
939
1066
|
}
|
|
940
1067
|
|
|
941
1068
|
const id: string = randomUUID();
|
|
942
|
-
const now: number = Date.now();
|
|
943
1069
|
|
|
944
1070
|
this.#db
|
|
945
1071
|
.query(
|
|
946
1072
|
"INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
|
|
947
1073
|
)
|
|
948
|
-
.run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind,
|
|
1074
|
+
.run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, batchNow, batchNow);
|
|
949
1075
|
|
|
950
|
-
|
|
1076
|
+
newIds.push(id);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Batch-fetch all newly inserted dependencies instead of one getDependencyOrThrow per row.
|
|
1080
|
+
for (let offset = 0; offset < newIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
1081
|
+
const chunkIds = newIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
1082
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
1083
|
+
const rows = this.#db
|
|
1084
|
+
.query(
|
|
1085
|
+
`SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE id IN (${inPlaceholders});`,
|
|
1086
|
+
)
|
|
1087
|
+
.all(...chunkIds) as DependencyRow[];
|
|
1088
|
+
const rowMap = new Map(rows.map((row) => [row.id, row]));
|
|
1089
|
+
for (const id of chunkIds) {
|
|
1090
|
+
const row = rowMap.get(id);
|
|
1091
|
+
if (!row) {
|
|
1092
|
+
throw new DomainError({ code: "not_found", message: `dependency not found: ${id}`, details: { entity: "dependency", id } });
|
|
1093
|
+
}
|
|
1094
|
+
dependencies.push(mapDependency(row));
|
|
1095
|
+
}
|
|
951
1096
|
}
|
|
952
1097
|
|
|
953
1098
|
return {
|
|
@@ -1602,36 +1747,64 @@ export class TrackerDomain {
|
|
|
1602
1747
|
return [];
|
|
1603
1748
|
}
|
|
1604
1749
|
|
|
1605
|
-
|
|
1750
|
+
// Collect all dependency-eligible change IDs upfront.
|
|
1751
|
+
const eligibleIds: string[] = [];
|
|
1606
1752
|
for (const change of changes) {
|
|
1607
|
-
if (change.kind
|
|
1608
|
-
|
|
1753
|
+
if (change.kind === "task" || change.kind === "subtask") {
|
|
1754
|
+
eligibleIds.push(change.id);
|
|
1609
1755
|
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (eligibleIds.length === 0) {
|
|
1759
|
+
return [];
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Batch-fetch all dependency rows with their target statuses using a
|
|
1763
|
+
// chunked IN query with JOINs. This replaces the previous per-ID
|
|
1764
|
+
// prepared statement approach, reducing N queries to ceil(N/999).
|
|
1765
|
+
type DepStatusRow = {
|
|
1766
|
+
source_id: string;
|
|
1767
|
+
source_kind: "task" | "subtask";
|
|
1768
|
+
depends_on_id: string;
|
|
1769
|
+
depends_on_kind: "task" | "subtask";
|
|
1770
|
+
dep_status: string | null;
|
|
1771
|
+
};
|
|
1610
1772
|
|
|
1611
|
-
|
|
1612
|
-
const dependencyNode =
|
|
1613
|
-
dependency.dependsOnKind === "task"
|
|
1614
|
-
? this.getTask(dependency.dependsOnId)
|
|
1615
|
-
: this.getSubtask(dependency.dependsOnId);
|
|
1773
|
+
const blockers: StatusCascadeBlocker[] = [];
|
|
1616
1774
|
|
|
1775
|
+
for (let offset = 0; offset < eligibleIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
1776
|
+
const chunkIds = eligibleIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
1777
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
1778
|
+
const rows = this.#db
|
|
1779
|
+
.query(
|
|
1780
|
+
`SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
|
|
1781
|
+
COALESCE(t.status, s.status) AS dep_status
|
|
1782
|
+
FROM dependencies d
|
|
1783
|
+
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
1784
|
+
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
1785
|
+
WHERE d.source_id IN (${inPlaceholders})
|
|
1786
|
+
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
1787
|
+
)
|
|
1788
|
+
.all(...chunkIds) as DepStatusRow[];
|
|
1789
|
+
|
|
1790
|
+
for (const row of rows) {
|
|
1617
1791
|
// Skip orphaned dependency rows where the referenced node no longer exists.
|
|
1618
|
-
if (
|
|
1792
|
+
if (row.dep_status === null) {
|
|
1619
1793
|
continue;
|
|
1620
1794
|
}
|
|
1621
1795
|
|
|
1622
|
-
const
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
if (dependencyStatus === "done" || willCascade) {
|
|
1796
|
+
const inScope = scopeIdSet.has(row.depends_on_id);
|
|
1797
|
+
const willCascade = targetStatus === "done" && changedIdSet.has(row.depends_on_id);
|
|
1798
|
+
if (row.dep_status === "done" || willCascade) {
|
|
1626
1799
|
continue;
|
|
1627
1800
|
}
|
|
1628
1801
|
|
|
1629
1802
|
blockers.push({
|
|
1630
|
-
sourceId:
|
|
1631
|
-
sourceKind:
|
|
1632
|
-
dependsOnId:
|
|
1633
|
-
dependsOnKind:
|
|
1634
|
-
dependsOnStatus:
|
|
1803
|
+
sourceId: row.source_id,
|
|
1804
|
+
sourceKind: row.source_kind,
|
|
1805
|
+
dependsOnId: row.depends_on_id,
|
|
1806
|
+
dependsOnKind: row.depends_on_kind,
|
|
1807
|
+
dependsOnStatus: row.dep_status,
|
|
1635
1808
|
inScope,
|
|
1636
1809
|
willCascade,
|
|
1637
1810
|
});
|
|
@@ -3,6 +3,7 @@ import { type Database } from "bun:sqlite";
|
|
|
3
3
|
import { writeTransaction } from "./database";
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_EVENT_RETENTION_DAYS = 90;
|
|
6
|
+
export const DEFAULT_CONFLICT_RETENTION_DAYS = 30;
|
|
6
7
|
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
|
|
7
8
|
|
|
8
9
|
export interface EventPruneOptions {
|
|
@@ -92,6 +93,20 @@ function countStaleCursors(db: Database): number {
|
|
|
92
93
|
return row?.count ?? 0;
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
export interface ConflictPruneOptions {
|
|
97
|
+
readonly retentionDays?: number;
|
|
98
|
+
readonly dryRun?: boolean;
|
|
99
|
+
readonly now?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ConflictPruneSummary {
|
|
103
|
+
readonly retentionDays: number;
|
|
104
|
+
readonly cutoffTimestamp: number;
|
|
105
|
+
readonly dryRun: boolean;
|
|
106
|
+
readonly candidateCount: number;
|
|
107
|
+
readonly deletedCount: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
|
|
96
111
|
const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
|
|
97
112
|
const dryRun: boolean = options.dryRun ?? false;
|
|
@@ -185,3 +200,60 @@ export function pruneEvents(db: Database, options: EventPruneOptions = {}): Even
|
|
|
185
200
|
};
|
|
186
201
|
});
|
|
187
202
|
}
|
|
203
|
+
|
|
204
|
+
export function pruneResolvedConflicts(db: Database, options: ConflictPruneOptions = {}): ConflictPruneSummary {
|
|
205
|
+
const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_CONFLICT_RETENTION_DAYS);
|
|
206
|
+
const dryRun: boolean = options.dryRun ?? false;
|
|
207
|
+
const now: number = options.now ?? Date.now();
|
|
208
|
+
const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
|
|
209
|
+
|
|
210
|
+
if (dryRun) {
|
|
211
|
+
const candidateRow = db
|
|
212
|
+
.query(
|
|
213
|
+
"SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
|
|
214
|
+
)
|
|
215
|
+
.get(cutoffTimestamp) as { count: number } | null;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
retentionDays,
|
|
219
|
+
cutoffTimestamp,
|
|
220
|
+
dryRun,
|
|
221
|
+
candidateCount: candidateRow?.count ?? 0,
|
|
222
|
+
deletedCount: 0,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return writeTransaction(db, (): ConflictPruneSummary => {
|
|
227
|
+
const candidateRow = db
|
|
228
|
+
.query(
|
|
229
|
+
"SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
|
|
230
|
+
)
|
|
231
|
+
.get(cutoffTimestamp) as { count: number } | null;
|
|
232
|
+
|
|
233
|
+
const candidateCount: number = candidateRow?.count ?? 0;
|
|
234
|
+
|
|
235
|
+
if (candidateCount === 0) {
|
|
236
|
+
return {
|
|
237
|
+
retentionDays,
|
|
238
|
+
cutoffTimestamp,
|
|
239
|
+
dryRun,
|
|
240
|
+
candidateCount,
|
|
241
|
+
deletedCount: 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const deleted = db
|
|
246
|
+
.query(
|
|
247
|
+
"DELETE FROM sync_conflicts WHERE resolution != 'pending' AND updated_at < ?;",
|
|
248
|
+
)
|
|
249
|
+
.run(cutoffTimestamp);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
retentionDays,
|
|
253
|
+
cutoffTimestamp,
|
|
254
|
+
dryRun,
|
|
255
|
+
candidateCount,
|
|
256
|
+
deletedCount: deleted.changes,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -58,6 +58,20 @@ const EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
|
|
|
58
58
|
"DROP TABLE IF EXISTS event_archive;",
|
|
59
59
|
];
|
|
60
60
|
|
|
61
|
+
const LOOKUP_INDEX_MIGRATION_UP_STATEMENTS: readonly string[] = [
|
|
62
|
+
"CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_kind ON dependencies(depends_on_id, depends_on_kind);",
|
|
63
|
+
"CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);",
|
|
64
|
+
"CREATE INDEX IF NOT EXISTS idx_subtasks_owner ON subtasks(owner);",
|
|
65
|
+
"CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_updated_at ON sync_conflicts(resolution, updated_at);",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const LOOKUP_INDEX_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
|
|
69
|
+
"DROP INDEX IF EXISTS idx_conflicts_resolution_updated_at;",
|
|
70
|
+
"DROP INDEX IF EXISTS idx_subtasks_owner;",
|
|
71
|
+
"DROP INDEX IF EXISTS idx_tasks_owner;",
|
|
72
|
+
"DROP INDEX IF EXISTS idx_dependencies_depends_on_kind;",
|
|
73
|
+
];
|
|
74
|
+
|
|
61
75
|
function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
|
|
62
76
|
const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
|
|
63
77
|
return columns.some((column) => column.name === columnName);
|
|
@@ -237,6 +251,20 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
237
251
|
);
|
|
238
252
|
},
|
|
239
253
|
},
|
|
254
|
+
{
|
|
255
|
+
version: 7,
|
|
256
|
+
name: "0007_add_lookup_indexes",
|
|
257
|
+
up(db: Database): void {
|
|
258
|
+
for (const statement of LOOKUP_INDEX_MIGRATION_UP_STATEMENTS) {
|
|
259
|
+
db.exec(statement);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
down(db: Database): void {
|
|
263
|
+
for (const statement of LOOKUP_INDEX_MIGRATION_DOWN_STATEMENTS) {
|
|
264
|
+
db.exec(statement);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
},
|
|
240
268
|
];
|
|
241
269
|
|
|
242
270
|
function migrationTableExists(db: Database): boolean {
|
package/src/sync/event-writes.ts
CHANGED
|
@@ -11,7 +11,7 @@ interface EventRecordInput {
|
|
|
11
11
|
readonly fields: Record<string, unknown>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function nextEventTimestamp(db: Database): number {
|
|
14
|
+
export function nextEventTimestamp(db: Database): number {
|
|
15
15
|
const now: number = Date.now();
|
|
16
16
|
const latestEvent = db
|
|
17
17
|
.query(
|
|
@@ -31,12 +31,16 @@ function nextEventTimestamp(db: Database): number {
|
|
|
31
31
|
return Math.max(now, latestEvent.created_at + 1);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/** Append a single event to the events table with git context. Returns void (event ID is not exposed). */
|
|
35
|
+
export function appendEventWithGitContext(
|
|
36
|
+
db: Database,
|
|
37
|
+
cwd: string,
|
|
38
|
+
input: EventRecordInput,
|
|
39
|
+
): void {
|
|
35
40
|
const git = resolveGitContext(cwd);
|
|
36
41
|
persistGitContext(db, git);
|
|
37
42
|
|
|
38
43
|
const now: number = nextEventTimestamp(db);
|
|
39
|
-
const eventId: string = randomUUID();
|
|
40
44
|
|
|
41
45
|
db.query(
|
|
42
46
|
`
|
|
@@ -54,7 +58,7 @@ export function appendEventWithGitContext(db: Database, cwd: string, input: Even
|
|
|
54
58
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
|
|
55
59
|
`,
|
|
56
60
|
).run(
|
|
57
|
-
|
|
61
|
+
randomUUID(),
|
|
58
62
|
input.entityKind,
|
|
59
63
|
input.entityId,
|
|
60
64
|
input.operation,
|
|
@@ -64,6 +68,4 @@ export function appendEventWithGitContext(db: Database, cwd: string, input: Even
|
|
|
64
68
|
now,
|
|
65
69
|
now,
|
|
66
70
|
);
|
|
67
|
-
|
|
68
|
-
return eventId;
|
|
69
71
|
}
|