|
| 1 | +import { test } from 'node:test'; |
| 2 | +import assert from 'node:assert/strict'; |
| 3 | +import { spawnSync } from 'node:child_process'; |
| 4 | +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; |
| 5 | +import { tmpdir } from 'node:os'; |
| 6 | +import { join } from 'node:path'; |
| 7 | +import { fileURLToPath } from 'node:url'; |
| 8 | +import { DatabaseSync } from 'node:sqlite'; |
| 9 | + |
| 10 | +import { CURRENT_VERSION } from '../db.mjs'; |
| 11 | +import { MIGRATION_SCHEMA, parseArgs, run } from './migrate.mjs'; |
| 12 | + |
| 13 | +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)); |
| 14 | +const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs'); |
| 15 | + |
| 16 | +function runCli(home, args = ['migrate', '--json']) { |
| 17 | + return spawnSync(process.execPath, [BIN_PATH, ...args], { |
| 18 | + cwd: REPO_ROOT, |
| 19 | + env: { ...process.env, HOME: home, USERPROFILE: home }, |
| 20 | + encoding: 'utf8', |
| 21 | + }); |
| 22 | +} |
| 23 | + |
| 24 | +function createV8Db(home) { |
| 25 | + const dir = join(home, '.throughline'); |
| 26 | + mkdirSync(dir, { recursive: true }); |
| 27 | + const db = new DatabaseSync(join(dir, 'throughline.db')); |
| 28 | + db.exec(` |
| 29 | + PRAGMA user_version = 8; |
| 30 | + CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT); |
| 31 | + CREATE TABLE skeletons (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, summary TEXT NOT NULL, created_at INTEGER NOT NULL, origin_session_id TEXT); |
| 32 | + CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, token_count INTEGER, created_at INTEGER NOT NULL, UNIQUE(session_id, origin_session_id, turn_number, role)); |
| 33 | + CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, origin_session_id TEXT, kind TEXT NOT NULL DEFAULT 'tool_input', source_id TEXT); |
| 34 | + CREATE TABLE handoff_batons (project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL); |
| 35 | + CREATE UNIQUE INDEX uq_skeletons_turn_v3 ON skeletons(session_id, origin_session_id, turn_number, role); |
| 36 | + CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL; |
| 37 | + `); |
| 38 | + db.close(); |
| 39 | +} |
| 40 | + |
| 41 | +test('migrate CLI migrates a v8 fixture to the current schema', () => { |
| 42 | + const home = mkdtempSync(join(tmpdir(), 'tl-migrate-v8-')); |
| 43 | + try { |
| 44 | + createV8Db(home); |
| 45 | + const result = runCli(home); |
| 46 | + assert.equal(result.status, 0, result.stderr); |
| 47 | + assert.deepEqual(JSON.parse(result.stdout), { |
| 48 | + schema: MIGRATION_SCHEMA, |
| 49 | + status: 'migrated', |
| 50 | + beforeSchemaVersion: 8, |
| 51 | + afterSchemaVersion: CURRENT_VERSION, |
| 52 | + supportedSchemaVersion: CURRENT_VERSION, |
| 53 | + }); |
| 54 | + const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'), { readOnly: true }); |
| 55 | + assert.equal(db.prepare('PRAGMA user_version').get().user_version, CURRENT_VERSION); |
| 56 | + assert.deepEqual(db.prepare('PRAGMA table_info(pending_handoffs)').all().map((row) => row.name), [ |
| 57 | + 'session_id', 'project_path', 'source', 'auto_predecessor_id', 'created_at', |
| 58 | + ]); |
| 59 | + db.close(); |
| 60 | + } finally { |
| 61 | + rmSync(home, { recursive: true, force: true }); |
| 62 | + } |
| 63 | +}); |
| 64 | + |
| 65 | +test('migrate CLI is idempotent for the current schema', () => { |
| 66 | + const home = mkdtempSync(join(tmpdir(), 'tl-migrate-current-')); |
| 67 | + try { |
| 68 | + createV8Db(home); |
| 69 | + assert.equal(runCli(home).status, 0); |
| 70 | + const result = runCli(home); |
| 71 | + assert.equal(result.status, 0, result.stderr); |
| 72 | + assert.equal(JSON.parse(result.stdout).status, 'already_current'); |
| 73 | + } finally { |
| 74 | + rmSync(home, { recursive: true, force: true }); |
| 75 | + } |
| 76 | +}); |
| 77 | + |
| 78 | +test('migrate CLI does not create a missing database', () => { |
| 79 | + const home = mkdtempSync(join(tmpdir(), 'tl-migrate-missing-')); |
| 80 | + try { |
| 81 | + const result = runCli(home); |
| 82 | + assert.equal(result.status, 0, result.stderr); |
| 83 | + assert.equal(JSON.parse(result.stdout).status, 'not_applicable'); |
| 84 | + assert.equal(existsSync(join(home, '.throughline')), false); |
| 85 | + } finally { |
| 86 | + rmSync(home, { recursive: true, force: true }); |
| 87 | + } |
| 88 | +}); |
| 89 | + |
| 90 | +test('migrate CLI rejects a future schema without changing it', () => { |
| 91 | + const home = mkdtempSync(join(tmpdir(), 'tl-migrate-future-')); |
| 92 | + try { |
| 93 | + createV8Db(home); |
| 94 | + const dbPath = join(home, '.throughline', 'throughline.db'); |
| 95 | + const db = new DatabaseSync(dbPath); |
| 96 | + db.exec(`PRAGMA user_version = ${CURRENT_VERSION + 1}`); |
| 97 | + db.close(); |
| 98 | + const result = runCli(home); |
| 99 | + assert.equal(result.status, 1, result.stderr); |
| 100 | + assert.deepEqual(JSON.parse(result.stdout), { |
| 101 | + schema: MIGRATION_SCHEMA, |
| 102 | + status: 'future_schema', |
| 103 | + beforeSchemaVersion: CURRENT_VERSION + 1, |
| 104 | + afterSchemaVersion: CURRENT_VERSION + 1, |
| 105 | + supportedSchemaVersion: CURRENT_VERSION, |
| 106 | + }); |
| 107 | + const verify = new DatabaseSync(dbPath, { readOnly: true }); |
| 108 | + assert.equal(verify.prepare('PRAGMA user_version').get().user_version, CURRENT_VERSION + 1); |
| 109 | + verify.close(); |
| 110 | + } finally { |
| 111 | + rmSync(home, { recursive: true, force: true }); |
| 112 | + } |
| 113 | +}); |
| 114 | + |
| 115 | +test('migrate CLI accepts only --json and does not reflect arguments', () => { |
| 116 | + for (const argv of [[], ['--json', '--json'], ['--db', '/private/secret.db', '--json']]) { |
| 117 | + const output = []; |
| 118 | + assert.equal(run(argv, { stdout: { write(value) { output.push(value); } } }), 2); |
| 119 | + assert.equal(JSON.parse(output[0]).status, 'invalid_request'); |
| 120 | + assert.doesNotMatch(output[0], /private|secret/); |
| 121 | + } |
| 122 | + assert.deepEqual(parseArgs(['--json']), { json: true }); |
| 123 | + assert.throws(() => parseArgs([]), /usage error/); |
| 124 | +}); |
| 125 | + |
| 126 | +test('migrate CLI reports an internal migration failure without reflecting its cause', () => { |
| 127 | + const output = []; |
| 128 | + assert.equal(run(['--json'], { |
| 129 | + stdout: { write(value) { output.push(value); } }, |
| 130 | + migrate() { throw new Error('/private/throughline.db contents'); }, |
| 131 | + }), 1); |
| 132 | + assert.deepEqual(JSON.parse(output[0]), { |
| 133 | + schema: MIGRATION_SCHEMA, |
| 134 | + status: 'migration_failed', |
| 135 | + beforeSchemaVersion: null, |
| 136 | + afterSchemaVersion: null, |
| 137 | + supportedSchemaVersion: CURRENT_VERSION, |
| 138 | + }); |
| 139 | + assert.doesNotMatch(output[0], /private|contents/); |
| 140 | +}); |
0 commit comments