trekoon 0.1.0
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 +91 -0
- package/AGENTS.md +54 -0
- package/CONTRIBUTING.md +18 -0
- package/README.md +151 -0
- package/bin/trekoon +5 -0
- package/bun.lock +28 -0
- package/package.json +24 -0
- package/src/commands/arg-parser.ts +93 -0
- package/src/commands/dep.ts +105 -0
- package/src/commands/epic.ts +539 -0
- package/src/commands/help.ts +61 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/quickstart.ts +61 -0
- package/src/commands/subtask.ts +187 -0
- package/src/commands/sync.ts +128 -0
- package/src/commands/task.ts +554 -0
- package/src/commands/wipe.ts +39 -0
- package/src/domain/tracker-domain.ts +576 -0
- package/src/domain/types.ts +99 -0
- package/src/index.ts +21 -0
- package/src/io/human-table.ts +191 -0
- package/src/io/output.ts +70 -0
- package/src/runtime/cli-shell.ts +158 -0
- package/src/runtime/command-types.ts +33 -0
- package/src/storage/database.ts +35 -0
- package/src/storage/migrations.ts +46 -0
- package/src/storage/path.ts +22 -0
- package/src/storage/schema.ts +116 -0
- package/src/storage/types.ts +15 -0
- package/src/sync/branch-db.ts +49 -0
- package/src/sync/event-writes.ts +49 -0
- package/src/sync/git-context.ts +67 -0
- package/src/sync/service.ts +654 -0
- package/src/sync/types.ts +31 -0
- package/tests/commands/dep.test.ts +101 -0
- package/tests/commands/epic.test.ts +383 -0
- package/tests/commands/subtask.test.ts +132 -0
- package/tests/commands/sync/sync-command.test.ts +1 -0
- package/tests/commands/sync.test.ts +199 -0
- package/tests/commands/task.test.ts +474 -0
- package/tests/integration/sync-workflow.test.ts +279 -0
- package/tests/io/human-table.test.ts +81 -0
- package/tests/runtime/output-mode.test.ts +54 -0
- package/tests/storage/database.test.ts +91 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { type Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import { openTrekoonDatabase } from "../storage/database";
|
|
6
|
+
import { openBranchDatabaseSnapshot } from "./branch-db";
|
|
7
|
+
import { persistGitContext, resolveGitContext } from "./git-context";
|
|
8
|
+
import { type PullSummary, type ResolveSummary, type SyncResolution, type SyncStatusSummary } from "./types";
|
|
9
|
+
|
|
10
|
+
interface StoredEvent {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly entity_kind: string;
|
|
13
|
+
readonly entity_id: string;
|
|
14
|
+
readonly operation: string;
|
|
15
|
+
readonly payload: string;
|
|
16
|
+
readonly git_branch: string | null;
|
|
17
|
+
readonly git_head: string | null;
|
|
18
|
+
readonly created_at: number;
|
|
19
|
+
readonly updated_at: number;
|
|
20
|
+
readonly version: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CursorRow {
|
|
24
|
+
readonly source_branch: string;
|
|
25
|
+
readonly cursor_token: string;
|
|
26
|
+
readonly last_event_at: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ConflictRow {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly event_id: string;
|
|
32
|
+
readonly entity_kind: string;
|
|
33
|
+
readonly entity_id: string;
|
|
34
|
+
readonly field_name: string;
|
|
35
|
+
readonly ours_value: string | null;
|
|
36
|
+
readonly theirs_value: string | null;
|
|
37
|
+
readonly resolution: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface EventPayload {
|
|
41
|
+
readonly fields?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parsePayload(rawPayload: string): EventPayload {
|
|
45
|
+
try {
|
|
46
|
+
const parsed: unknown = JSON.parse(rawPayload);
|
|
47
|
+
|
|
48
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
49
|
+
return parsed as EventPayload;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall back to empty payload.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tableForEntityKind(entityKind: string): "epics" | "tasks" | "subtasks" | "dependencies" | null {
|
|
59
|
+
switch (entityKind) {
|
|
60
|
+
case "epic":
|
|
61
|
+
return "epics";
|
|
62
|
+
case "task":
|
|
63
|
+
return "tasks";
|
|
64
|
+
case "subtask":
|
|
65
|
+
return "subtasks";
|
|
66
|
+
case "dependency":
|
|
67
|
+
return "dependencies";
|
|
68
|
+
default:
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseCursorToken(token: string): { createdAt: number; id: string | null } {
|
|
74
|
+
const [createdAtRaw, idRaw] = token.split(":");
|
|
75
|
+
const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : 0,
|
|
79
|
+
id: idRaw && idRaw.length > 0 ? idRaw : null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cursorTokenFromEvent(event: StoredEvent): string {
|
|
84
|
+
return `${event.created_at}:${event.id}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadCursor(db: Database, sourceBranch: string): CursorRow | null {
|
|
88
|
+
return db
|
|
89
|
+
.query(
|
|
90
|
+
`
|
|
91
|
+
SELECT source_branch, cursor_token, last_event_at
|
|
92
|
+
FROM sync_cursors
|
|
93
|
+
WHERE source_branch = ?
|
|
94
|
+
LIMIT 1;
|
|
95
|
+
`,
|
|
96
|
+
)
|
|
97
|
+
.get(sourceBranch) as CursorRow | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveCursor(db: Database, sourceBranch: string, cursorToken: string, lastEventAt: number | null): void {
|
|
101
|
+
const now: number = Date.now();
|
|
102
|
+
|
|
103
|
+
db.query(
|
|
104
|
+
`
|
|
105
|
+
INSERT INTO sync_cursors (
|
|
106
|
+
id,
|
|
107
|
+
source_branch,
|
|
108
|
+
cursor_token,
|
|
109
|
+
last_event_at,
|
|
110
|
+
created_at,
|
|
111
|
+
updated_at,
|
|
112
|
+
version
|
|
113
|
+
) VALUES (
|
|
114
|
+
@sourceBranch,
|
|
115
|
+
@sourceBranch,
|
|
116
|
+
@cursorToken,
|
|
117
|
+
@lastEventAt,
|
|
118
|
+
@now,
|
|
119
|
+
@now,
|
|
120
|
+
1
|
|
121
|
+
)
|
|
122
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
123
|
+
cursor_token = excluded.cursor_token,
|
|
124
|
+
last_event_at = excluded.last_event_at,
|
|
125
|
+
updated_at = excluded.updated_at,
|
|
126
|
+
version = sync_cursors.version + 1;
|
|
127
|
+
`,
|
|
128
|
+
).run({
|
|
129
|
+
"@sourceBranch": sourceBranch,
|
|
130
|
+
"@cursorToken": cursorToken,
|
|
131
|
+
"@lastEventAt": lastEventAt,
|
|
132
|
+
"@now": now,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function queryNewEvents(remoteDb: Database, cursorToken: string): StoredEvent[] {
|
|
137
|
+
const cursor = parseCursorToken(cursorToken);
|
|
138
|
+
|
|
139
|
+
return remoteDb
|
|
140
|
+
.query(
|
|
141
|
+
`
|
|
142
|
+
SELECT id, entity_kind, entity_id, operation, payload, git_branch, git_head, created_at, updated_at, version
|
|
143
|
+
FROM events
|
|
144
|
+
WHERE created_at > @createdAt
|
|
145
|
+
OR (created_at = @createdAt AND id > @id)
|
|
146
|
+
ORDER BY created_at ASC, id ASC;
|
|
147
|
+
`,
|
|
148
|
+
)
|
|
149
|
+
.all({
|
|
150
|
+
"@createdAt": cursor.createdAt,
|
|
151
|
+
"@id": cursor.id ?? "",
|
|
152
|
+
}) as StoredEvent[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function countPendingConflicts(db: Database): number {
|
|
156
|
+
const row = db
|
|
157
|
+
.query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
|
|
158
|
+
.get() as { count: number } | null;
|
|
159
|
+
|
|
160
|
+
return row?.count ?? 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function countBehind(remoteDb: Database, cursorToken: string): number {
|
|
164
|
+
const cursor = parseCursorToken(cursorToken);
|
|
165
|
+
const row = remoteDb
|
|
166
|
+
.query(
|
|
167
|
+
`
|
|
168
|
+
SELECT COUNT(*) AS count
|
|
169
|
+
FROM events
|
|
170
|
+
WHERE created_at > @createdAt
|
|
171
|
+
OR (created_at = @createdAt AND id > @id);
|
|
172
|
+
`,
|
|
173
|
+
)
|
|
174
|
+
.get({
|
|
175
|
+
"@createdAt": cursor.createdAt,
|
|
176
|
+
"@id": cursor.id ?? "",
|
|
177
|
+
}) as { count: number } | null;
|
|
178
|
+
|
|
179
|
+
return row?.count ?? 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listRemoteEventIds(remoteDb: Database): Set<string> {
|
|
183
|
+
const rows = remoteDb.query("SELECT id FROM events;").all() as Array<{ id: string }>;
|
|
184
|
+
return new Set(rows.map((row) => row.id));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function countAhead(localDb: Database, currentBranch: string | null, remoteEventIds: Set<string>): number {
|
|
188
|
+
const rows = localDb
|
|
189
|
+
.query("SELECT id, git_branch FROM events WHERE git_branch = ?;")
|
|
190
|
+
.all(currentBranch) as Array<{ id: string; git_branch: string | null }>;
|
|
191
|
+
|
|
192
|
+
return rows.filter((row) => !remoteEventIds.has(row.id)).length;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readFieldValue(payload: EventPayload, field: string): unknown {
|
|
196
|
+
const fields = payload.fields;
|
|
197
|
+
if (!fields) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return fields[field];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function serializeValue(value: unknown): string | null {
|
|
205
|
+
if (typeof value === "undefined") {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return JSON.stringify(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function entityFieldConflict(
|
|
213
|
+
localDb: Database,
|
|
214
|
+
sourceBranch: string,
|
|
215
|
+
event: StoredEvent,
|
|
216
|
+
fieldName: string,
|
|
217
|
+
incomingValue: unknown,
|
|
218
|
+
): { oursValue: string | null; theirsValue: string | null } | null {
|
|
219
|
+
const rows = localDb
|
|
220
|
+
.query(
|
|
221
|
+
`
|
|
222
|
+
SELECT payload, git_branch
|
|
223
|
+
FROM events
|
|
224
|
+
WHERE entity_kind = ? AND entity_id = ?
|
|
225
|
+
ORDER BY created_at DESC, id DESC
|
|
226
|
+
LIMIT 50;
|
|
227
|
+
`,
|
|
228
|
+
)
|
|
229
|
+
.all(event.entity_kind, event.entity_id) as Array<{ payload: string; git_branch: string | null }>;
|
|
230
|
+
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
if (row.git_branch === sourceBranch) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const payload = parsePayload(row.payload);
|
|
237
|
+
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
238
|
+
|
|
239
|
+
if (typeof localValue === "undefined") {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const oursValue = serializeValue(localValue);
|
|
244
|
+
const theirsValue = serializeValue(incomingValue);
|
|
245
|
+
|
|
246
|
+
if (oursValue !== theirsValue) {
|
|
247
|
+
return {
|
|
248
|
+
oursValue,
|
|
249
|
+
theirsValue,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createConflict(
|
|
258
|
+
db: Database,
|
|
259
|
+
event: StoredEvent,
|
|
260
|
+
fieldName: string,
|
|
261
|
+
oursValue: string | null,
|
|
262
|
+
theirsValue: string | null,
|
|
263
|
+
): void {
|
|
264
|
+
const now: number = Date.now();
|
|
265
|
+
db.query(
|
|
266
|
+
`
|
|
267
|
+
INSERT INTO sync_conflicts (
|
|
268
|
+
id,
|
|
269
|
+
event_id,
|
|
270
|
+
entity_kind,
|
|
271
|
+
entity_id,
|
|
272
|
+
field_name,
|
|
273
|
+
ours_value,
|
|
274
|
+
theirs_value,
|
|
275
|
+
resolution,
|
|
276
|
+
created_at,
|
|
277
|
+
updated_at,
|
|
278
|
+
version
|
|
279
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, 1);
|
|
280
|
+
`,
|
|
281
|
+
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, now, now);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
|
|
285
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
286
|
+
if (!tableName) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const now: number = Date.now();
|
|
291
|
+
|
|
292
|
+
if (tableName === "epics") {
|
|
293
|
+
db.query(
|
|
294
|
+
`
|
|
295
|
+
INSERT INTO epics (id, title, description, status, created_at, updated_at, version)
|
|
296
|
+
VALUES (@id, @title, @description, @status, @now, @now, 1)
|
|
297
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
298
|
+
title = excluded.title,
|
|
299
|
+
description = excluded.description,
|
|
300
|
+
status = excluded.status,
|
|
301
|
+
updated_at = excluded.updated_at,
|
|
302
|
+
version = epics.version + 1;
|
|
303
|
+
`,
|
|
304
|
+
).run({
|
|
305
|
+
"@id": event.entity_id,
|
|
306
|
+
"@title": typeof fields.title === "string" ? fields.title : "Untitled epic",
|
|
307
|
+
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
308
|
+
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
309
|
+
"@now": now,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (tableName === "tasks") {
|
|
316
|
+
db.query(
|
|
317
|
+
`
|
|
318
|
+
INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
|
|
319
|
+
VALUES (@id, @epicId, @title, @description, @status, @now, @now, 1)
|
|
320
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
321
|
+
epic_id = excluded.epic_id,
|
|
322
|
+
title = excluded.title,
|
|
323
|
+
description = excluded.description,
|
|
324
|
+
status = excluded.status,
|
|
325
|
+
updated_at = excluded.updated_at,
|
|
326
|
+
version = tasks.version + 1;
|
|
327
|
+
`,
|
|
328
|
+
).run({
|
|
329
|
+
"@id": event.entity_id,
|
|
330
|
+
"@epicId": typeof fields.epic_id === "string" ? fields.epic_id : "missing-epic",
|
|
331
|
+
"@title": typeof fields.title === "string" ? fields.title : "Untitled task",
|
|
332
|
+
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
333
|
+
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
334
|
+
"@now": now,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (tableName === "subtasks") {
|
|
341
|
+
db.query(
|
|
342
|
+
`
|
|
343
|
+
INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
|
|
344
|
+
VALUES (@id, @taskId, @title, @description, @status, @now, @now, 1)
|
|
345
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
346
|
+
task_id = excluded.task_id,
|
|
347
|
+
title = excluded.title,
|
|
348
|
+
description = excluded.description,
|
|
349
|
+
status = excluded.status,
|
|
350
|
+
updated_at = excluded.updated_at,
|
|
351
|
+
version = subtasks.version + 1;
|
|
352
|
+
`,
|
|
353
|
+
).run({
|
|
354
|
+
"@id": event.entity_id,
|
|
355
|
+
"@taskId": typeof fields.task_id === "string" ? fields.task_id : "missing-task",
|
|
356
|
+
"@title": typeof fields.title === "string" ? fields.title : "Untitled subtask",
|
|
357
|
+
"@description": typeof fields.description === "string" ? fields.description : "",
|
|
358
|
+
"@status": typeof fields.status === "string" ? fields.status : "open",
|
|
359
|
+
"@now": now,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
db.query(
|
|
366
|
+
`
|
|
367
|
+
INSERT INTO dependencies (
|
|
368
|
+
id,
|
|
369
|
+
source_id,
|
|
370
|
+
source_kind,
|
|
371
|
+
depends_on_id,
|
|
372
|
+
depends_on_kind,
|
|
373
|
+
created_at,
|
|
374
|
+
updated_at,
|
|
375
|
+
version
|
|
376
|
+
)
|
|
377
|
+
VALUES (@id, @sourceId, @sourceKind, @dependsOnId, @dependsOnKind, @now, @now, 1)
|
|
378
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
379
|
+
source_id = excluded.source_id,
|
|
380
|
+
source_kind = excluded.source_kind,
|
|
381
|
+
depends_on_id = excluded.depends_on_id,
|
|
382
|
+
depends_on_kind = excluded.depends_on_kind,
|
|
383
|
+
updated_at = excluded.updated_at,
|
|
384
|
+
version = dependencies.version + 1;
|
|
385
|
+
`,
|
|
386
|
+
).run({
|
|
387
|
+
"@id": event.entity_id,
|
|
388
|
+
"@sourceId": typeof fields.source_id === "string" ? fields.source_id : "",
|
|
389
|
+
"@sourceKind": typeof fields.source_kind === "string" ? fields.source_kind : "task",
|
|
390
|
+
"@dependsOnId": typeof fields.depends_on_id === "string" ? fields.depends_on_id : "",
|
|
391
|
+
"@dependsOnKind": typeof fields.depends_on_kind === "string" ? fields.depends_on_kind : "task",
|
|
392
|
+
"@now": now,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function storeEvent(db: Database, event: StoredEvent): void {
|
|
399
|
+
db.query(
|
|
400
|
+
`
|
|
401
|
+
INSERT OR IGNORE INTO events (
|
|
402
|
+
id,
|
|
403
|
+
entity_kind,
|
|
404
|
+
entity_id,
|
|
405
|
+
operation,
|
|
406
|
+
payload,
|
|
407
|
+
git_branch,
|
|
408
|
+
git_head,
|
|
409
|
+
created_at,
|
|
410
|
+
updated_at,
|
|
411
|
+
version
|
|
412
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
413
|
+
`,
|
|
414
|
+
).run(
|
|
415
|
+
event.id,
|
|
416
|
+
event.entity_kind,
|
|
417
|
+
event.entity_id,
|
|
418
|
+
event.operation,
|
|
419
|
+
event.payload,
|
|
420
|
+
event.git_branch,
|
|
421
|
+
event.git_head,
|
|
422
|
+
event.created_at,
|
|
423
|
+
event.updated_at,
|
|
424
|
+
event.version,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary {
|
|
429
|
+
const storage = openTrekoonDatabase(cwd);
|
|
430
|
+
const git = resolveGitContext(cwd);
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
persistGitContext(storage.db, git);
|
|
434
|
+
|
|
435
|
+
const cursor = loadCursor(storage.db, sourceBranch);
|
|
436
|
+
const cursorToken: string = cursor?.cursor_token ?? "0:";
|
|
437
|
+
const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
return {
|
|
441
|
+
sourceBranch,
|
|
442
|
+
ahead: countAhead(storage.db, git.branchName, listRemoteEventIds(remote.db)),
|
|
443
|
+
behind: countBehind(remote.db, cursorToken),
|
|
444
|
+
pendingConflicts: countPendingConflicts(storage.db),
|
|
445
|
+
git,
|
|
446
|
+
};
|
|
447
|
+
} finally {
|
|
448
|
+
remote.close();
|
|
449
|
+
}
|
|
450
|
+
} finally {
|
|
451
|
+
storage.close();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
456
|
+
const storage = openTrekoonDatabase(cwd);
|
|
457
|
+
const git = resolveGitContext(cwd);
|
|
458
|
+
persistGitContext(storage.db, git);
|
|
459
|
+
|
|
460
|
+
const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const cursor = loadCursor(storage.db, sourceBranch);
|
|
464
|
+
const cursorToken = cursor?.cursor_token ?? "0:";
|
|
465
|
+
const incomingEvents: StoredEvent[] = queryNewEvents(remote.db, cursorToken);
|
|
466
|
+
|
|
467
|
+
let appliedEvents = 0;
|
|
468
|
+
let createdConflicts = 0;
|
|
469
|
+
let lastToken: string | null = null;
|
|
470
|
+
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
471
|
+
|
|
472
|
+
storage.db.transaction((): void => {
|
|
473
|
+
for (const incoming of incomingEvents) {
|
|
474
|
+
const payload = parsePayload(incoming.payload);
|
|
475
|
+
const incomingFields: Record<string, unknown> = payload.fields ?? {};
|
|
476
|
+
const fieldsToApply: Record<string, unknown> = {};
|
|
477
|
+
let hasAppliedField = false;
|
|
478
|
+
|
|
479
|
+
for (const [fieldName, value] of Object.entries(incomingFields)) {
|
|
480
|
+
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
481
|
+
|
|
482
|
+
if (conflict) {
|
|
483
|
+
createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
|
|
484
|
+
createdConflicts += 1;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
fieldsToApply[fieldName] = value;
|
|
489
|
+
hasAppliedField = true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (hasAppliedField && applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
493
|
+
appliedEvents += 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
storeEvent(storage.db, incoming);
|
|
497
|
+
lastToken = cursorTokenFromEvent(incoming);
|
|
498
|
+
lastEventAt = incoming.created_at;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (lastToken) {
|
|
502
|
+
saveCursor(storage.db, sourceBranch, lastToken, lastEventAt);
|
|
503
|
+
}
|
|
504
|
+
})();
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
sourceBranch,
|
|
508
|
+
scannedEvents: incomingEvents.length,
|
|
509
|
+
appliedEvents,
|
|
510
|
+
createdConflicts,
|
|
511
|
+
cursorToken: lastToken,
|
|
512
|
+
};
|
|
513
|
+
} finally {
|
|
514
|
+
remote.close();
|
|
515
|
+
storage.close();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function parseConflictValue(value: string | null): unknown {
|
|
520
|
+
if (!value) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(value);
|
|
526
|
+
} catch {
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
|
|
532
|
+
const tableName = tableForEntityKind(entityKind);
|
|
533
|
+
if (!tableName) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const allowedFields: Record<string, readonly string[]> = {
|
|
538
|
+
epics: ["title", "description", "status"],
|
|
539
|
+
tasks: ["epic_id", "title", "description", "status"],
|
|
540
|
+
subtasks: ["task_id", "title", "description", "status"],
|
|
541
|
+
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const validFields: readonly string[] = allowedFields[tableName] ?? [];
|
|
545
|
+
if (!validFields.includes(fieldName)) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const now: number = Date.now();
|
|
550
|
+
db.query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
|
|
551
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
552
|
+
now,
|
|
553
|
+
entityId,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function appendResolutionEvent(
|
|
558
|
+
db: Database,
|
|
559
|
+
gitBranch: string | null,
|
|
560
|
+
gitHead: string | null,
|
|
561
|
+
conflict: ConflictRow,
|
|
562
|
+
resolution: SyncResolution,
|
|
563
|
+
): void {
|
|
564
|
+
const now: number = Date.now();
|
|
565
|
+
const resolvedValue: string | null = resolution === "theirs" ? conflict.theirs_value : conflict.ours_value;
|
|
566
|
+
|
|
567
|
+
db.query(
|
|
568
|
+
`
|
|
569
|
+
INSERT INTO events (
|
|
570
|
+
id,
|
|
571
|
+
entity_kind,
|
|
572
|
+
entity_id,
|
|
573
|
+
operation,
|
|
574
|
+
payload,
|
|
575
|
+
git_branch,
|
|
576
|
+
git_head,
|
|
577
|
+
created_at,
|
|
578
|
+
updated_at,
|
|
579
|
+
version
|
|
580
|
+
) VALUES (?, ?, ?, 'resolve_conflict', ?, ?, ?, ?, ?, 1);
|
|
581
|
+
`,
|
|
582
|
+
).run(
|
|
583
|
+
randomUUID(),
|
|
584
|
+
conflict.entity_kind,
|
|
585
|
+
conflict.entity_id,
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
conflict_id: conflict.id,
|
|
588
|
+
field: conflict.field_name,
|
|
589
|
+
resolution,
|
|
590
|
+
value: resolvedValue,
|
|
591
|
+
}),
|
|
592
|
+
gitBranch,
|
|
593
|
+
gitHead,
|
|
594
|
+
now,
|
|
595
|
+
now,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
|
|
600
|
+
const storage = openTrekoonDatabase(cwd);
|
|
601
|
+
const git = resolveGitContext(cwd);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
persistGitContext(storage.db, git);
|
|
605
|
+
|
|
606
|
+
const conflict = storage.db
|
|
607
|
+
.query(
|
|
608
|
+
`
|
|
609
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution
|
|
610
|
+
FROM sync_conflicts
|
|
611
|
+
WHERE id = ?
|
|
612
|
+
LIMIT 1;
|
|
613
|
+
`,
|
|
614
|
+
)
|
|
615
|
+
.get(conflictId) as ConflictRow | null;
|
|
616
|
+
|
|
617
|
+
if (!conflict) {
|
|
618
|
+
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (conflict.resolution !== "pending") {
|
|
622
|
+
throw new Error(`Conflict '${conflictId}' already resolved.`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
storage.db.transaction((): void => {
|
|
626
|
+
if (resolution === "theirs") {
|
|
627
|
+
updateSingleField(
|
|
628
|
+
storage.db,
|
|
629
|
+
conflict.entity_kind,
|
|
630
|
+
conflict.entity_id,
|
|
631
|
+
conflict.field_name,
|
|
632
|
+
parseConflictValue(conflict.theirs_value),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const now: number = Date.now();
|
|
637
|
+
storage.db
|
|
638
|
+
.query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
639
|
+
.run(resolution, now, conflict.id);
|
|
640
|
+
|
|
641
|
+
appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
|
|
642
|
+
})();
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
conflictId,
|
|
646
|
+
resolution,
|
|
647
|
+
entityKind: conflict.entity_kind,
|
|
648
|
+
entityId: conflict.entity_id,
|
|
649
|
+
fieldName: conflict.field_name,
|
|
650
|
+
};
|
|
651
|
+
} finally {
|
|
652
|
+
storage.close();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type SyncResolution = "ours" | "theirs";
|
|
2
|
+
|
|
3
|
+
export interface GitContextSnapshot {
|
|
4
|
+
readonly worktreePath: string;
|
|
5
|
+
readonly branchName: string | null;
|
|
6
|
+
readonly headSha: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SyncStatusSummary {
|
|
10
|
+
readonly sourceBranch: string;
|
|
11
|
+
readonly ahead: number;
|
|
12
|
+
readonly behind: number;
|
|
13
|
+
readonly pendingConflicts: number;
|
|
14
|
+
readonly git: GitContextSnapshot;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PullSummary {
|
|
18
|
+
readonly sourceBranch: string;
|
|
19
|
+
readonly scannedEvents: number;
|
|
20
|
+
readonly appliedEvents: number;
|
|
21
|
+
readonly createdConflicts: number;
|
|
22
|
+
readonly cursorToken: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolveSummary {
|
|
26
|
+
readonly conflictId: string;
|
|
27
|
+
readonly resolution: SyncResolution;
|
|
28
|
+
readonly entityKind: string;
|
|
29
|
+
readonly entityId: string;
|
|
30
|
+
readonly fieldName: string;
|
|
31
|
+
}
|