trekoon 0.2.0 → 0.2.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.
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
5
  import { openTrekoonDatabase } from "../storage/database";
6
- import { openBranchDatabaseSnapshot } from "./branch-db";
6
+ import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
7
7
  import { persistGitContext, resolveGitContext } from "./git-context";
8
8
  import {
9
9
  type PullSummary,
@@ -29,6 +29,8 @@ interface StoredEvent {
29
29
  }
30
30
 
31
31
  interface CursorRow {
32
+ readonly owner_scope: string;
33
+ readonly owner_worktree_path: string;
32
34
  readonly source_branch: string;
33
35
  readonly cursor_token: string;
34
36
  readonly last_event_at: number | null;
@@ -116,40 +118,45 @@ function tableForEntityKind(entityKind: string): "epics" | "tasks" | "subtasks"
116
118
  }
117
119
  }
118
120
 
119
- function parseCursorToken(token: string): { createdAt: number; id: string | null } {
120
- const [createdAtRaw, idRaw] = token.split(":");
121
- const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
122
-
123
- return {
124
- createdAt: Number.isFinite(createdAt) ? createdAt : 0,
125
- id: idRaw && idRaw.length > 0 ? idRaw : null,
126
- };
127
- }
128
-
129
121
  function cursorTokenFromEvent(event: StoredEvent): string {
130
122
  return `${event.created_at}:${event.id}`;
131
123
  }
132
124
 
133
- function loadCursor(db: Database, sourceBranch: string): CursorRow | null {
125
+ function cursorIdForWorktree(worktreePath: string, sourceBranch: string): string {
126
+ return `${worktreePath}::${sourceBranch}`;
127
+ }
128
+
129
+ function loadCursor(db: Database, worktreePath: string, sourceBranch: string): CursorRow | null {
134
130
  return db
135
131
  .query(
136
132
  `
137
- SELECT source_branch, cursor_token, last_event_at
133
+ SELECT owner_scope, owner_worktree_path, source_branch, cursor_token, last_event_at
138
134
  FROM sync_cursors
139
- WHERE source_branch = ?
135
+ WHERE owner_scope = 'worktree'
136
+ AND owner_worktree_path = ?
137
+ AND source_branch = ?
140
138
  LIMIT 1;
141
139
  `,
142
140
  )
143
- .get(sourceBranch) as CursorRow | null;
141
+ .get(worktreePath, sourceBranch) as CursorRow | null;
144
142
  }
145
143
 
146
- function saveCursor(db: Database, sourceBranch: string, cursorToken: string, lastEventAt: number | null): void {
144
+ function saveCursor(
145
+ db: Database,
146
+ worktreePath: string,
147
+ sourceBranch: string,
148
+ cursorToken: string,
149
+ lastEventAt: number | null,
150
+ ): void {
147
151
  const now: number = Date.now();
152
+ const cursorId = cursorIdForWorktree(worktreePath, sourceBranch);
148
153
 
149
154
  db.query(
150
155
  `
151
156
  INSERT INTO sync_cursors (
152
157
  id,
158
+ owner_scope,
159
+ owner_worktree_path,
153
160
  source_branch,
154
161
  cursor_token,
155
162
  last_event_at,
@@ -157,7 +164,9 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
157
164
  updated_at,
158
165
  version
159
166
  ) VALUES (
160
- @sourceBranch,
167
+ @cursorId,
168
+ 'worktree',
169
+ @worktreePath,
161
170
  @sourceBranch,
162
171
  @cursorToken,
163
172
  @lastEventAt,
@@ -166,12 +175,16 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
166
175
  1
167
176
  )
168
177
  ON CONFLICT(id) DO UPDATE SET
178
+ owner_scope = excluded.owner_scope,
179
+ owner_worktree_path = excluded.owner_worktree_path,
169
180
  cursor_token = excluded.cursor_token,
170
181
  last_event_at = excluded.last_event_at,
171
182
  updated_at = excluded.updated_at,
172
183
  version = sync_cursors.version + 1;
173
184
  `,
174
185
  ).run({
186
+ "@cursorId": cursorId,
187
+ "@worktreePath": worktreePath,
175
188
  "@sourceBranch": sourceBranch,
176
189
  "@cursorToken": cursorToken,
177
190
  "@lastEventAt": lastEventAt,
@@ -179,25 +192,6 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
179
192
  });
180
193
  }
181
194
 
182
- function queryNewEvents(remoteDb: Database, cursorToken: string): StoredEvent[] {
183
- const cursor = parseCursorToken(cursorToken);
184
-
185
- return remoteDb
186
- .query(
187
- `
188
- SELECT id, entity_kind, entity_id, operation, payload, git_branch, git_head, created_at, updated_at, version
189
- FROM events
190
- WHERE created_at > @createdAt
191
- OR (created_at = @createdAt AND id > @id)
192
- ORDER BY created_at ASC, id ASC;
193
- `,
194
- )
195
- .all({
196
- "@createdAt": cursor.createdAt,
197
- "@id": cursor.id ?? "",
198
- }) as StoredEvent[];
199
- }
200
-
201
195
  function countPendingConflicts(db: Database): number {
202
196
  const row = db
203
197
  .query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
@@ -206,54 +200,24 @@ function countPendingConflicts(db: Database): number {
206
200
  return row?.count ?? 0;
207
201
  }
208
202
 
209
- function countBehind(remoteDb: Database, cursorToken: string): number {
210
- const cursor = parseCursorToken(cursorToken);
211
- const row = remoteDb
203
+ function countAhead(localDb: Database, currentBranch: string | null, sourceBranch: string): number {
204
+ if (!currentBranch || currentBranch === sourceBranch) {
205
+ return 0;
206
+ }
207
+
208
+ const row = localDb
212
209
  .query(
213
210
  `
214
211
  SELECT COUNT(*) AS count
215
212
  FROM events
216
- WHERE created_at > @createdAt
217
- OR (created_at = @createdAt AND id > @id);
213
+ WHERE git_branch = @branch;
218
214
  `,
219
215
  )
220
- .get({
221
- "@createdAt": cursor.createdAt,
222
- "@id": cursor.id ?? "",
223
- }) as { count: number } | null;
216
+ .get({ "@branch": currentBranch }) as { count: number } | null;
224
217
 
225
218
  return row?.count ?? 0;
226
219
  }
227
220
 
228
- function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
229
- if (!currentBranch) {
230
- return 0;
231
- }
232
-
233
- localDb.query("ATTACH DATABASE ? AS sync_remote;").run(remoteDbPath);
234
-
235
- try {
236
- const row = localDb
237
- .query(
238
- `
239
- SELECT COUNT(*) AS count
240
- FROM events AS local_events
241
- WHERE local_events.git_branch = @branch
242
- AND NOT EXISTS (
243
- SELECT 1
244
- FROM sync_remote.events AS remote_events
245
- WHERE remote_events.id = local_events.id
246
- );
247
- `,
248
- )
249
- .get({ "@branch": currentBranch }) as { count: number } | null;
250
-
251
- return row?.count ?? 0;
252
- } finally {
253
- localDb.query("DETACH DATABASE sync_remote;").run();
254
- }
255
- }
256
-
257
221
  function buildSyncErrorHints(diagnostics: {
258
222
  malformedPayloadEvents: number;
259
223
  applyRejectedEvents: number;
@@ -401,6 +365,18 @@ function rowExists(db: Database, tableName: string, id: string): boolean {
401
365
  return row !== null;
402
366
  }
403
367
 
368
+ function dependencyNodeExists(db: Database, nodeKind: string, nodeId: string): boolean {
369
+ if (nodeKind === "task") {
370
+ return rowExists(db, "tasks", nodeId);
371
+ }
372
+
373
+ if (nodeKind === "subtask") {
374
+ return rowExists(db, "subtasks", nodeId);
375
+ }
376
+
377
+ return false;
378
+ }
379
+
404
380
  function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
405
381
  const value: unknown = fields[fieldName];
406
382
  if (typeof value !== "string" || value.length === 0) {
@@ -503,6 +479,10 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
503
479
  return false;
504
480
  }
505
481
 
482
+ if (!dependencyNodeExists(db, sourceKind, sourceId) || !dependencyNodeExists(db, dependsOnKind, dependsOnId)) {
483
+ return false;
484
+ }
485
+
506
486
  db.query(
507
487
  `
508
488
  INSERT INTO dependencies (
@@ -625,6 +605,28 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
625
605
  return false;
626
606
  }
627
607
 
608
+ function applyReplayedCreateWithConflicts(
609
+ db: Database,
610
+ event: StoredEvent,
611
+ fields: Record<string, unknown>,
612
+ withheldConflictCount: number,
613
+ ): boolean {
614
+ if (withheldConflictCount === 0 || !event.operation.endsWith(".created")) {
615
+ return false;
616
+ }
617
+
618
+ const tableName = tableForEntityKind(event.entity_kind);
619
+ if (!tableName || !rowExists(db, tableName, event.entity_id)) {
620
+ return false;
621
+ }
622
+
623
+ if (Object.keys(fields).length === 0) {
624
+ return true;
625
+ }
626
+
627
+ return applyUpdatePatch(db, event, fields);
628
+ }
629
+
628
630
  function storeEvent(db: Database, event: StoredEvent): void {
629
631
  db.query(
630
632
  `
@@ -662,21 +664,18 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
662
664
  try {
663
665
  persistGitContext(storage.db, git);
664
666
 
665
- const cursor = loadCursor(storage.db, sourceBranch);
667
+ const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
666
668
  const cursorToken: string = cursor?.cursor_token ?? "0:";
667
- const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
669
+ const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
668
670
 
669
- try {
670
- return {
671
- sourceBranch,
672
- ahead: countAhead(storage.db, git.branchName, remote.path),
673
- behind: countBehind(remote.db, cursorToken),
674
- pendingConflicts: countPendingConflicts(storage.db),
675
- git,
676
- };
677
- } finally {
678
- remote.close();
679
- }
671
+ return {
672
+ sourceBranch,
673
+ ahead: countAhead(storage.db, git.branchName, sourceBranch),
674
+ behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
675
+ pendingConflicts: countPendingConflicts(storage.db),
676
+ sameBranch: onSourceBranch,
677
+ git,
678
+ };
680
679
  } finally {
681
680
  storage.close();
682
681
  }
@@ -684,15 +683,48 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
684
683
 
685
684
  export function syncPull(cwd: string, sourceBranch: string): PullSummary {
686
685
  const storage = openTrekoonDatabase(cwd);
687
- const git = resolveGitContext(cwd);
688
- persistGitContext(storage.db, git);
689
-
690
- const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
691
686
 
692
687
  try {
693
- const cursor = loadCursor(storage.db, sourceBranch);
688
+ const git = resolveGitContext(cwd);
689
+ persistGitContext(storage.db, git);
690
+ const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
694
691
  const cursorToken = cursor?.cursor_token ?? "0:";
695
- const incomingEvents: StoredEvent[] = queryNewEvents(remote.db, cursorToken);
692
+ const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
693
+
694
+ // Same-branch fast path: skip conflict detection when already on sourceBranch.
695
+ // Null branchName (detached HEAD) falls through to the normal path.
696
+ if (git.branchName !== null && git.branchName === sourceBranch) {
697
+ let lastToken: string | null = null;
698
+ let lastEventAt: number | null = cursor?.last_event_at ?? null;
699
+
700
+ storage.db.transaction((): void => {
701
+ for (const incoming of incomingEvents) {
702
+ storeEvent(storage.db, incoming);
703
+ lastToken = cursorTokenFromEvent(incoming);
704
+ lastEventAt = incoming.created_at;
705
+ }
706
+
707
+ if (lastToken) {
708
+ saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
709
+ }
710
+ })();
711
+
712
+ return {
713
+ sourceBranch,
714
+ scannedEvents: incomingEvents.length,
715
+ appliedEvents: 0,
716
+ createdConflicts: 0,
717
+ cursorToken: lastToken,
718
+ sameBranch: true,
719
+ diagnostics: {
720
+ malformedPayloadEvents: 0,
721
+ applyRejectedEvents: 0,
722
+ quarantinedEvents: 0,
723
+ conflictEvents: 0,
724
+ errorHints: [],
725
+ },
726
+ };
727
+ }
696
728
 
697
729
  let appliedEvents = 0;
698
730
  let createdConflicts = 0;
@@ -727,11 +759,13 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
727
759
 
728
760
  const payload: EventPayload = { fields: payloadValidation.fields };
729
761
  const fieldsToApply: Record<string, unknown> = {};
762
+ let withheldConflictCount = 0;
730
763
 
731
764
  for (const [fieldName, value] of Object.entries(payload.fields)) {
732
765
  const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
733
766
 
734
767
  if (conflict) {
768
+ withheldConflictCount += 1;
735
769
  conflictEvents += 1;
736
770
  createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
737
771
  createdConflicts += 1;
@@ -743,6 +777,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
743
777
 
744
778
  if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
745
779
  appliedEvents += 1;
780
+ } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
781
+ appliedEvents += 1;
746
782
  } else if (incoming.operation !== "resolve_conflict") {
747
783
  applyRejectedEvents += 1;
748
784
  quarantinedEvents += 1;
@@ -763,7 +799,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
763
799
  }
764
800
 
765
801
  if (lastToken) {
766
- saveCursor(storage.db, sourceBranch, lastToken, lastEventAt);
802
+ saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
767
803
  }
768
804
  })();
769
805
 
@@ -773,6 +809,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
773
809
  appliedEvents,
774
810
  createdConflicts,
775
811
  cursorToken: lastToken,
812
+ sameBranch: false,
776
813
  diagnostics: {
777
814
  malformedPayloadEvents,
778
815
  applyRejectedEvents,
@@ -786,7 +823,6 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
786
823
  },
787
824
  };
788
825
  } finally {
789
- remote.close();
790
826
  storage.close();
791
827
  }
792
828
  }
package/src/sync/types.ts CHANGED
@@ -12,6 +12,7 @@ export interface SyncStatusSummary {
12
12
  readonly ahead: number;
13
13
  readonly behind: number;
14
14
  readonly pendingConflicts: number;
15
+ readonly sameBranch: boolean;
15
16
  readonly git: GitContextSnapshot;
16
17
  }
17
18
 
@@ -21,6 +22,7 @@ export interface PullSummary {
21
22
  readonly appliedEvents: number;
22
23
  readonly createdConflicts: number;
23
24
  readonly cursorToken: string | null;
25
+ readonly sameBranch: boolean;
24
26
  readonly diagnostics: SyncPullDiagnostics;
25
27
  }
26
28