Skip to content

Commit 6fd9554

Browse files
committed
feat: 製品所有DB migration入口を追加する
1 parent 2e01642 commit 6fd9554

5 files changed

Lines changed: 262 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ hard failure、`projection_pending`契約は変更していない。focused 16/1
198198
| [src/cli/observer-read.mjs](src/cli/observer-read.mjs) | `observer-read` — existing absolute project向けJSON-only completed-turn page。opaque cursorを受け、snapshot / delta / thread・host switch、`resync_required``projection_pending`を返す |
199199
| [src/cli/observer-wait.mjs](src/cli/observer-wait.mjs) | `observer-wait` — opaque after cursorから最大3600秒待機し、`changed` / `timeout` / `resync_required` / `ambiguous_parent`だけをJSONで返す。cancelは成功に丸めない |
200200
| [src/cli/runtime-errors.mjs](src/cli/runtime-errors.mjs) | `runtime-errors snapshot\|diagnostics\|ack\|resolve\|reopen\|compact --json` — product-owned store の bounded JSON API。snapshot/diagnostics は state path を出さず、mutation API は cursor または fingerprint だけを受け付ける |
201+
| [src/cli/migrate.mjs](src/cli/migrate.mjs) | `migrate --json` — 既存の Throughline DB だけを production migration で現行 schema へ移行する正規入口。DB 不在時は作成せず `not_applicable`、現行は `already_current`、future schema と migration failure は非 0 の固定 JSON で明示する |
201202
| [src/cli/codex-capture.mjs](src/cli/codex-capture.mjs) | `codex-capture` — 明示 Codex thread id の rollout active turns を `codex:<thread_id>` session として DB に保存する。thread id が無い場合は自動推測しない |
202203
| [src/cli/codex-summarize.mjs](src/cli/codex-summarize.mjs) | `codex-summarize` — captured `codex:<thread_id>` session の古い L2 を Codex CLI backend で L1 skeleton に要約する。Claude Haiku へ fallback しない |
203204
| [src/cli/codex-resume.mjs](src/cli/codex-resume.mjs) | `codex-resume` — Codex primary 用 active-work context を DB から描画する。`--format handoff` で current thread を mutate しない新規 Codex thread 用 handoff prompt を出す。handoff は L2 件数 / 本文長 / detail refs を cap し、full context は通常 text renderer に残す。`--format item-json` で developer message item JSON を出す。`--memo-stdin` で Codex-primary in-flight memo を先頭に足す |

bin/throughline.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* throughline handoff-preview # Codex-facing throughline_handoff JSON preview
1414
* throughline auditor-context --json # Read-only bounded auditor context JSON
1515
* throughline factory-diagnostics --json # Native factory read-only readiness JSON
16+
* throughline migrate --json # Migrate the existing Throughline database only
1617
* throughline runtime-errors snapshot --json # Product-owned runtime error aggregates
1718
* throughline codex-capture # Capture active Codex rollout turns into Throughline DB
1819
* throughline codex-hook user-prompt-submit # Codex current-session auto-refresh prompt hook
@@ -95,6 +96,11 @@ switch (cmd) {
9596
if (exitCode !== 0) process.exitCode = exitCode;
9697
break;
9798
}
99+
case 'migrate': {
100+
const exitCode = (await import('../src/cli/migrate.mjs')).run(rest);
101+
if (exitCode !== 0) process.exitCode = exitCode;
102+
break;
103+
}
98104
case 'runtime-errors': {
99105
const exitCode = (await import('../src/cli/runtime-errors.mjs')).run(rest);
100106
if (exitCode !== 0) process.exitCode = exitCode;
@@ -207,6 +213,8 @@ Usage:
207213
throughline factory-diagnostics --json
208214
Read-only native factory readiness JSON. Never emits
209215
session/prompt bodies, secrets, absolute paths, or raw state
216+
throughline migrate --json Migrate the existing Throughline database only.
217+
Does not create a missing database and emits a versioned JSON result
210218
throughline runtime-errors snapshot --json
211219
Read bounded local runtime error aggregates. Also supports
212220
diagnostics, ack <cursor>, resolve <fingerprint>, and compact

src/cli/migrate.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { CURRENT_VERSION, DatabaseMigrationError, migrateDefaultDb } from '../db.mjs';
2+
3+
export const MIGRATION_SCHEMA = 'throughline.database_migration.v1';
4+
5+
export function parseArgs(argv = []) {
6+
if (argv.length !== 1 || argv[0] !== '--json') throw new TypeError('usage error');
7+
return { json: true };
8+
}
9+
10+
function result({ status, beforeSchemaVersion, afterSchemaVersion }) {
11+
return {
12+
schema: MIGRATION_SCHEMA,
13+
status,
14+
beforeSchemaVersion,
15+
afterSchemaVersion,
16+
supportedSchemaVersion: CURRENT_VERSION,
17+
};
18+
}
19+
20+
export function run(argv = [], { stdout = process.stdout, migrate = migrateDefaultDb } = {}) {
21+
try {
22+
parseArgs(argv);
23+
} catch {
24+
stdout.write(`${JSON.stringify(result({
25+
status: 'invalid_request', beforeSchemaVersion: null, afterSchemaVersion: null,
26+
}))}\n`);
27+
return 2;
28+
}
29+
30+
try {
31+
stdout.write(`${JSON.stringify(result(migrate()))}\n`);
32+
return 0;
33+
} catch (error) {
34+
const failure = error instanceof DatabaseMigrationError
35+
? error
36+
: new DatabaseMigrationError('migration_failed', null, null);
37+
stdout.write(`${JSON.stringify(result({
38+
status: failure.code,
39+
beforeSchemaVersion: failure.beforeSchemaVersion,
40+
afterSchemaVersion: failure.afterSchemaVersion,
41+
}))}\n`);
42+
return 1;
43+
}
44+
}

src/cli/migrate.test.mjs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
});

src/db.mjs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { DatabaseSync } from 'node:sqlite';
6-
import { mkdirSync } from 'fs';
6+
import { existsSync, mkdirSync } from 'fs';
77
import { homedir } from 'os';
88
import { join } from 'path';
99

@@ -240,6 +240,74 @@ function initSchema(db) {
240240
}
241241
}
242242

243+
/**
244+
* 既存の Throughline DB だけを現在 schema へ移行する。
245+
* 通常の getDb() と異なり、DB や親ディレクトリを作成しない。
246+
*
247+
* @returns {{ status: 'not_applicable' | 'already_current' | 'migrated', beforeSchemaVersion: number | null, afterSchemaVersion: number | null, supportedSchemaVersion: number }}
248+
*/
249+
export function migrateDefaultDb() {
250+
if (!existsSync(DB_PATH)) {
251+
return {
252+
status: 'not_applicable',
253+
beforeSchemaVersion: null,
254+
afterSchemaVersion: null,
255+
supportedSchemaVersion: CURRENT_VERSION,
256+
};
257+
}
258+
259+
let db;
260+
try {
261+
db = new DatabaseSync(DB_PATH);
262+
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
263+
db.exec('PRAGMA foreign_keys = ON');
264+
265+
const beforeSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
266+
if (beforeSchemaVersion > CURRENT_VERSION) {
267+
throw new DatabaseMigrationError('future_schema', beforeSchemaVersion, beforeSchemaVersion);
268+
}
269+
270+
if (beforeSchemaVersion === CURRENT_VERSION) {
271+
return {
272+
status: 'already_current',
273+
beforeSchemaVersion,
274+
afterSchemaVersion: beforeSchemaVersion,
275+
supportedSchemaVersion: CURRENT_VERSION,
276+
};
277+
}
278+
279+
const journalMode = db.prepare('PRAGMA journal_mode').get().journal_mode;
280+
if (String(journalMode).toLowerCase() !== 'wal') {
281+
db.exec('PRAGMA journal_mode = WAL');
282+
}
283+
initSchema(db);
284+
const afterSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
285+
if (afterSchemaVersion !== CURRENT_VERSION) {
286+
throw new DatabaseMigrationError('version_mismatch', beforeSchemaVersion, afterSchemaVersion);
287+
}
288+
return {
289+
status: 'migrated',
290+
beforeSchemaVersion,
291+
afterSchemaVersion,
292+
supportedSchemaVersion: CURRENT_VERSION,
293+
};
294+
} catch (error) {
295+
if (error instanceof DatabaseMigrationError) throw error;
296+
throw new DatabaseMigrationError('migration_failed', null, null);
297+
} finally {
298+
db?.close();
299+
}
300+
}
301+
302+
export class DatabaseMigrationError extends Error {
303+
constructor(code, beforeSchemaVersion, afterSchemaVersion) {
304+
super(code);
305+
this.code = code;
306+
this.beforeSchemaVersion = beforeSchemaVersion;
307+
this.afterSchemaVersion = afterSchemaVersion;
308+
}
309+
}
310+
243311
/**
244312
* DB インスタンスを返す(シングルトン)
245313
* @returns {DatabaseSync}

0 commit comments

Comments
 (0)