trekoon 0.4.1 → 0.4.2
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 +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -1
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +45 -13
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -1,7 +1,27 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
1
4
|
import { Database } from "bun:sqlite";
|
|
2
5
|
|
|
6
|
+
import { DomainError } from "../domain/types";
|
|
3
7
|
import { BASE_SCHEMA_STATEMENTS, SCHEMA_VERSION } from "./schema";
|
|
4
8
|
|
|
9
|
+
const BACKUP_HINT = "Run 'trekoon migrate backup' to snapshot .trekoon/trekoon.db before any manual recovery.";
|
|
10
|
+
|
|
11
|
+
function migrationDownUnsupported(migrationName: string, version: number): DomainError {
|
|
12
|
+
return new DomainError({
|
|
13
|
+
code: "migration_down_unsupported",
|
|
14
|
+
message:
|
|
15
|
+
`Migration ${migrationName} is irreversible: rolling back below version ${version} is not supported. ` +
|
|
16
|
+
`${BACKUP_HINT}`,
|
|
17
|
+
details: {
|
|
18
|
+
migrationName,
|
|
19
|
+
version,
|
|
20
|
+
backupCommand: "trekoon migrate backup",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
const BASE_MIGRATION_VERSION = 1;
|
|
6
26
|
const BASE_MIGRATION_NAME = `0001_base_schema_v${SCHEMA_VERSION}`;
|
|
7
27
|
const LEGACY_BASE_MIGRATION_NAME_PATTERNS: readonly string[] = [
|
|
@@ -113,6 +133,57 @@ const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
|
|
|
113
133
|
"DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
|
|
114
134
|
];
|
|
115
135
|
|
|
136
|
+
const SYNC_CONFLICTS_SCOPE_DOWN_STATEMENTS: readonly string[] = [
|
|
137
|
+
"DROP INDEX IF EXISTS idx_sync_conflicts_scope_entity;",
|
|
138
|
+
"DROP INDEX IF EXISTS idx_sync_conflicts_scope_resolution;",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
function migrateSyncConflictsScope(db: Database): void {
|
|
142
|
+
if (!tableExists(db, "sync_conflicts")) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!tableHasColumn(db, "sync_conflicts", "worktree_path")) {
|
|
147
|
+
db.exec("ALTER TABLE sync_conflicts ADD COLUMN worktree_path TEXT NOT NULL DEFAULT '';");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!tableHasColumn(db, "sync_conflicts", "current_branch")) {
|
|
151
|
+
db.exec("ALTER TABLE sync_conflicts ADD COLUMN current_branch TEXT NOT NULL DEFAULT '';");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Backfill legacy rows from the most-recent git_context entry so existing
|
|
155
|
+
// pending conflicts remain reachable to the current worktree. Pre-existing
|
|
156
|
+
// rows from peer worktrees (rare in practice — pre-fix the bug erased
|
|
157
|
+
// them anyway) end up scoped to the current worktree's branch; this is a
|
|
158
|
+
// best-effort migration since we have no historical context to recover.
|
|
159
|
+
db.exec(`
|
|
160
|
+
UPDATE sync_conflicts
|
|
161
|
+
SET worktree_path = COALESCE(
|
|
162
|
+
NULLIF(worktree_path, ''),
|
|
163
|
+
(SELECT worktree_path FROM git_context ORDER BY updated_at DESC LIMIT 1),
|
|
164
|
+
''
|
|
165
|
+
)
|
|
166
|
+
WHERE worktree_path IS NULL OR worktree_path = '';
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
db.exec(`
|
|
170
|
+
UPDATE sync_conflicts
|
|
171
|
+
SET current_branch = COALESCE(
|
|
172
|
+
NULLIF(current_branch, ''),
|
|
173
|
+
(SELECT branch_name FROM git_context ORDER BY updated_at DESC LIMIT 1),
|
|
174
|
+
''
|
|
175
|
+
)
|
|
176
|
+
WHERE current_branch IS NULL OR current_branch = '';
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
db.exec(
|
|
180
|
+
"CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_entity ON sync_conflicts(worktree_path, current_branch, entity_kind, entity_id);",
|
|
181
|
+
);
|
|
182
|
+
db.exec(
|
|
183
|
+
"CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_resolution ON sync_conflicts(worktree_path, current_branch, resolution);",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
116
187
|
function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
|
|
117
188
|
const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
|
|
118
189
|
return columns.some((column) => column.name === columnName);
|
|
@@ -266,12 +337,7 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
266
337
|
migrateWorktreeScopedSyncMetadata(db);
|
|
267
338
|
},
|
|
268
339
|
down(_db: Database): void {
|
|
269
|
-
throw
|
|
270
|
-
"Migration 0004 (worktree_scoped_sync_metadata) is irreversible. " +
|
|
271
|
-
"It adds columns via ALTER TABLE that cannot be removed without " +
|
|
272
|
-
"reconstructing tables and risking data loss. " +
|
|
273
|
-
"Rollback below version 4 is not supported.",
|
|
274
|
-
);
|
|
340
|
+
throw migrationDownUnsupported("0004_worktree_scoped_sync_metadata", 4);
|
|
275
341
|
},
|
|
276
342
|
},
|
|
277
343
|
{
|
|
@@ -298,11 +364,7 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
298
364
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_edge ON dependencies (source_id, depends_on_id);");
|
|
299
365
|
},
|
|
300
366
|
down(_db: Database): void {
|
|
301
|
-
throw
|
|
302
|
-
"Migration 0005 (dependency_edge_integrity) is irreversible. " +
|
|
303
|
-
"It removes orphaned rows and deduplicates dependency edges. " +
|
|
304
|
-
"Rollback below version 5 is not supported.",
|
|
305
|
-
);
|
|
367
|
+
throw migrationDownUnsupported("0005_dependency_edge_integrity", 5);
|
|
306
368
|
},
|
|
307
369
|
},
|
|
308
370
|
{
|
|
@@ -317,12 +379,7 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
317
379
|
}
|
|
318
380
|
},
|
|
319
381
|
down(_db: Database): void {
|
|
320
|
-
throw
|
|
321
|
-
"Migration 0006 (add_owner_column) is irreversible. " +
|
|
322
|
-
"It adds columns via ALTER TABLE that cannot be removed without " +
|
|
323
|
-
"reconstructing tables and risking data loss. " +
|
|
324
|
-
"Rollback below version 6 is not supported.",
|
|
325
|
-
);
|
|
382
|
+
throw migrationDownUnsupported("0006_add_owner_column", 6);
|
|
326
383
|
},
|
|
327
384
|
},
|
|
328
385
|
{
|
|
@@ -381,6 +438,22 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
381
438
|
}
|
|
382
439
|
},
|
|
383
440
|
},
|
|
441
|
+
{
|
|
442
|
+
version: 11,
|
|
443
|
+
name: "0011_sync_conflicts_worktree_branch_scope",
|
|
444
|
+
up(db: Database): void {
|
|
445
|
+
migrateSyncConflictsScope(db);
|
|
446
|
+
},
|
|
447
|
+
down(db: Database): void {
|
|
448
|
+
// Dropping columns requires a table rewrite in SQLite (PRAGMA) — not
|
|
449
|
+
// strictly reversible without potential data loss. We drop the new
|
|
450
|
+
// indexes; the columns persist with their default empty-string values
|
|
451
|
+
// so a re-up no-ops cleanly.
|
|
452
|
+
for (const statement of SYNC_CONFLICTS_SCOPE_DOWN_STATEMENTS) {
|
|
453
|
+
db.exec(statement);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
},
|
|
384
457
|
];
|
|
385
458
|
|
|
386
459
|
function migrationTableExists(db: Database): boolean {
|
|
@@ -525,6 +598,243 @@ function recordMigration(db: Database, migration: Migration): void {
|
|
|
525
598
|
);
|
|
526
599
|
}
|
|
527
600
|
|
|
601
|
+
/** Name of the marker file relative to the .trekoon storage directory. */
|
|
602
|
+
const MIGRATION_VERSION_MARKER_FILENAME = "migration-version";
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* On-disk marker payload. The legacy v1 format was a bare integer. v2 stores a
|
|
606
|
+
* JSON object so we can pin the marker to a content fingerprint of the DB
|
|
607
|
+
* (PRAGMA user_version) instead of relying on filesystem mtimes — which the
|
|
608
|
+
* old defensive check could not distinguish from a DB restored from a backup
|
|
609
|
+
* with an older user_version but a newer mtime.
|
|
610
|
+
*/
|
|
611
|
+
interface MarkerPayload {
|
|
612
|
+
readonly version: number;
|
|
613
|
+
readonly userVersion: number;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Derive the path to the migration-version marker file from the database
|
|
618
|
+
* connection's filename. Returns null for in-memory databases.
|
|
619
|
+
*/
|
|
620
|
+
function resolveMarkerPath(db: Database): string | null {
|
|
621
|
+
const dbFile: string = db.filename;
|
|
622
|
+
if (!dbFile || dbFile === ":memory:") {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
return join(dirname(dbFile), MIGRATION_VERSION_MARKER_FILENAME);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Read `PRAGMA user_version` from the connection. SQLite stores this as a
|
|
630
|
+
* 32-bit signed integer in the database header — every restore-from-backup
|
|
631
|
+
* naturally carries the original `user_version` along with the bytes.
|
|
632
|
+
*/
|
|
633
|
+
function readUserVersion(db: Database): number {
|
|
634
|
+
const row = db.query("PRAGMA user_version;").get() as { user_version: number } | null;
|
|
635
|
+
return row?.user_version ?? 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Set `PRAGMA user_version`. Used as a content-stamp written inside the same
|
|
640
|
+
* transaction that applies (or rolls back) migrations so the DB header is
|
|
641
|
+
* always authoritative for the current schema state — independent of the
|
|
642
|
+
* sidecar marker file.
|
|
643
|
+
*
|
|
644
|
+
* NOTE: PRAGMA does not support parameter binding. The caller must pass an
|
|
645
|
+
* integer or a value that will throw at the SQL level otherwise.
|
|
646
|
+
*/
|
|
647
|
+
function setUserVersion(db: Database, version: number): void {
|
|
648
|
+
if (!Number.isInteger(version) || version < 0) {
|
|
649
|
+
throw new Error(`PRAGMA user_version must be a non-negative integer (got ${version}).`);
|
|
650
|
+
}
|
|
651
|
+
db.exec(`PRAGMA user_version = ${version};`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Read the marker payload from disk. Falls back to parsing the legacy v1
|
|
656
|
+
* format (bare integer) so previously-installed markers still work without a
|
|
657
|
+
* cold reset.
|
|
658
|
+
*/
|
|
659
|
+
function readMarkerPayload(markerPath: string): MarkerPayload | null {
|
|
660
|
+
try {
|
|
661
|
+
if (!existsSync(markerPath)) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const raw: string = readFileSync(markerPath, "utf8").trim();
|
|
666
|
+
if (raw.length === 0) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (raw.startsWith("{")) {
|
|
671
|
+
const parsed = JSON.parse(raw) as Partial<MarkerPayload>;
|
|
672
|
+
const version: number | undefined = parsed.version;
|
|
673
|
+
const userVersion: number | undefined = parsed.userVersion;
|
|
674
|
+
if (
|
|
675
|
+
typeof version !== "number" ||
|
|
676
|
+
!Number.isFinite(version) ||
|
|
677
|
+
version < 0 ||
|
|
678
|
+
typeof userVersion !== "number" ||
|
|
679
|
+
!Number.isFinite(userVersion) ||
|
|
680
|
+
userVersion < 0
|
|
681
|
+
) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
return { version, userVersion };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Legacy v1 format: bare integer. Treat it as having no fingerprint;
|
|
688
|
+
// returning userVersion: -1 forces canSkipProbeViaMarker to fall through
|
|
689
|
+
// to the slow path, which then rewrites the marker in v2 form.
|
|
690
|
+
const legacyVersion: number = parseInt(raw, 10);
|
|
691
|
+
if (!Number.isFinite(legacyVersion) || legacyVersion < 0) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
return { version: legacyVersion, userVersion: -1 };
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Read the migration version stored in the marker file. Returns null when the
|
|
702
|
+
* file is absent, unreadable, or malformed. Kept for backwards-compatible
|
|
703
|
+
* test/inspection use; the in-process fast-path uses {@link readMarkerPayload}.
|
|
704
|
+
*/
|
|
705
|
+
export function readMigrationVersionMarker(db: Database): number | null {
|
|
706
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
707
|
+
if (!markerPath) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const payload: MarkerPayload | null = readMarkerPayload(markerPath);
|
|
712
|
+
return payload?.version ?? null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface WriteMarkerResult {
|
|
716
|
+
readonly written: boolean;
|
|
717
|
+
readonly skipped: boolean;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Internal marker writer that surfaces the outcome to callers. Writes
|
|
722
|
+
* atomically via temp + rename. Returns `{written:false}` on filesystem
|
|
723
|
+
* errors so callers (e.g. {@link rollbackDatabase}) can attempt a stale-marker
|
|
724
|
+
* cleanup instead of silently leaving a misleading hint on disk.
|
|
725
|
+
*/
|
|
726
|
+
function writeMarkerPayload(
|
|
727
|
+
db: Database,
|
|
728
|
+
payload: MarkerPayload,
|
|
729
|
+
): WriteMarkerResult {
|
|
730
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
731
|
+
if (!markerPath) {
|
|
732
|
+
return { written: false, skipped: true };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
mkdirSync(dirname(markerPath), { recursive: true });
|
|
737
|
+
const tmpPath = `${markerPath}.tmp`;
|
|
738
|
+
writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
|
|
739
|
+
renameSync(tmpPath, markerPath);
|
|
740
|
+
return { written: true, skipped: false };
|
|
741
|
+
} catch {
|
|
742
|
+
return { written: false, skipped: false };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Best-effort: remove a stale marker so the next cold start re-probes the
|
|
748
|
+
* schema. Used when a fresh marker write fails after a rollback (or other
|
|
749
|
+
* version-changing op) to avoid leaving the previous version's marker on disk
|
|
750
|
+
* pointing at a now-incorrect schema state.
|
|
751
|
+
*/
|
|
752
|
+
function unlinkMarkerIfExists(db: Database): void {
|
|
753
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
754
|
+
if (!markerPath) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
if (existsSync(markerPath)) {
|
|
759
|
+
unlinkSync(markerPath);
|
|
760
|
+
}
|
|
761
|
+
} catch (error) {
|
|
762
|
+
// Defense-in-depth — the worst case is that the next cold start spends
|
|
763
|
+
// one extra schema probe noticing the marker mismatch. We surface the
|
|
764
|
+
// failure at warn level (System Hardening 0.4.2, finding 30) so that
|
|
765
|
+
// operators can spot recurring stale-marker issues without escalating
|
|
766
|
+
// a one-off cleanup failure to a hard error.
|
|
767
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
768
|
+
console.warn(
|
|
769
|
+
`[trekoon] failed to unlink stale schema marker at ${markerPath}: ${message}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Write the migration version to the marker file atomically (temp + rename).
|
|
776
|
+
* Public, backwards-compatible signature. Reads the current PRAGMA user_version
|
|
777
|
+
* from the connection so fast-path consumers can verify the marker against the
|
|
778
|
+
* DB header on the next call.
|
|
779
|
+
*/
|
|
780
|
+
export function writeMigrationVersionMarker(db: Database, version: number): void {
|
|
781
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
782
|
+
if (!markerPath) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const userVersion: number = readUserVersion(db);
|
|
786
|
+
writeMarkerPayload(db, { version, userVersion });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Determine whether the marker file allows skipping the schema probe query.
|
|
791
|
+
* Returns true only when:
|
|
792
|
+
* 1. The marker file exists, parses cleanly, and reports
|
|
793
|
+
* `version === LATEST_MIGRATION_VERSION`.
|
|
794
|
+
* 2. The DB's PRAGMA user_version matches the marker's recorded user_version
|
|
795
|
+
* AND equals `LATEST_MIGRATION_VERSION`.
|
|
796
|
+
*
|
|
797
|
+
* The user_version match is the load-bearing freshness check — replacing the
|
|
798
|
+
* previous mtime heuristic, which could not distinguish a DB restored from an
|
|
799
|
+
* older backup (older user_version, fresh mtime) from a healthy current DB.
|
|
800
|
+
* Restoring a backup brings the original user_version with the bytes, so the
|
|
801
|
+
* pragma check naturally fails over to the slow path even when the marker
|
|
802
|
+
* file is "newer" than the DB on disk.
|
|
803
|
+
*/
|
|
804
|
+
function canSkipProbeViaMarker(db: Database): boolean {
|
|
805
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
806
|
+
if (!markerPath) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
const payload: MarkerPayload | null = readMarkerPayload(markerPath);
|
|
812
|
+
if (!payload) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (payload.version !== LATEST_MIGRATION_VERSION) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (payload.userVersion !== LATEST_MIGRATION_VERSION) {
|
|
821
|
+
// Legacy v1 markers report userVersion: -1; force probe so the marker
|
|
822
|
+
// gets rewritten in v2 form.
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const dbFile: string = db.filename;
|
|
827
|
+
if (!dbFile || !existsSync(dbFile)) {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const dbUserVersion: number = readUserVersion(db);
|
|
832
|
+
return dbUserVersion === payload.userVersion;
|
|
833
|
+
} catch {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
528
838
|
function isSchemaCurrentFastPath(db: Database, latestVersion: number): boolean {
|
|
529
839
|
if (latestVersion === 0 || !migrationTableExists(db) || !hasMigrationVersionColumn(db)) {
|
|
530
840
|
return false;
|
|
@@ -567,12 +877,49 @@ export function migrateDatabase(db: Database): void {
|
|
|
567
877
|
|
|
568
878
|
const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
569
879
|
|
|
570
|
-
//
|
|
880
|
+
// Marker fast path: skip ALL probe queries when the persisted marker file
|
|
881
|
+
// records the latest version and is newer than the DB file. This saves the
|
|
882
|
+
// schema_migrations SELECT on warm CLI starts.
|
|
883
|
+
if (canSkipProbeViaMarker(db)) {
|
|
884
|
+
migrateBoardIdempotencyState(db);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Schema fast path: avoid BEGIN EXCLUSIVE when schema is already current.
|
|
571
889
|
// This reduces startup lock contention while keeping the explicit
|
|
572
890
|
// transactional migration path for non-current/legacy schemas.
|
|
573
891
|
if (isSchemaCurrentFastPath(db, latestVersion)) {
|
|
574
892
|
migrateBoardIdempotencyState(db);
|
|
575
|
-
|
|
893
|
+
|
|
894
|
+
// Re-confirm the schema state under an EXCLUSIVE lock before stamping
|
|
895
|
+
// the DB header. Without the lock, a concurrent rollback in another
|
|
896
|
+
// process can interleave between the unlocked probe above and the
|
|
897
|
+
// setUserVersion call below, leaving PRAGMA user_version pointing at a
|
|
898
|
+
// higher version than schema_migrations actually reflects. If the
|
|
899
|
+
// recheck disagrees we bail to the slow migrate path which will
|
|
900
|
+
// re-apply any missing migrations transactionally.
|
|
901
|
+
let fastPathConfirmed = false;
|
|
902
|
+
runExclusive(db, (): void => {
|
|
903
|
+
if (currentVersion(db) !== latestVersion) {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Stamp the DB header inside the same exclusive transaction so the
|
|
907
|
+
// user_version cannot diverge from schema_migrations on commit.
|
|
908
|
+
// This also covers first runs after the v1 marker format upgrade and
|
|
909
|
+
// restored-from-backup DBs whose schema_migrations happens to match
|
|
910
|
+
// latest but whose user_version still trails.
|
|
911
|
+
setUserVersion(db, latestVersion);
|
|
912
|
+
fastPathConfirmed = true;
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
if (fastPathConfirmed) {
|
|
916
|
+
// Persist the marker so the next cold start can short-circuit. The
|
|
917
|
+
// marker is a sidecar hint — even if this write fails, the DB header
|
|
918
|
+
// (stamped inside the tx above) remains authoritative.
|
|
919
|
+
writeMigrationVersionMarker(db, latestVersion);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// Disagreement: fall through to the slow path below.
|
|
576
923
|
}
|
|
577
924
|
|
|
578
925
|
runExclusive(db, (): void => {
|
|
@@ -594,7 +941,37 @@ export function migrateDatabase(db: Database): void {
|
|
|
594
941
|
migration.up(db);
|
|
595
942
|
recordMigration(db, migration);
|
|
596
943
|
}
|
|
944
|
+
|
|
945
|
+
// Stamp the DB header inside the same exclusive transaction so the
|
|
946
|
+
// user_version cannot diverge from schema_migrations on commit.
|
|
947
|
+
setUserVersion(db, latestVersion);
|
|
597
948
|
});
|
|
949
|
+
|
|
950
|
+
// Persist the new version so the next cold start can skip the probe.
|
|
951
|
+
writeMigrationVersionMarker(db, latestVersion);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export const LATEST_MIGRATION_VERSION: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Read the highest applied migration version from a database without mutating
|
|
958
|
+
* it. Safe to call against a connection opened in `readonly: true` mode.
|
|
959
|
+
* Returns 0 when the schema_migrations table does not exist or has no rows.
|
|
960
|
+
*/
|
|
961
|
+
export function readCurrentMigrationVersionReadOnly(db: Database): number {
|
|
962
|
+
if (!migrationTableExists(db)) {
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (!hasMigrationVersionColumn(db)) {
|
|
967
|
+
return 0;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const row = db
|
|
971
|
+
.query("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE version IS NOT NULL;")
|
|
972
|
+
.get() as { version: number } | null;
|
|
973
|
+
|
|
974
|
+
return row?.version ?? 0;
|
|
598
975
|
}
|
|
599
976
|
|
|
600
977
|
export function describeMigrations(db: Database): MigrationStatus {
|
|
@@ -628,7 +1005,7 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
|
|
|
628
1005
|
throw new Error("Rollback target version must be a non-negative integer.");
|
|
629
1006
|
}
|
|
630
1007
|
|
|
631
|
-
|
|
1008
|
+
const summary: RollbackSummary = runExclusive(db, (): RollbackSummary => {
|
|
632
1009
|
ensureMigrationTable(db);
|
|
633
1010
|
ensureMigrationVersionColumn(db);
|
|
634
1011
|
validateMigrationPlan();
|
|
@@ -658,6 +1035,13 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
|
|
|
658
1035
|
rolledBackMigrations.push(migration.name);
|
|
659
1036
|
}
|
|
660
1037
|
|
|
1038
|
+
// Stamp the DB header inside the rollback transaction so the
|
|
1039
|
+
// user_version is authoritative for the post-rollback schema state. If
|
|
1040
|
+
// the sidecar marker write below fails for any reason, the next
|
|
1041
|
+
// canSkipProbeViaMarker() check will see a stale marker.userVersion and
|
|
1042
|
+
// fall through to the slow probe path — no silent drift.
|
|
1043
|
+
setUserVersion(db, targetVersion);
|
|
1044
|
+
|
|
661
1045
|
return {
|
|
662
1046
|
fromVersion,
|
|
663
1047
|
toVersion: targetVersion,
|
|
@@ -665,4 +1049,22 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
|
|
|
665
1049
|
rolledBackMigrations,
|
|
666
1050
|
};
|
|
667
1051
|
});
|
|
1052
|
+
|
|
1053
|
+
// Update the marker so the next start reflects the rolled-back version.
|
|
1054
|
+
// If the write fails, attempt to delete the now-stale marker so a future
|
|
1055
|
+
// cold start re-probes instead of trusting a marker that points at the
|
|
1056
|
+
// pre-rollback version.
|
|
1057
|
+
const markerPath: string | null = resolveMarkerPath(db);
|
|
1058
|
+
if (markerPath) {
|
|
1059
|
+
const userVersion: number = readUserVersion(db);
|
|
1060
|
+
const markerResult: WriteMarkerResult = writeMarkerPayload(db, {
|
|
1061
|
+
version: targetVersion,
|
|
1062
|
+
userVersion,
|
|
1063
|
+
});
|
|
1064
|
+
if (!markerResult.written && !markerResult.skipped) {
|
|
1065
|
+
unlinkMarkerIfExists(db);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return summary;
|
|
668
1070
|
}
|
package/src/storage/path.ts
CHANGED
|
@@ -74,6 +74,14 @@ export interface StoragePathDiagnostics {
|
|
|
74
74
|
|
|
75
75
|
const storagePathCache: Map<string, StoragePaths> = new Map();
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Clear the process-level storage path cache.
|
|
79
|
+
* Intended for test isolation only — production code should never call this.
|
|
80
|
+
*/
|
|
81
|
+
export function clearStoragePathCache(): void {
|
|
82
|
+
storagePathCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
function resolveGitPath(workingDirectory: string, argument: "--git-common-dir" | "--show-toplevel"): string | null {
|
|
78
86
|
const result = spawnSync("git", ["rev-parse", argument], {
|
|
79
87
|
cwd: workingDirectory,
|
package/src/storage/schema.ts
CHANGED
|
@@ -112,7 +112,9 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
|
112
112
|
resolution TEXT NOT NULL DEFAULT 'pending',
|
|
113
113
|
created_at INTEGER NOT NULL,
|
|
114
114
|
updated_at INTEGER NOT NULL,
|
|
115
|
-
version INTEGER NOT NULL DEFAULT 1
|
|
115
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
116
|
+
worktree_path TEXT NOT NULL DEFAULT '',
|
|
117
|
+
current_branch TEXT NOT NULL DEFAULT ''
|
|
116
118
|
);
|
|
117
119
|
`,
|
|
118
120
|
`
|
|
@@ -136,6 +138,8 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
|
136
138
|
`CREATE INDEX IF NOT EXISTS idx_sync_cursors_owner ON sync_cursors(owner_scope, owner_worktree_path, source_branch);`,
|
|
137
139
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
|
|
138
140
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
|
|
141
|
+
`CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_entity ON sync_conflicts(worktree_path, current_branch, entity_kind, entity_id);`,
|
|
142
|
+
`CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_resolution ON sync_conflicts(worktree_path, current_branch, resolution);`,
|
|
139
143
|
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
|
|
140
144
|
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
|
|
141
145
|
];
|
package/src/sync/event-writes.ts
CHANGED
|
@@ -18,6 +18,14 @@ interface EventWriteContext {
|
|
|
18
18
|
|
|
19
19
|
const transactionEventContexts: WeakMap<Database, EventWriteContext> = new WeakMap();
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Compute the next safe event timestamp INSIDE a write lock.
|
|
23
|
+
*
|
|
24
|
+
* Must only be called after BEGIN IMMEDIATE has been issued on `db`.
|
|
25
|
+
* Reading the max(created_at) while holding the write lock guarantees no
|
|
26
|
+
* concurrent writer can commit a higher timestamp between the read and the
|
|
27
|
+
* subsequent INSERT, preventing (created_at, id) collisions.
|
|
28
|
+
*/
|
|
21
29
|
export function nextEventTimestamp(db: Database): number {
|
|
22
30
|
const now: number = Date.now();
|
|
23
31
|
const latestEvent = db
|
|
@@ -38,22 +46,41 @@ export function nextEventTimestamp(db: Database): number {
|
|
|
38
46
|
return Math.max(now, latestEvent.created_at + 1);
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Execute `fn` inside a transaction event context, computing the event
|
|
51
|
+
* timestamp AFTER the write lock is held (i.e. after BEGIN IMMEDIATE).
|
|
52
|
+
*
|
|
53
|
+
* The caller MUST resolve the git context BEFORE entering the write
|
|
54
|
+
* transaction and pass it in via `git`. This keeps the (potentially slow)
|
|
55
|
+
* `git branch` / `git rev-parse` subprocesses outside the SQLite write lock,
|
|
56
|
+
* so concurrent writers on a cold git-context cache do not serialize on
|
|
57
|
+
* subprocess invocations behind BEGIN IMMEDIATE.
|
|
58
|
+
*
|
|
59
|
+
* Only `nextEventTimestamp(db)` runs inside the lock — reading
|
|
60
|
+
* `MAX(created_at)` while the write lock is held is what guarantees no
|
|
61
|
+
* concurrent writer can commit a higher timestamp before our INSERT.
|
|
62
|
+
*
|
|
63
|
+
* If a context is already active for this database connection (nested call),
|
|
64
|
+
* `fn` is invoked directly so the outer context's monotonic counter is
|
|
65
|
+
* reused; the supplied `git` argument is ignored in that case.
|
|
66
|
+
*
|
|
67
|
+
* @param db - The database connection that is already inside BEGIN IMMEDIATE.
|
|
68
|
+
* @param git - Pre-resolved git context (resolved BEFORE the write lock).
|
|
69
|
+
* @param fn - The transaction body to run.
|
|
70
|
+
*/
|
|
71
|
+
export function withTransactionEventContext<T>(db: Database, git: ResolvedGitContext, fn: () => T): T {
|
|
52
72
|
const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
|
|
53
73
|
if (existingContext) {
|
|
54
74
|
return fn();
|
|
55
75
|
}
|
|
56
76
|
|
|
77
|
+
// Compute the timestamp NOW — the write lock is already held. The git
|
|
78
|
+
// context was resolved by the caller before BEGIN IMMEDIATE, so no
|
|
79
|
+
// subprocess invocations happen here.
|
|
80
|
+
const nextTimestamp: number = nextEventTimestamp(db);
|
|
81
|
+
const resolvedGit: ResolvedGitContext = { ...git, persistedAt: nextTimestamp };
|
|
82
|
+
const context: EventWriteContext = { git: resolvedGit, nextTimestamp };
|
|
83
|
+
|
|
57
84
|
transactionEventContexts.set(db, context);
|
|
58
85
|
|
|
59
86
|
try {
|