trekoon 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +40 -14
- package/docs/ai-agents.md +1 -0
- package/docs/commands.md +18 -0
- package/docs/quickstart.md +35 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/epic.ts +84 -1
- package/src/commands/help.ts +19 -1
- package/src/commands/quickstart.ts +13 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/export/build-epic-export-bundle.ts +178 -0
- package/src/export/path.ts +48 -0
- package/src/export/render-markdown.ts +256 -0
- package/src/export/types.ts +61 -0
- package/src/export/write.ts +97 -0
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ExportBundle, ExportExternalNode, ExportStatusCounts, ExportWarning } from "./types";
|
|
2
|
+
import type { SubtaskRecord, TaskRecord } from "../domain/types";
|
|
3
|
+
|
|
4
|
+
export function renderMarkdown(bundle: ExportBundle): string {
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
|
|
7
|
+
renderFrontmatter(lines, bundle);
|
|
8
|
+
renderTitle(lines, bundle);
|
|
9
|
+
renderSummaryTable(lines, bundle);
|
|
10
|
+
renderDescription(lines, bundle);
|
|
11
|
+
renderTaskIndex(lines, bundle);
|
|
12
|
+
renderTaskDetails(lines, bundle);
|
|
13
|
+
renderDependencies(lines, bundle);
|
|
14
|
+
renderExternalNodes(lines, bundle);
|
|
15
|
+
renderWarnings(lines, bundle);
|
|
16
|
+
renderFooter(lines, bundle);
|
|
17
|
+
|
|
18
|
+
return lines.join("\n") + "\n";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Markdown escaping helpers ---
|
|
22
|
+
|
|
23
|
+
function escapeTableCell(text: string): string {
|
|
24
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeHeading(text: string): string {
|
|
28
|
+
return text.replace(/\n/g, " ").replace(/#+\s*/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function escapeInlineText(text: string): string {
|
|
32
|
+
return text.replace(/([[\]`*_~])/g, "\\$1").replace(/\n/g, " ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeBlockText(text: string): string {
|
|
36
|
+
// Descriptions are rendered as block content; preserve newlines but
|
|
37
|
+
// ensure lines that start with Markdown structural characters are safe.
|
|
38
|
+
return text
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((line) => {
|
|
41
|
+
if (/^#{1,6}\s/.test(line)) return `\\${line}`;
|
|
42
|
+
if (/^(\s*[-*+]|\s*\d+\.)\s/.test(line)) return line;
|
|
43
|
+
if (/^\s*\|/.test(line)) return `\\${line}`;
|
|
44
|
+
return line;
|
|
45
|
+
})
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatDescriptionIndented(text: string, indent: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => `${indent}${line}`)
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Render sections ---
|
|
57
|
+
|
|
58
|
+
function renderFrontmatter(lines: string[], bundle: ExportBundle): void {
|
|
59
|
+
lines.push("---");
|
|
60
|
+
lines.push(`epic_id: ${bundle.epic.id}`);
|
|
61
|
+
lines.push(`schema_version: ${bundle.schemaVersion}`);
|
|
62
|
+
lines.push(`exported_at: ${new Date(bundle.exportedAt).toISOString()}`);
|
|
63
|
+
lines.push(`status: ${bundle.epic.status}`);
|
|
64
|
+
lines.push("---");
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderTitle(lines: string[], bundle: ExportBundle): void {
|
|
69
|
+
lines.push(`# ${escapeHeading(bundle.epic.title)}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderSummaryTable(lines: string[], bundle: ExportBundle): void {
|
|
74
|
+
lines.push("## Summary");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("| Metric | Count |");
|
|
77
|
+
lines.push("|--------|-------|");
|
|
78
|
+
lines.push(`| Tasks | ${bundle.summary.taskCount} |`);
|
|
79
|
+
lines.push(`| Subtasks | ${bundle.summary.subtaskCount} |`);
|
|
80
|
+
lines.push(`| Dependencies | ${bundle.summary.dependencyCount} |`);
|
|
81
|
+
lines.push(`| External nodes | ${bundle.summary.externalNodeCount} |`);
|
|
82
|
+
lines.push(`| Warnings | ${bundle.summary.warningCount} |`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
|
|
85
|
+
if (bundle.summary.taskCount > 0) {
|
|
86
|
+
lines.push("### Task status breakdown");
|
|
87
|
+
lines.push("");
|
|
88
|
+
renderStatusTable(lines, bundle.summary.taskStatuses);
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (bundle.summary.subtaskCount > 0) {
|
|
93
|
+
lines.push("### Subtask status breakdown");
|
|
94
|
+
lines.push("");
|
|
95
|
+
renderStatusTable(lines, bundle.summary.subtaskStatuses);
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderStatusTable(lines: string[], counts: ExportStatusCounts): void {
|
|
101
|
+
lines.push("| Status | Count |");
|
|
102
|
+
lines.push("|--------|-------|");
|
|
103
|
+
if (counts.todo > 0) lines.push(`| todo | ${counts.todo} |`);
|
|
104
|
+
if (counts.inProgress > 0) lines.push(`| in_progress | ${counts.inProgress} |`);
|
|
105
|
+
if (counts.done > 0) lines.push(`| done | ${counts.done} |`);
|
|
106
|
+
if (counts.blocked > 0) lines.push(`| blocked | ${counts.blocked} |`);
|
|
107
|
+
if (counts.other > 0) lines.push(`| other | ${counts.other} |`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderDescription(lines: string[], bundle: ExportBundle): void {
|
|
111
|
+
if (!bundle.epic.description) return;
|
|
112
|
+
|
|
113
|
+
lines.push("## Description");
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(escapeBlockText(bundle.epic.description));
|
|
116
|
+
lines.push("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderTaskIndex(lines: string[], bundle: ExportBundle): void {
|
|
120
|
+
if (bundle.tasks.length === 0) return;
|
|
121
|
+
|
|
122
|
+
lines.push("## Task index");
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("| # | Title | Status | Subtasks |");
|
|
125
|
+
lines.push("|---|-------|--------|----------|");
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < bundle.tasks.length; i++) {
|
|
128
|
+
const task = bundle.tasks[i];
|
|
129
|
+
const subtaskCount = bundle.subtasks.filter((s) => s.taskId === task.id).length;
|
|
130
|
+
const anchor = taskAnchor(task);
|
|
131
|
+
lines.push(`| ${i + 1} | [${escapeTableCell(escapeInlineText(task.title))}](#${anchor}) | ${task.status} | ${subtaskCount} |`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderTaskDetails(lines: string[], bundle: ExportBundle): void {
|
|
137
|
+
if (bundle.tasks.length === 0) return;
|
|
138
|
+
|
|
139
|
+
lines.push("## Tasks");
|
|
140
|
+
lines.push("");
|
|
141
|
+
|
|
142
|
+
for (const task of bundle.tasks) {
|
|
143
|
+
renderSingleTask(lines, task, bundle);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderSingleTask(lines: string[], task: TaskRecord, bundle: ExportBundle): void {
|
|
148
|
+
lines.push(`### ${escapeHeading(task.title)}`);
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(`**ID:** \`${task.id}\` `);
|
|
151
|
+
lines.push(`**Status:** ${task.status} `);
|
|
152
|
+
if (task.owner) {
|
|
153
|
+
lines.push(`**Owner:** ${escapeInlineText(task.owner)} `);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
|
|
157
|
+
if (task.description) {
|
|
158
|
+
lines.push(escapeBlockText(task.description));
|
|
159
|
+
lines.push("");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const blockedBy = bundle.blockedBy.get(task.id) ?? [];
|
|
163
|
+
if (blockedBy.length > 0) {
|
|
164
|
+
lines.push("**Blocked by:**");
|
|
165
|
+
for (const depId of blockedBy) {
|
|
166
|
+
lines.push(`- \`${depId}\``);
|
|
167
|
+
}
|
|
168
|
+
lines.push("");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const blocks = bundle.blocks.get(task.id) ?? [];
|
|
172
|
+
if (blocks.length > 0) {
|
|
173
|
+
lines.push("**Blocks:**");
|
|
174
|
+
for (const depId of blocks) {
|
|
175
|
+
lines.push(`- \`${depId}\``);
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const subtasks = bundle.subtasks.filter((s) => s.taskId === task.id);
|
|
181
|
+
if (subtasks.length > 0) {
|
|
182
|
+
lines.push("#### Subtasks");
|
|
183
|
+
lines.push("");
|
|
184
|
+
for (const subtask of subtasks) {
|
|
185
|
+
renderSingleSubtask(lines, subtask, bundle);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderSingleSubtask(lines: string[], subtask: SubtaskRecord, bundle: ExportBundle): void {
|
|
191
|
+
const statusIcon = subtask.status === "done" ? "x" : " ";
|
|
192
|
+
lines.push(`- [${statusIcon}] **${escapeInlineText(subtask.title)}** — \`${subtask.id}\` (${subtask.status})`);
|
|
193
|
+
|
|
194
|
+
if (subtask.description) {
|
|
195
|
+
lines.push(formatDescriptionIndented(escapeBlockText(subtask.description), " "));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const blockedBy = bundle.blockedBy.get(subtask.id) ?? [];
|
|
199
|
+
if (blockedBy.length > 0) {
|
|
200
|
+
lines.push(` Blocked by: ${blockedBy.map((id) => `\`${id}\``).join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderDependencies(lines: string[], bundle: ExportBundle): void {
|
|
205
|
+
if (bundle.dependencies.length === 0) return;
|
|
206
|
+
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("## Dependencies");
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push("| Source | Depends on | Type |");
|
|
211
|
+
lines.push("|--------|------------|------|");
|
|
212
|
+
|
|
213
|
+
for (const dep of bundle.dependencies) {
|
|
214
|
+
const type = dep.internal ? "internal" : "external";
|
|
215
|
+
lines.push(`| \`${dep.sourceId}\` (${dep.sourceKind}) | \`${dep.dependsOnId}\` (${dep.dependsOnKind}) | ${type} |`);
|
|
216
|
+
}
|
|
217
|
+
lines.push("");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderExternalNodes(lines: string[], bundle: ExportBundle): void {
|
|
221
|
+
if (bundle.externalNodes.length === 0) return;
|
|
222
|
+
|
|
223
|
+
lines.push("## External nodes");
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("These nodes belong to other epics but are referenced by dependencies in this epic.");
|
|
226
|
+
lines.push("");
|
|
227
|
+
lines.push("| ID | Kind | Title | Status | Epic ID |");
|
|
228
|
+
lines.push("|----|------|-------|--------|---------|");
|
|
229
|
+
|
|
230
|
+
for (const node of bundle.externalNodes) {
|
|
231
|
+
const title = node.title !== null ? escapeTableCell(node.title) : "—";
|
|
232
|
+
lines.push(`| \`${node.id}\` | ${node.kind} | ${title} | ${node.status ?? "—"} | ${node.epicId ?? "—"} |`);
|
|
233
|
+
}
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderWarnings(lines: string[], bundle: ExportBundle): void {
|
|
238
|
+
if (bundle.warnings.length === 0) return;
|
|
239
|
+
|
|
240
|
+
lines.push("## Warnings");
|
|
241
|
+
lines.push("");
|
|
242
|
+
for (const warning of bundle.warnings) {
|
|
243
|
+
lines.push(`- **${escapeInlineText(warning.code)}**: ${escapeInlineText(warning.message)}`);
|
|
244
|
+
}
|
|
245
|
+
lines.push("");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderFooter(lines: string[], bundle: ExportBundle): void {
|
|
249
|
+
lines.push("---");
|
|
250
|
+
lines.push("");
|
|
251
|
+
lines.push(`*Exported from Trekoon on ${new Date(bundle.exportedAt).toISOString()}. This is a snapshot — the database is the source of truth.*`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function taskAnchor(task: TaskRecord): string {
|
|
255
|
+
return `task-${task.id}`;
|
|
256
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DependencyRecord, EpicRecord, SubtaskRecord, TaskRecord } from "../domain/types";
|
|
2
|
+
|
|
3
|
+
export const EXPORT_SCHEMA_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
export type ExportNodeKind = "task" | "subtask";
|
|
6
|
+
|
|
7
|
+
export interface ExportDependencyEdge {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly sourceId: string;
|
|
10
|
+
readonly sourceKind: ExportNodeKind;
|
|
11
|
+
readonly dependsOnId: string;
|
|
12
|
+
readonly dependsOnKind: ExportNodeKind;
|
|
13
|
+
readonly internal: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExportExternalNode {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly kind: ExportNodeKind;
|
|
19
|
+
readonly title: string | null;
|
|
20
|
+
readonly status: string | null;
|
|
21
|
+
readonly epicId: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExportWarning {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly entityId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExportStatusCounts {
|
|
31
|
+
readonly total: number;
|
|
32
|
+
readonly todo: number;
|
|
33
|
+
readonly inProgress: number;
|
|
34
|
+
readonly done: number;
|
|
35
|
+
readonly blocked: number;
|
|
36
|
+
readonly other: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExportSummary {
|
|
40
|
+
readonly taskCount: number;
|
|
41
|
+
readonly subtaskCount: number;
|
|
42
|
+
readonly dependencyCount: number;
|
|
43
|
+
readonly externalNodeCount: number;
|
|
44
|
+
readonly warningCount: number;
|
|
45
|
+
readonly taskStatuses: ExportStatusCounts;
|
|
46
|
+
readonly subtaskStatuses: ExportStatusCounts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ExportBundle {
|
|
50
|
+
readonly schemaVersion: number;
|
|
51
|
+
readonly exportedAt: number;
|
|
52
|
+
readonly epic: EpicRecord;
|
|
53
|
+
readonly tasks: readonly TaskRecord[];
|
|
54
|
+
readonly subtasks: readonly SubtaskRecord[];
|
|
55
|
+
readonly dependencies: readonly ExportDependencyEdge[];
|
|
56
|
+
readonly externalNodes: readonly ExportExternalNode[];
|
|
57
|
+
readonly blockedBy: ReadonlyMap<string, readonly string[]>;
|
|
58
|
+
readonly blocks: ReadonlyMap<string, readonly string[]>;
|
|
59
|
+
readonly warnings: readonly ExportWarning[];
|
|
60
|
+
readonly summary: ExportSummary;
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdirSync, openSync, renameSync, unlinkSync, writeSync, closeSync, constants } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export interface WriteResult {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly overwritten: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function atomicWrite(options: {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly content: string;
|
|
13
|
+
readonly overwrite: boolean;
|
|
14
|
+
}): WriteResult {
|
|
15
|
+
const dir = dirname(options.path);
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
if (options.overwrite) {
|
|
19
|
+
return writeViaTempRename(options.path, options.content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return writeExclusive(options.path, options.content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeViaTempRename(targetPath: string, content: string): WriteResult {
|
|
26
|
+
const dir = dirname(targetPath);
|
|
27
|
+
const tempPath = resolve(dir, `.export-${randomUUID()}.tmp`);
|
|
28
|
+
let overwritten = false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Probe whether the target already exists by attempting an exclusive open.
|
|
32
|
+
// If it succeeds, the file didn't exist, so we close and remove that probe
|
|
33
|
+
// since we'll write via temp+rename anyway for atomicity.
|
|
34
|
+
try {
|
|
35
|
+
const probeFd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
36
|
+
closeSync(probeFd);
|
|
37
|
+
unlinkSync(targetPath);
|
|
38
|
+
overwritten = false;
|
|
39
|
+
} catch {
|
|
40
|
+
overwritten = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC);
|
|
44
|
+
try {
|
|
45
|
+
writeSync(fd, content);
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
renameSync(tempPath, targetPath);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
try { unlinkSync(tempPath); } catch { /* best-effort cleanup */ }
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { path: targetPath, overwritten };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeExclusive(targetPath: string, content: string): WriteResult {
|
|
59
|
+
// O_CREAT | O_EXCL is atomic: the kernel fails if the file already exists.
|
|
60
|
+
let fd: number;
|
|
61
|
+
try {
|
|
62
|
+
fd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
if (isFileExistsError(error)) {
|
|
65
|
+
throw new ExportWriteError(
|
|
66
|
+
`File already exists: ${targetPath}. Use --overwrite to resave.`,
|
|
67
|
+
"file_exists",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
writeSync(fd, content);
|
|
75
|
+
} finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { path: targetPath, overwritten: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isFileExistsError(error: unknown): boolean {
|
|
83
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
84
|
+
return (error as { code: string }).code === "EEXIST";
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ExportWriteError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
|
|
92
|
+
constructor(message: string, code: string) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "ExportWriteError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -105,6 +105,14 @@ const BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
|
|
|
105
105
|
"DROP TABLE IF EXISTS board_idempotency_keys;",
|
|
106
106
|
];
|
|
107
107
|
|
|
108
|
+
const BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS: readonly string[] = [
|
|
109
|
+
"CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
|
|
113
|
+
"DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
|
|
114
|
+
];
|
|
115
|
+
|
|
108
116
|
function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
|
|
109
117
|
const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
|
|
110
118
|
return columns.some((column) => column.name === columnName);
|
|
@@ -359,6 +367,20 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
359
367
|
}
|
|
360
368
|
},
|
|
361
369
|
},
|
|
370
|
+
{
|
|
371
|
+
version: 10,
|
|
372
|
+
name: "0010_board_idempotency_retention_index",
|
|
373
|
+
up(db: Database): void {
|
|
374
|
+
for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS) {
|
|
375
|
+
db.exec(statement);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
down(db: Database): void {
|
|
379
|
+
for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS) {
|
|
380
|
+
db.exec(statement);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
},
|
|
362
384
|
];
|
|
363
385
|
|
|
364
386
|
function migrationTableExists(db: Database): boolean {
|
|
@@ -557,6 +579,11 @@ export function migrateDatabase(db: Database): void {
|
|
|
557
579
|
ensureMigrationTable(db);
|
|
558
580
|
ensureMigrationVersionColumn(db);
|
|
559
581
|
|
|
582
|
+
// Backfill the legacy board_idempotency_keys.state column before running
|
|
583
|
+
// any migrations so that later migrations (e.g. 0010's state-scoped index)
|
|
584
|
+
// can assume the column exists on databases whose 0009 predates it.
|
|
585
|
+
migrateBoardIdempotencyState(db);
|
|
586
|
+
|
|
560
587
|
const version: number = currentVersion(db);
|
|
561
588
|
|
|
562
589
|
for (const migration of MIGRATIONS) {
|
|
@@ -567,8 +594,6 @@ export function migrateDatabase(db: Database): void {
|
|
|
567
594
|
migration.up(db);
|
|
568
595
|
recordMigration(db, migration);
|
|
569
596
|
}
|
|
570
|
-
|
|
571
|
-
migrateBoardIdempotencyState(db);
|
|
572
597
|
});
|
|
573
598
|
}
|
|
574
599
|
|
package/src/storage/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SCHEMA_VERSION =
|
|
1
|
+
export const SCHEMA_VERSION = 3;
|
|
2
2
|
|
|
3
3
|
export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
4
4
|
`PRAGMA foreign_keys = ON;`,
|
|
@@ -137,4 +137,5 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
|
137
137
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
|
|
138
138
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
|
|
139
139
|
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
|
|
140
|
+
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
|
|
140
141
|
];
|
package/src/sync/event-writes.ts
CHANGED
|
@@ -38,18 +38,22 @@ export function nextEventTimestamp(db: Database): number {
|
|
|
38
38
|
return Math.max(now, latestEvent.created_at + 1);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
42
|
-
const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
|
|
43
|
-
if (existingContext) {
|
|
44
|
-
return fn();
|
|
45
|
-
}
|
|
46
|
-
|
|
41
|
+
export function prepareEventWriteContext(db: Database, cwd: string): EventWriteContext {
|
|
47
42
|
const nextTimestamp: number = nextEventTimestamp(db);
|
|
48
43
|
const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
|
|
49
|
-
|
|
44
|
+
|
|
45
|
+
return {
|
|
50
46
|
git,
|
|
51
47
|
nextTimestamp,
|
|
52
48
|
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function withTransactionEventContext<T>(db: Database, context: EventWriteContext, fn: () => T): T {
|
|
52
|
+
const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
|
|
53
|
+
if (existingContext) {
|
|
54
|
+
return fn();
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
transactionEventContexts.set(db, context);
|
|
54
58
|
|
|
55
59
|
try {
|