trekoon 0.2.1 → 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;
@@ -700,21 +664,18 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
700
664
  try {
701
665
  persistGitContext(storage.db, git);
702
666
 
703
- const cursor = loadCursor(storage.db, sourceBranch);
667
+ const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
704
668
  const cursorToken: string = cursor?.cursor_token ?? "0:";
705
- const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
669
+ const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
706
670
 
707
- try {
708
- return {
709
- sourceBranch,
710
- ahead: countAhead(storage.db, git.branchName, remote.path),
711
- behind: countBehind(remote.db, cursorToken),
712
- pendingConflicts: countPendingConflicts(storage.db),
713
- git,
714
- };
715
- } finally {
716
- remote.close();
717
- }
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
+ };
718
679
  } finally {
719
680
  storage.close();
720
681
  }
@@ -722,15 +683,48 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
722
683
 
723
684
  export function syncPull(cwd: string, sourceBranch: string): PullSummary {
724
685
  const storage = openTrekoonDatabase(cwd);
725
- const git = resolveGitContext(cwd);
726
- persistGitContext(storage.db, git);
727
-
728
- const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
729
686
 
730
687
  try {
731
- 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);
732
691
  const cursorToken = cursor?.cursor_token ?? "0:";
733
- 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
+ }
734
728
 
735
729
  let appliedEvents = 0;
736
730
  let createdConflicts = 0;
@@ -805,7 +799,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
805
799
  }
806
800
 
807
801
  if (lastToken) {
808
- saveCursor(storage.db, sourceBranch, lastToken, lastEventAt);
802
+ saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
809
803
  }
810
804
  })();
811
805
 
@@ -815,6 +809,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
815
809
  appliedEvents,
816
810
  createdConflicts,
817
811
  cursorToken: lastToken,
812
+ sameBranch: false,
818
813
  diagnostics: {
819
814
  malformedPayloadEvents,
820
815
  applyRejectedEvents,
@@ -828,7 +823,6 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
828
823
  },
829
824
  };
830
825
  } finally {
831
- remote.close();
832
826
  storage.close();
833
827
  }
834
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