trekoon 0.3.0 → 0.3.1

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.
@@ -177,9 +177,64 @@ const MIGRATIONS: readonly Migration[] = [
177
177
  up(db: Database): void {
178
178
  migrateWorktreeScopedSyncMetadata(db);
179
179
  },
180
- down(db: Database): void {
181
- db.exec("DROP INDEX IF EXISTS idx_sync_cursors_owner;");
182
- db.exec("DROP INDEX IF EXISTS idx_git_context_scope_path;");
180
+ down(_db: Database): void {
181
+ throw new Error(
182
+ "Migration 0004 (worktree_scoped_sync_metadata) is irreversible. " +
183
+ "It adds columns via ALTER TABLE that cannot be removed without " +
184
+ "reconstructing tables and risking data loss. " +
185
+ "Rollback below version 4 is not supported.",
186
+ );
187
+ },
188
+ },
189
+ {
190
+ version: 5,
191
+ name: "0005_dependency_edge_integrity",
192
+ up(db: Database): void {
193
+ // Clean up orphaned dependency rows where source or target no longer exists.
194
+ db.exec(`
195
+ DELETE FROM dependencies
196
+ WHERE source_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks)
197
+ OR depends_on_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks);
198
+ `);
199
+
200
+ // Deduplicate any existing duplicate edges before creating the unique index.
201
+ // Keep one arbitrary row per logical edge (MIN(id) is lexicographic, not chronological, but any survivor is equivalent).
202
+ db.exec(`
203
+ DELETE FROM dependencies
204
+ WHERE id NOT IN (
205
+ SELECT MIN(id) FROM dependencies
206
+ GROUP BY source_id, depends_on_id
207
+ );
208
+ `);
209
+
210
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_edge ON dependencies (source_id, depends_on_id);");
211
+ },
212
+ down(_db: Database): void {
213
+ throw new Error(
214
+ "Migration 0005 (dependency_edge_integrity) is irreversible. " +
215
+ "It removes orphaned rows and deduplicates dependency edges. " +
216
+ "Rollback below version 5 is not supported.",
217
+ );
218
+ },
219
+ },
220
+ {
221
+ version: 6,
222
+ name: "0006_add_owner_column",
223
+ up(db: Database): void {
224
+ if (!tableHasColumn(db, "tasks", "owner")) {
225
+ db.exec("ALTER TABLE tasks ADD COLUMN owner TEXT;");
226
+ }
227
+ if (!tableHasColumn(db, "subtasks", "owner")) {
228
+ db.exec("ALTER TABLE subtasks ADD COLUMN owner TEXT;");
229
+ }
230
+ },
231
+ down(_db: Database): void {
232
+ throw new Error(
233
+ "Migration 0006 (add_owner_column) is irreversible. " +
234
+ "It adds columns via ALTER TABLE that cannot be removed without " +
235
+ "reconstructing tables and risking data loss. " +
236
+ "Rollback below version 6 is not supported.",
237
+ );
183
238
  },
184
239
  },
185
240
  ];
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
- import { openTrekoonDatabase } from "../storage/database";
5
+ import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
6
  import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
7
7
  import { persistGitContext, resolveGitContext } from "./git-context";
8
8
  import {
@@ -15,6 +15,49 @@ import {
15
15
  type SyncStatusSummary,
16
16
  } from "./types";
17
17
 
18
+ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
19
+ if (cursorToken === "0:") {
20
+ return false;
21
+ }
22
+
23
+ const [createdAtRaw, idRaw] = cursorToken.split(":");
24
+ const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
25
+ const id: string = idRaw ?? "";
26
+
27
+ if (!Number.isFinite(createdAt) || createdAt === 0) {
28
+ return false;
29
+ }
30
+
31
+ // Check if the event referenced by the cursor still exists.
32
+ // If the cursor references a specific event id, check for it.
33
+ // Otherwise, check if any event at or after the cursor timestamp exists
34
+ // on the source branch.
35
+ if (id.length > 0) {
36
+ const row = db
37
+ .query("SELECT id FROM events WHERE id = ? LIMIT 1;")
38
+ .get(id) as { id: string } | null;
39
+ if (row) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // The referenced event is gone. Check if there are any events on the
45
+ // source branch at or after the cursor timestamp — if not, the cursor
46
+ // may simply be at the end of the stream.
47
+ const newerRow = db
48
+ .query(
49
+ `SELECT id FROM events
50
+ WHERE git_branch = ? AND created_at >= ?
51
+ ORDER BY created_at ASC, id ASC
52
+ LIMIT 1;`,
53
+ )
54
+ .get(sourceBranch, createdAt) as { id: string } | null;
55
+
56
+ // If there are newer events but our referenced event is gone,
57
+ // events between the cursor and the oldest remaining event were pruned.
58
+ return newerRow !== null;
59
+ }
60
+
18
61
  interface StoredEvent {
19
62
  readonly id: string;
20
63
  readonly entity_kind: string;
@@ -289,23 +332,22 @@ function entityFieldConflict(
289
332
  return null;
290
333
  }
291
334
 
335
+ // Note: loads all matching events into memory. For entities with very large
336
+ // event histories, consider a cursor-based scan. The idx_events_entity index
337
+ // keeps the scan narrow by (entity_kind, entity_id).
292
338
  const rows = localDb
293
339
  .query(
294
340
  `
295
341
  SELECT payload, git_branch
296
342
  FROM events
297
- WHERE entity_kind = ? AND entity_id = ?
343
+ WHERE entity_kind = ? AND entity_id = ? AND git_branch != ?
298
344
  ORDER BY created_at DESC, id DESC
299
- LIMIT 50;
300
- `,
345
+ LIMIT 500;
346
+ `,
301
347
  )
302
- .all(event.entity_kind, event.entity_id) as Array<{ payload: string; git_branch: string | null }>;
348
+ .all(event.entity_kind, event.entity_id, sourceBranch) as Array<{ payload: string; git_branch: string | null }>;
303
349
 
304
350
  for (const row of rows) {
305
- if (row.git_branch === sourceBranch) {
306
- continue;
307
- }
308
-
309
351
  const payloadValidation = parsePayload(row.payload);
310
352
  if (!payloadValidation.ok) {
311
353
  continue;
@@ -360,6 +402,15 @@ function createConflict(
360
402
  ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
361
403
  }
362
404
 
405
+ function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
406
+ const row = db
407
+ .query(
408
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch != ? LIMIT 1;`,
409
+ )
410
+ .get(entityKind, entityId, sourceBranch);
411
+ return row !== null;
412
+ }
413
+
363
414
  function rowExists(db: Database, tableName: string, id: string): boolean {
364
415
  const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
365
416
  return row !== null;
@@ -495,10 +546,9 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
495
546
  updated_at,
496
547
  version
497
548
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
498
- ON CONFLICT(id) DO UPDATE SET
499
- source_id = excluded.source_id,
549
+ ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
550
+ id = excluded.id,
500
551
  source_kind = excluded.source_kind,
501
- depends_on_id = excluded.depends_on_id,
502
552
  depends_on_kind = excluded.depends_on_kind,
503
553
  updated_at = excluded.updated_at,
504
554
  version = dependencies.version + 1;
@@ -689,6 +739,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
689
739
  persistGitContext(storage.db, git);
690
740
  const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
691
741
  const cursorToken = cursor?.cursor_token ?? "0:";
742
+ const staleCursor: boolean = cursor !== null && isCursorStale(storage.db, cursorToken, sourceBranch);
692
743
  const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
693
744
 
694
745
  // Same-branch fast path: skip conflict detection when already on sourceBranch.
@@ -697,7 +748,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
697
748
  let lastToken: string | null = null;
698
749
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
699
750
 
700
- storage.db.transaction((): void => {
751
+ writeTransaction(storage.db, (): void => {
701
752
  for (const incoming of incomingEvents) {
702
753
  storeEvent(storage.db, incoming);
703
754
  lastToken = cursorTokenFromEvent(incoming);
@@ -707,7 +758,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
707
758
  if (lastToken) {
708
759
  saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
709
760
  }
710
- })();
761
+ });
711
762
 
712
763
  return {
713
764
  sourceBranch,
@@ -721,7 +772,10 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
721
772
  applyRejectedEvents: 0,
722
773
  quarantinedEvents: 0,
723
774
  conflictEvents: 0,
724
- errorHints: [],
775
+ staleCursor,
776
+ errorHints: staleCursor
777
+ ? ["Stale cursor detected; some events may have been pruned. Consider a full rebuild."]
778
+ : [],
725
779
  },
726
780
  };
727
781
  }
@@ -735,7 +789,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
735
789
  let lastToken: string | null = null;
736
790
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
737
791
 
738
- storage.db.transaction((): void => {
792
+ writeTransaction(storage.db, (): void => {
739
793
  for (const incoming of incomingEvents) {
740
794
  const payloadValidation = parsePayload(incoming.payload);
741
795
 
@@ -758,6 +812,23 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
758
812
  }
759
813
 
760
814
  const payload: EventPayload = { fields: payloadValidation.fields };
815
+
816
+ const isDeleteWithLocalEdits =
817
+ incoming.operation.endsWith(".deleted") &&
818
+ hasLocalEntityEdits(storage.db, incoming.entity_kind, incoming.entity_id, sourceBranch);
819
+ if (isDeleteWithLocalEdits) {
820
+ // Note: dependency.removed is intentionally excluded — dependencies are
821
+ // edges (not entities with local edit history), so conflict detection
822
+ // does not apply to them.
823
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
824
+ createdConflicts += 1;
825
+ conflictEvents += 1;
826
+ storeEvent(storage.db, incoming);
827
+ lastToken = cursorTokenFromEvent(incoming);
828
+ lastEventAt = incoming.created_at;
829
+ continue;
830
+ }
831
+
761
832
  const fieldsToApply: Record<string, unknown> = {};
762
833
  let withheldConflictCount = 0;
763
834
 
@@ -801,7 +872,16 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
801
872
  if (lastToken) {
802
873
  saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
803
874
  }
804
- })();
875
+ });
876
+
877
+ const errorHints: string[] = buildSyncErrorHints({
878
+ malformedPayloadEvents,
879
+ applyRejectedEvents,
880
+ conflictEvents,
881
+ });
882
+ if (staleCursor) {
883
+ errorHints.push("Stale cursor detected; some events may have been pruned. Consider a full rebuild.");
884
+ }
805
885
 
806
886
  return {
807
887
  sourceBranch,
@@ -815,11 +895,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
815
895
  applyRejectedEvents,
816
896
  quarantinedEvents,
817
897
  conflictEvents,
818
- errorHints: buildSyncErrorHints({
819
- malformedPayloadEvents,
820
- applyRejectedEvents,
821
- conflictEvents,
822
- }),
898
+ staleCursor,
899
+ errorHints,
823
900
  },
824
901
  };
825
902
  } finally {
@@ -1010,7 +1087,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1010
1087
  throw new Error(`Conflict '${conflictId}' already resolved.`);
1011
1088
  }
1012
1089
 
1013
- storage.db.transaction((): void => {
1090
+ writeTransaction(storage.db, (): void => {
1014
1091
  if (resolution === "theirs") {
1015
1092
  updateSingleField(
1016
1093
  storage.db,
@@ -1027,7 +1104,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1027
1104
  .run(resolution, now, conflict.id);
1028
1105
 
1029
1106
  appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
1030
- })();
1107
+ });
1031
1108
 
1032
1109
  return {
1033
1110
  conflictId,
package/src/sync/types.ts CHANGED
@@ -31,6 +31,7 @@ export interface SyncPullDiagnostics {
31
31
  readonly applyRejectedEvents: number;
32
32
  readonly quarantinedEvents: number;
33
33
  readonly conflictEvents: number;
34
+ readonly staleCursor: boolean;
34
35
  readonly errorHints: readonly string[];
35
36
  }
36
37