cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(current): 校验 threads 必需列,完善 schema drift 防御

之前仅检查 threads 表存在,如果表存在但列(rollout_path / updated_at_ms 等)
被上游改名,SELECT 会冒泡 SQLiteError 堆栈而不是走 CurrentStateDbError +
CLI 结构化错误分支。

补 PRAGMA table_info(threads) 列检查:用 THREADS_REQUIRED_COLUMNS 常量
列出 SELECT 引用的所有列(id, rollout_path, cwd, title, updated_at_ms),
缺任意一列即抛 CurrentStateDbError 指明缺哪些列。

测试: query.test.ts +1, cli.test.ts +1, 后者额外断言 stdout 不含
"SQLiteError" 锁定结构化 payload 契约。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Entire-Checkpoint: b9987d117979

cat 5a35f4dc 1657a0e8

+75
+26
cli.test.ts
··· 113 113 expect(payload.error.message).toContain("threads"); 114 114 }); 115 115 116 + test("current --json emits structured error when 'threads' is missing required columns", async () => { 117 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-cols-")); 118 + tempDirs.push(base); 119 + const stateDbPath = join(base, "state.sqlite"); 120 + const db = new Database(stateDbPath); 121 + db.run(` 122 + CREATE TABLE threads ( 123 + id TEXT PRIMARY KEY, 124 + cwd TEXT NOT NULL, 125 + title TEXT NOT NULL 126 + ) 127 + `); 128 + db.close(); 129 + 130 + const result = await runCli(["current", "--state-db", stateDbPath, "--json"]); 131 + expect(result.exitCode).toBe(1); 132 + const payload = JSON.parse(result.stdout) as { 133 + error: { code: string; message: string }; 134 + }; 135 + expect(payload.error.code).toBe("state_db_unavailable"); 136 + expect(payload.error.message).toContain("rollout_path"); 137 + // Crucially, raw SQLite errors should never reach stdout — exit 1 with a 138 + // structured payload is the contract. 139 + expect(result.stdout).not.toContain("SQLiteError"); 140 + }); 141 + 116 142 test("find text output points to read-range", async () => { 117 143 const base = mkdtempSync(join(tmpdir(), "cxs-cli-")); 118 144 tempDirs.push(base);
+28
query.test.ts
··· 81 81 expect((caught as Error).message).toContain("threads"); 82 82 }); 83 83 84 + test("current throws CurrentStateDbError when 'threads' is missing required columns", () => { 85 + const base = mkdtempSync(join(tmpdir(), "cxs-current-cols-")); 86 + tempDirs.push(base); 87 + const stateDbPath = join(base, "state.sqlite"); 88 + const db = new Database(stateDbPath); 89 + // Table exists but lacks rollout_path & updated_at_ms — simulates an 90 + // upstream rename of the columns we SELECT in getCurrentSessions. 91 + db.run(` 92 + CREATE TABLE threads ( 93 + id TEXT PRIMARY KEY, 94 + cwd TEXT NOT NULL, 95 + title TEXT NOT NULL 96 + ) 97 + `); 98 + db.close(); 99 + 100 + let caught: unknown = null; 101 + try { 102 + getCurrentSessions(stateDbPath, "/tmp/project", 10); 103 + } catch (error) { 104 + caught = error; 105 + } 106 + expect(caught).toBeInstanceOf(CurrentStateDbError); 107 + const message = (caught as Error).message; 108 + expect(message).toContain("rollout_path"); 109 + expect(message).toContain("updated_at_ms"); 110 + }); 111 + 84 112 test("sync -> find -> read-range -> read-page works on fixture sessions", async () => { 85 113 const base = mkdtempSync(join(tmpdir(), "cxs-test-")); 86 114 tempDirs.push(base);
+21
query.ts
··· 36 36 } 37 37 } 38 38 39 + // Hard-coded identifier list — getCurrentSessions's SELECT references each of 40 + // these. Keep this in sync with the SELECT below. 41 + const THREADS_REQUIRED_COLUMNS = ["id", "rollout_path", "cwd", "title", "updated_at_ms"] as const; 42 + 39 43 export function findSessions( 40 44 dbPath: string, 41 45 query: string, ··· 123 127 if (!tableExists(db, "threads")) { 124 128 throw new CurrentStateDbError( 125 129 `unexpected codex state db schema: missing 'threads' table at ${stateDbPath}`, 130 + ); 131 + } 132 + const missingColumns = findMissingColumns(db, "threads", THREADS_REQUIRED_COLUMNS); 133 + if (missingColumns.length > 0) { 134 + throw new CurrentStateDbError( 135 + `unexpected codex state db schema: 'threads' missing column(s) ${missingColumns.join(", ")} at ${stateDbPath}`, 126 136 ); 127 137 } 128 138 const candidates = db ··· 347 357 .query<unknown, [string]>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1") 348 358 .get(tableName); 349 359 return Boolean(row); 360 + } 361 + 362 + // Why: PRAGMA table_info doesn't accept bound parameters, so callers MUST 363 + // pass a hard-coded identifier. Returns required columns that the table is 364 + // missing, in input order; empty array means schema is good. 365 + function findMissingColumns(db: Database, tableName: string, required: readonly string[]): string[] { 366 + const rows = db 367 + .query<{ name: string }, []>(`PRAGMA table_info(${tableName})`) 368 + .all() as Array<{ name: string }>; 369 + const present = new Set(rows.map((row) => row.name)); 370 + return required.filter((column) => !present.has(column)); 350 371 } 351 372 352 373 /**