Skip to content

Commit 41ff831

Browse files
authored
Merge pull request #30 from AgentWorkforce/feat/relayauth-migrate-d1-runner
feat(migrate): Cloudflare D1 runner
2 parents 2f7c1f8 + 2016517 commit 41ff831

4 files changed

Lines changed: 443 additions & 0 deletions

File tree

packages/migrate/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"types": "./dist/runners/node-sqlite.d.ts",
1414
"import": "./dist/runners/node-sqlite.js"
1515
},
16+
"./d1": {
17+
"types": "./dist/runners/d1.d.ts",
18+
"import": "./dist/runners/d1.js"
19+
},
1620
"./fs": {
1721
"types": "./dist/sources/fs.d.ts",
1822
"import": "./dist/sources/fs.js"
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { DatabaseSync } from "node:sqlite";
6+
import test from "node:test";
7+
8+
import {
9+
MigrationChecksumMismatchError,
10+
createD1Runner,
11+
createFsMigrationSource,
12+
runMigrations,
13+
sha256,
14+
splitSqlStatements,
15+
type D1DatabaseLike,
16+
type D1PreparedStatementLike,
17+
type D1ResultLike,
18+
} from "../index.js";
19+
20+
/**
21+
* Wrap node:sqlite's synchronous DatabaseSync behind the async D1 prepared-
22+
* statement surface so the D1 runner can be exercised without a real
23+
* Cloudflare binding.
24+
*
25+
* The goal is not to emulate D1 perfectly; it's to exercise the runner's
26+
* prepare/batch/run/all/first call patterns against a real SQL engine.
27+
*/
28+
function createFakeD1(db: DatabaseSync): D1DatabaseLike {
29+
function prepare<Row = unknown>(sql: string): D1PreparedStatementLike<Row> {
30+
let boundParams: unknown[] = [];
31+
const api: D1PreparedStatementLike<Row> = {
32+
bind(...values: unknown[]) {
33+
boundParams = values;
34+
return api;
35+
},
36+
async run(): Promise<D1ResultLike<Row>> {
37+
db.prepare(sql).run(...(boundParams as unknown[]));
38+
return { success: true };
39+
},
40+
async all(): Promise<D1ResultLike<Row>> {
41+
const rows = db.prepare(sql).all(...(boundParams as unknown[])) as Row[];
42+
return { results: rows, success: true };
43+
},
44+
async first<T = Row>(): Promise<T | null> {
45+
const row = db.prepare(sql).get(...(boundParams as unknown[])) as T | undefined;
46+
return row ?? null;
47+
},
48+
};
49+
return api;
50+
}
51+
52+
return {
53+
prepare,
54+
async batch<Row = unknown>(
55+
statements: D1PreparedStatementLike<Row>[],
56+
): Promise<D1ResultLike<Row>[]> {
57+
const results: D1ResultLike<Row>[] = [];
58+
db.exec("BEGIN");
59+
try {
60+
for (const stmt of statements) {
61+
results.push(await stmt.run());
62+
}
63+
db.exec("COMMIT");
64+
} catch (err) {
65+
try {
66+
db.exec("ROLLBACK");
67+
} catch {
68+
// swallow — caller needs the original error
69+
}
70+
throw err;
71+
}
72+
return results;
73+
},
74+
};
75+
}
76+
77+
function createTempDir(): { dir: string; cleanup: () => void } {
78+
const dir = mkdtempSync(join(tmpdir(), "relayauth-migrate-d1-test-"));
79+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
80+
}
81+
82+
function writeMigration(dir: string, name: string, sql: string): void {
83+
writeFileSync(join(dir, name), sql, "utf8");
84+
}
85+
86+
test("fresh DB: all files apply and journal is populated", async () => {
87+
const { dir, cleanup } = createTempDir();
88+
const db = new DatabaseSync(":memory:");
89+
try {
90+
writeMigration(dir, "0001_users.sql", "CREATE TABLE users (id TEXT PRIMARY KEY);");
91+
writeMigration(dir, "0002_posts.sql", "CREATE TABLE posts (id TEXT PRIMARY KEY);");
92+
93+
const runner = createD1Runner(createFakeD1(db));
94+
const source = createFsMigrationSource(dir);
95+
96+
const result = await runMigrations(runner, source);
97+
98+
assert.deepEqual(result.applied, ["0001_users", "0002_posts"]);
99+
assert.deepEqual(result.skipped, []);
100+
101+
const applied = await runner.listApplied();
102+
assert.equal(applied.length, 2);
103+
assert.equal(applied[0]?.id, "0001_users");
104+
assert.equal(applied[0]?.checksum, sha256("CREATE TABLE users (id TEXT PRIMARY KEY);"));
105+
106+
db.exec("INSERT INTO users (id) VALUES ('u1')");
107+
db.exec("INSERT INTO posts (id) VALUES ('p1')");
108+
} finally {
109+
db.close();
110+
cleanup();
111+
}
112+
});
113+
114+
test("rerun: nothing applies again, skipped equals all", async () => {
115+
const { dir, cleanup } = createTempDir();
116+
const db = new DatabaseSync(":memory:");
117+
try {
118+
writeMigration(dir, "0001_users.sql", "CREATE TABLE users (id TEXT PRIMARY KEY);");
119+
120+
const runner = createD1Runner(createFakeD1(db));
121+
const source = createFsMigrationSource(dir);
122+
123+
await runMigrations(runner, source);
124+
const second = await runMigrations(runner, source);
125+
126+
assert.deepEqual(second.applied, []);
127+
assert.deepEqual(second.skipped, ["0001_users"]);
128+
} finally {
129+
db.close();
130+
cleanup();
131+
}
132+
});
133+
134+
test("checksum drift throws MigrationChecksumMismatchError", async () => {
135+
const { dir, cleanup } = createTempDir();
136+
const db = new DatabaseSync(":memory:");
137+
try {
138+
writeMigration(dir, "0001_users.sql", "CREATE TABLE users (id TEXT PRIMARY KEY);");
139+
140+
const runner = createD1Runner(createFakeD1(db));
141+
await runMigrations(runner, createFsMigrationSource(dir));
142+
143+
writeMigration(dir, "0001_users.sql", "CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT);");
144+
145+
await assert.rejects(
146+
() => runMigrations(runner, createFsMigrationSource(dir)),
147+
(err: unknown) => err instanceof MigrationChecksumMismatchError,
148+
);
149+
} finally {
150+
db.close();
151+
cleanup();
152+
}
153+
});
154+
155+
test("multi-statement migration is batched atomically — failure rolls back prior statements", async () => {
156+
const { dir, cleanup } = createTempDir();
157+
const db = new DatabaseSync(":memory:");
158+
try {
159+
// Second statement is invalid (missing table). The batch must roll back
160+
// the first so `users` doesn't exist after the failed migration.
161+
writeMigration(
162+
dir,
163+
"0001_broken.sql",
164+
`CREATE TABLE users (id TEXT PRIMARY KEY);
165+
INSERT INTO nonexistent (id) VALUES ('x');`,
166+
);
167+
168+
const runner = createD1Runner(createFakeD1(db));
169+
const source = createFsMigrationSource(dir);
170+
171+
await assert.rejects(() => runMigrations(runner, source));
172+
173+
// Prior statement must not have survived the failed batch.
174+
const tables = db
175+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'")
176+
.all();
177+
assert.equal(tables.length, 0);
178+
179+
// Journal must not record the failed migration.
180+
const applied = await runner.listApplied();
181+
assert.equal(applied.length, 0);
182+
} finally {
183+
db.close();
184+
cleanup();
185+
}
186+
});
187+
188+
test("splitSqlStatements strips -- comments and splits on unquoted semicolons", () => {
189+
const sql = `
190+
-- header comment
191+
CREATE TABLE a (id TEXT); -- inline comment
192+
CREATE INDEX idx_a ON a (id);
193+
`;
194+
assert.deepEqual(splitSqlStatements(sql), [
195+
"CREATE TABLE a (id TEXT)",
196+
"CREATE INDEX idx_a ON a (id)",
197+
]);
198+
});
199+
200+
test("splitSqlStatements preserves -- inside single-quoted strings", () => {
201+
const sql = "INSERT INTO t (val) VALUES ('foo--bar'); INSERT INTO t (val) VALUES ('baz');";
202+
assert.deepEqual(splitSqlStatements(sql), [
203+
"INSERT INTO t (val) VALUES ('foo--bar')",
204+
"INSERT INTO t (val) VALUES ('baz')",
205+
]);
206+
});
207+
208+
test("splitSqlStatements preserves semicolons inside single-quoted strings", () => {
209+
const sql = "INSERT INTO t (val) VALUES ('hello; world'); INSERT INTO t (val) VALUES ('x');";
210+
assert.deepEqual(splitSqlStatements(sql), [
211+
"INSERT INTO t (val) VALUES ('hello; world')",
212+
"INSERT INTO t (val) VALUES ('x')",
213+
]);
214+
});
215+
216+
test("splitSqlStatements handles '' escape inside single-quoted strings", () => {
217+
// 'it''s fine' is the SQL standard for a string containing a literal quote.
218+
const sql = "INSERT INTO t (val) VALUES ('it''s; fine'); INSERT INTO t (val) VALUES ('y');";
219+
assert.deepEqual(splitSqlStatements(sql), [
220+
"INSERT INTO t (val) VALUES ('it''s; fine')",
221+
"INSERT INTO t (val) VALUES ('y')",
222+
]);
223+
});
224+
225+
test("splitSqlStatements preserves semicolons and -- inside double-quoted identifiers", () => {
226+
const sql = 'CREATE TABLE "weird;name--here" (id TEXT); CREATE INDEX idx ON "weird;name--here" (id);';
227+
assert.deepEqual(splitSqlStatements(sql), [
228+
'CREATE TABLE "weird;name--here" (id TEXT)',
229+
'CREATE INDEX idx ON "weird;name--here" (id)',
230+
]);
231+
});
232+
233+
test("recordApplied is idempotent on duplicate ids", async () => {
234+
const db = new DatabaseSync(":memory:");
235+
try {
236+
const runner = createD1Runner(createFakeD1(db));
237+
await runner.initialize();
238+
239+
await runner.recordApplied({ id: "0001_x", checksum: "abc" }, 1);
240+
await runner.recordApplied({ id: "0001_x", checksum: "def" }, 2);
241+
242+
const applied = await runner.listApplied();
243+
assert.equal(applied.length, 1);
244+
assert.equal(applied[0]?.checksum, "abc"); // first wins; INSERT OR IGNORE
245+
} finally {
246+
db.close();
247+
}
248+
});

packages/migrate/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,11 @@ export {
1515
createNodeSqliteRunner,
1616
type NodeSqliteDatabase,
1717
} from "./runners/node-sqlite.js";
18+
export {
19+
createD1Runner,
20+
splitSqlStatements,
21+
type D1DatabaseLike,
22+
type D1PreparedStatementLike,
23+
type D1ResultLike,
24+
} from "./runners/d1.js";
1825
export { createFsMigrationSource, sha256 } from "./sources/fs.js";

0 commit comments

Comments
 (0)