Skip to content

Commit 4731229

Browse files
stainluclaude
andcommitted
test(sqlite): migration dispatch on pre-existing DBs
Long-lived deploys carry DB files across orchestrator releases. A broken migration would silently break every next restart. These tests pin the invariants that matter most: - Fresh DB gets every column from CREATE TABLE (no silent drift between the migration ALTERs and the starting schema). - Pre-Item-15 sessions DB → environment_id / ephemeral / remaining_subagent_depth land with their safe defaults (0, 0, NULL) and existing rows survive. - Pre-Item-17/19 agents DB → callable_agents_json, max_subagent_depth, version, updated_at, archived_at, permission_policy_json all added; legacy rows default to version=1, not-delegating, not-archived. - Pre-Item-19 agent_versions gets permission_policy_json. - Every guard is idempotent — reopening an already-migrated DB is a no-op (the production-critical invariant that runs on every restart). - Write-close-reopen round-trip: data written through the live store survives a full restart cycle against the same path. 6 cases, 125 tests passing overall (was 119). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5c4f41f commit 4731229

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

src/store/sqlite.test.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import Database from "better-sqlite3";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
7+
import { SqliteStore } from "./sqlite.js";
8+
9+
// These tests target the ALTER TABLE migration dispatch that runs in
10+
// SqliteStore's constructor. A pre-migration DB is constructed by
11+
// writing the *old* schema (the shape that predated a given migration),
12+
// inserting data, closing the handle, and then opening it again through
13+
// SqliteStore — which should add the missing columns in place without
14+
// corrupting existing rows.
15+
//
16+
// Why this matters: long-lived deploys carry DB files across orchestrator
17+
// releases. If a migration is wrong, every next restart silently breaks
18+
// production. Unit-tested here because the cost of regressing is
19+
// catastrophic (data loss or startup crash) and the cost of testing is
20+
// tiny (a temp SQLite file per case).
21+
22+
let tmpDir: string;
23+
24+
beforeEach(() => {
25+
tmpDir = mkdtempSync(join(tmpdir(), "sqlite-migrations-"));
26+
});
27+
28+
afterEach(() => {
29+
rmSync(tmpDir, { recursive: true, force: true });
30+
});
31+
32+
function newDbPath(name = "test.db"): string {
33+
return join(tmpDir, name);
34+
}
35+
36+
/** Column name set for a given table in a raw DB handle. */
37+
function columns(db: Database.Database, table: string): Set<string> {
38+
const rows = db.pragma(`table_info(${table})`) as Array<{ name: string }>;
39+
return new Set(rows.map((r) => r.name));
40+
}
41+
42+
describe("SqliteStore — fresh-DB schema", () => {
43+
it("creates every table and every current column on a brand new file", () => {
44+
const store = new SqliteStore(newDbPath());
45+
// Read via a second handle so we can snapshot the schema without
46+
// going through the ORM-shaped methods.
47+
const probe = new Database(newDbPath(), { readonly: true });
48+
const agents = columns(probe, "agents");
49+
const sessions = columns(probe, "sessions");
50+
const envs = columns(probe, "environments");
51+
const versions = columns(probe, "agent_versions");
52+
probe.close();
53+
store.close();
54+
55+
// Agents — every column documented on AgentConfig is present.
56+
for (const col of [
57+
"agent_id",
58+
"model",
59+
"tools_json",
60+
"instructions",
61+
"permission_policy_json",
62+
"name",
63+
"created_at",
64+
"updated_at",
65+
"archived_at",
66+
"version",
67+
"callable_agents_json",
68+
"max_subagent_depth",
69+
]) {
70+
expect(agents, `agents missing ${col}`).toContain(col);
71+
}
72+
73+
// Sessions — all three additive columns present on fresh create.
74+
for (const col of [
75+
"session_id",
76+
"agent_id",
77+
"status",
78+
"ephemeral",
79+
"remaining_subagent_depth",
80+
"environment_id",
81+
"tokens_in",
82+
"tokens_out",
83+
"cost_usd",
84+
"error",
85+
"created_at",
86+
"last_event_at",
87+
]) {
88+
expect(sessions, `sessions missing ${col}`).toContain(col);
89+
}
90+
91+
expect(envs).toContain("environment_id");
92+
expect(envs).toContain("networking_json");
93+
94+
expect(versions).toContain("permission_policy_json");
95+
});
96+
});
97+
98+
describe("SqliteStore — additive migrations on pre-existing DBs", () => {
99+
it("adds sessions columns (environment_id, ephemeral, remaining_subagent_depth) when opening a legacy DB", () => {
100+
const path = newDbPath();
101+
// Pre-migration shape of the sessions table — no environment_id,
102+
// no ephemeral, no remaining_subagent_depth.
103+
const seed = new Database(path);
104+
seed.exec(`
105+
CREATE TABLE sessions (
106+
session_id TEXT PRIMARY KEY,
107+
agent_id TEXT NOT NULL,
108+
status TEXT NOT NULL CHECK (status IN ('idle','running','failed')),
109+
tokens_in INTEGER NOT NULL DEFAULT 0,
110+
tokens_out INTEGER NOT NULL DEFAULT 0,
111+
cost_usd REAL NOT NULL DEFAULT 0,
112+
error TEXT,
113+
created_at INTEGER NOT NULL,
114+
last_event_at INTEGER
115+
);
116+
`);
117+
seed.prepare(
118+
`INSERT INTO sessions (session_id, agent_id, status, created_at)
119+
VALUES (?, ?, 'idle', ?)`,
120+
).run("ses_old", "agt_old", 1000);
121+
seed.close();
122+
123+
// Open through SqliteStore — migrations fire during the constructor.
124+
const store = new SqliteStore(path);
125+
store.close();
126+
127+
const probe = new Database(path, { readonly: true });
128+
const cols = columns(probe, "sessions");
129+
expect(cols).toContain("environment_id");
130+
expect(cols).toContain("ephemeral");
131+
expect(cols).toContain("remaining_subagent_depth");
132+
133+
// Pre-existing row survived the migration and got the NOT-NULL
134+
// defaults for the new integer columns.
135+
const row = probe.prepare(
136+
`SELECT session_id, ephemeral, remaining_subagent_depth, environment_id
137+
FROM sessions WHERE session_id = 'ses_old'`,
138+
).get() as {
139+
session_id: string;
140+
ephemeral: number;
141+
remaining_subagent_depth: number;
142+
environment_id: string | null;
143+
};
144+
probe.close();
145+
expect(row.session_id).toBe("ses_old");
146+
expect(row.ephemeral).toBe(0); // safe default (not ephemeral)
147+
expect(row.remaining_subagent_depth).toBe(0); // safe default (no subagents)
148+
expect(row.environment_id).toBeNull(); // nullable column
149+
});
150+
151+
it("adds every agents migration column when opening a v1-era DB", () => {
152+
// Pre-migration agents table: only the fields that existed before
153+
// Items 12-14, 17, 19 landed. Minimum viable shape.
154+
const path = newDbPath();
155+
const seed = new Database(path);
156+
seed.exec(`
157+
CREATE TABLE agents (
158+
agent_id TEXT PRIMARY KEY,
159+
model TEXT NOT NULL,
160+
tools_json TEXT NOT NULL,
161+
instructions TEXT NOT NULL,
162+
name TEXT,
163+
created_at INTEGER NOT NULL
164+
);
165+
`);
166+
seed.prepare(
167+
`INSERT INTO agents (agent_id, model, tools_json, instructions, created_at)
168+
VALUES (?, ?, ?, ?, ?)`,
169+
).run("agt_old", "moonshot/kimi-k2.5", "[]", "", 1000);
170+
seed.close();
171+
172+
const store = new SqliteStore(path);
173+
store.close();
174+
175+
const probe = new Database(path, { readonly: true });
176+
const cols = columns(probe, "agents");
177+
// Every column the latest code expects to be able to read from.
178+
for (const col of [
179+
"callable_agents_json",
180+
"max_subagent_depth",
181+
"version",
182+
"updated_at",
183+
"archived_at",
184+
"permission_policy_json",
185+
]) {
186+
expect(cols, `missing ${col} after migration`).toContain(col);
187+
}
188+
189+
const row = probe.prepare(
190+
`SELECT agent_id, max_subagent_depth, version, callable_agents_json,
191+
permission_policy_json, updated_at, archived_at
192+
FROM agents WHERE agent_id = 'agt_old'`,
193+
).get() as {
194+
agent_id: string;
195+
max_subagent_depth: number;
196+
version: number;
197+
callable_agents_json: string | null;
198+
permission_policy_json: string | null;
199+
updated_at: number | null;
200+
archived_at: number | null;
201+
};
202+
probe.close();
203+
expect(row.agent_id).toBe("agt_old");
204+
expect(row.max_subagent_depth).toBe(0); // safe default (no delegation)
205+
expect(row.version).toBe(1); // safe default (legacy rows start at v1)
206+
expect(row.callable_agents_json).toBeNull();
207+
expect(row.permission_policy_json).toBeNull();
208+
expect(row.updated_at).toBeNull();
209+
expect(row.archived_at).toBeNull();
210+
});
211+
212+
it("adds permission_policy_json to agent_versions when opening a pre-Item-19 DB", () => {
213+
const path = newDbPath();
214+
const seed = new Database(path);
215+
// agent_versions as it existed before Item 19 (no permission_policy_json column).
216+
seed.exec(`
217+
CREATE TABLE agent_versions (
218+
agent_id TEXT NOT NULL,
219+
version INTEGER NOT NULL,
220+
model TEXT NOT NULL,
221+
tools_json TEXT NOT NULL,
222+
instructions TEXT NOT NULL,
223+
name TEXT,
224+
callable_agents_json TEXT,
225+
max_subagent_depth INTEGER NOT NULL DEFAULT 0,
226+
created_at INTEGER NOT NULL,
227+
PRIMARY KEY (agent_id, version)
228+
);
229+
`);
230+
seed.prepare(
231+
`INSERT INTO agent_versions
232+
(agent_id, version, model, tools_json, instructions, created_at)
233+
VALUES (?, ?, ?, ?, ?, ?)`,
234+
).run("agt_old", 1, "m", "[]", "", 1000);
235+
seed.close();
236+
237+
const store = new SqliteStore(path);
238+
store.close();
239+
240+
const probe = new Database(path, { readonly: true });
241+
expect(columns(probe, "agent_versions")).toContain("permission_policy_json");
242+
probe.close();
243+
});
244+
245+
it("is idempotent — opening an already-migrated DB is a no-op", () => {
246+
const path = newDbPath();
247+
// First open: creates tables and runs all migrations as no-ops.
248+
new SqliteStore(path).close();
249+
// Second open: every migration guard sees the column already present
250+
// and short-circuits. Must NOT throw. This is the production-critical
251+
// invariant — every restart of every deploy re-runs this code path.
252+
expect(() => {
253+
const store = new SqliteStore(path);
254+
store.close();
255+
}).not.toThrow();
256+
});
257+
258+
it("preserves data written through one migration cycle across a restart", () => {
259+
// End-to-end: create a session through the live store, close it,
260+
// reopen it, and verify the session round-trips. Proves the
261+
// prepared-statement SELECT shape matches the latest schema on a
262+
// DB that was originally created by the same latest schema — no
263+
// silent column-type mismatches.
264+
const path = newDbPath();
265+
let store = new SqliteStore(path);
266+
const agent = store.agents.create({
267+
model: "m",
268+
tools: [],
269+
instructions: "",
270+
permissionPolicy: { type: "always_allow" },
271+
callableAgents: [],
272+
maxSubagentDepth: 0,
273+
});
274+
const session = store.sessions.create({ agentId: agent.agentId });
275+
store.close();
276+
277+
store = new SqliteStore(path);
278+
const loaded = store.sessions.get(session.sessionId);
279+
expect(loaded?.sessionId).toBe(session.sessionId);
280+
expect(loaded?.status).toBe("idle");
281+
const loadedAgent = store.agents.get(agent.agentId);
282+
expect(loadedAgent?.version).toBe(1);
283+
expect(loadedAgent?.callableAgents).toEqual([]);
284+
store.close();
285+
});
286+
});

0 commit comments

Comments
 (0)