pi-mono/packages/durable/test/sqlite-migrations.test.ts
Mario Zechner 5901c9b9e5 feat: add durable SQLite storage backend
Add the portable SQLite storage core, Node adapter, versioned migrations, conformance coverage, and storage benchmarks.
2026-09-22 17:46:42 +02:00

158 lines
5.2 KiB
TypeScript

import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { afterEach, describe, expect, it } from "vitest";
import {
applySqliteMigrations,
CURRENT_SQLITE_SCHEMA_VERSION,
SQLITE_MIGRATIONS,
type SqliteMigration,
} from "../src/storage/sqlite/index.ts";
import { openNodeSqliteDatabase, openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { ROOT_CONVERSATION_ID } from "../src/types.ts";
const directories = new Set<string>();
async function databasePath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "pi-durable-migrations-"));
directories.add(directory);
return join(directory, "storage.sqlite");
}
afterEach(async () => {
for (const directory of directories) await rm(directory, { recursive: true, force: true });
directories.clear();
});
describe("durable SQLite migrations", () => {
it("creates the current schema and can be applied repeatedly", async () => {
const database = await openNodeSqliteDatabase(await databasePath());
try {
await applySqliteMigrations(database);
await applySqliteMigrations(database);
expect(database.prepare("SELECT version FROM durable_schema WHERE singleton = 1").get()).toEqual({
version: CURRENT_SQLITE_SCHEMA_VERSION,
});
expect(database.prepare("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1").get()).toEqual({
next_id: "2",
next_seq: 1,
});
} finally {
database.close();
}
});
it("rejects a database newer than the portable core", async () => {
const path = await databasePath();
const database = await openNodeSqliteDatabase(path);
await applySqliteMigrations(database);
database
.prepare("UPDATE durable_schema SET version = ? WHERE singleton = 1")
.run(CURRENT_SQLITE_SCHEMA_VERSION + 1);
database.close();
await expect(openNodeSqliteStorage(path)).rejects.toThrow("is newer than supported version");
});
it("rolls initial bootstrap and every pending migration back together", async () => {
const database = await openNodeSqliteDatabase(await databasePath());
try {
const failed: readonly SqliteMigration[] = [
{
version: 1,
statements: [
"CREATE TABLE migration_first (value TEXT) STRICT",
"INSERT INTO migration_first (value) VALUES ('retained')",
],
},
{
version: 2,
statements: ["CREATE TABLE migration_second (value TEXT) STRICT", "THIS IS NOT SQL"],
},
];
await expect(applySqliteMigrations(database, failed)).rejects.toThrow();
expect(
database
.prepare(
"SELECT count(*) AS count FROM sqlite_schema WHERE name IN ('durable_schema', 'migration_first', 'migration_second')",
)
.get(),
).toEqual({ count: 0 });
await applySqliteMigrations(database, [
failed[0],
{ version: 2, statements: ["CREATE TABLE migration_second (value TEXT) STRICT"] },
]);
expect(database.prepare("SELECT version FROM durable_schema WHERE singleton = 1").get()).toEqual({
version: 2,
});
expect(database.prepare("SELECT value FROM migration_first").get()).toEqual({ value: "retained" });
} finally {
database.close();
}
});
it("rolls a failed migration back and preserves stored data for a successful retry", async () => {
const path = await databasePath();
const storage = await openNodeSqliteStorage(path);
await storage.commit(
[
{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } },
{
type: "entry",
value: {
id: 2,
conversationId: ROOT_CONVERSATION_ID,
kind: "retained",
data: { retained: true },
},
},
],
BACKGROUND_CONTEXT,
);
await storage.close(BACKGROUND_CONTEXT);
const database = await openNodeSqliteDatabase(path);
const nextVersion = CURRENT_SQLITE_SCHEMA_VERSION + 1;
const failedMigrations: readonly SqliteMigration[] = [
...SQLITE_MIGRATIONS,
{
version: nextVersion,
statements: ["CREATE TABLE migration_probe (value TEXT) STRICT", "THIS IS NOT SQL"],
},
];
await expect(applySqliteMigrations(database, failedMigrations)).rejects.toThrow();
expect(database.prepare("SELECT version FROM durable_schema WHERE singleton = 1").get()).toEqual({
version: CURRENT_SQLITE_SCHEMA_VERSION,
});
expect(
database
.prepare("SELECT count(*) AS count FROM sqlite_schema WHERE type = 'table' AND name = 'migration_probe'")
.get(),
).toEqual({ count: 0 });
const successfulMigrations: readonly SqliteMigration[] = [
...SQLITE_MIGRATIONS,
{ version: nextVersion, statements: ["CREATE TABLE migration_probe (value TEXT) STRICT"] },
];
await applySqliteMigrations(database, successfulMigrations);
expect(database.prepare("SELECT version FROM durable_schema WHERE singleton = 1").get()).toEqual({
version: nextVersion,
});
expect(database.prepare("SELECT record, commit_seq FROM entries WHERE id = 2").get()).toEqual({
record: JSON.stringify({
id: 2,
conversationId: ROOT_CONVERSATION_ID,
kind: "retained",
data: { retained: true },
}),
commit_seq: 1,
});
expect(database.prepare("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1").get()).toEqual({
next_id: "3",
next_seq: 2,
});
database.close();
});
});