mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Every built-in storage implements ReplayableStorage: head(), replay() at an explicit sequence, and export() of the current state as a log that replays to the same reads. SQLite schema version 3 adds sequence indexes so export streams. @earendil-works/pi-durable/sync adds ReplicatingStorage, which appends each committed batch to an outbox with fork copies resolved, compactRecords(), and versioned JSON log lines.
177 lines
6.1 KiB
TypeScript
177 lines
6.1 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 { idFromNumber } from "../src/ids.ts";
|
|
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 { type EntryId, 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(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({
|
|
version: CURRENT_SQLITE_SCHEMA_VERSION,
|
|
});
|
|
expect(await database.get("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1")).toEqual({
|
|
next_id: "2",
|
|
next_seq: 1,
|
|
});
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
});
|
|
|
|
it("migrates a version 1 database to store sleeping tasks, keeping its tasks and indexes", async () => {
|
|
const path = await databasePath();
|
|
const database = await openNodeSqliteDatabase(path);
|
|
await applySqliteMigrations(database, SQLITE_MIGRATIONS.slice(0, 1));
|
|
const waiting = {
|
|
id: 5,
|
|
conversationId: 1,
|
|
kind: "test.task",
|
|
version: 1,
|
|
input: null,
|
|
background: false,
|
|
abortRequested: false,
|
|
state: { status: "waiting", checkpoint: { phase: "run" }, on: [], policy: "allSettled" },
|
|
};
|
|
await database.run(
|
|
"INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record) VALUES (5, 1, 'test.task', 'waiting', 0, 0, ?)",
|
|
JSON.stringify(waiting),
|
|
);
|
|
await expect(
|
|
database.run(
|
|
"INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record) VALUES (6, 1, 'test.task', 'sleeping', 0, 0, '{}')",
|
|
),
|
|
).rejects.toThrow();
|
|
await database.close();
|
|
|
|
const storage = await openNodeSqliteStorage(path);
|
|
await storage.close(BACKGROUND_CONTEXT);
|
|
const migrated = await openNodeSqliteDatabase(path);
|
|
try {
|
|
expect(await migrated.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({ version: 3 });
|
|
expect(await migrated.get("SELECT status, record FROM tasks WHERE id = 5")).toEqual({
|
|
status: "waiting",
|
|
record: JSON.stringify(waiting),
|
|
});
|
|
await migrated.run(
|
|
"INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record) VALUES (6, 1, 'test.task', 'sleeping', 0, 0, '{}')",
|
|
);
|
|
const indexes = await migrated.all<{ name: string }>(
|
|
"SELECT name FROM sqlite_schema WHERE type = 'index' AND tbl_name = 'tasks' ORDER BY name",
|
|
);
|
|
expect(indexes.map(({ name }) => name)).toEqual([
|
|
"tasks_by_abort_requested",
|
|
"tasks_by_background",
|
|
"tasks_by_conversation",
|
|
"tasks_by_kind",
|
|
"tasks_by_status",
|
|
]);
|
|
expect(await migrated.get("SELECT count(*) AS count FROM sqlite_schema WHERE name = 'tasks_v2'")).toEqual({
|
|
count: 0,
|
|
});
|
|
} finally {
|
|
await migrated.close();
|
|
}
|
|
});
|
|
|
|
it("rejects a database newer than the portable core", async () => {
|
|
const path = await databasePath();
|
|
const database = await openNodeSqliteDatabase(path);
|
|
await applySqliteMigrations(database);
|
|
await database.run(
|
|
"UPDATE durable_schema SET version = ? WHERE singleton = 1",
|
|
CURRENT_SQLITE_SCHEMA_VERSION + 1,
|
|
);
|
|
await database.close();
|
|
|
|
await expect(openNodeSqliteStorage(path)).rejects.toThrow("is newer than supported version");
|
|
});
|
|
|
|
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: idFromNumber<EntryId>(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(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({
|
|
version: CURRENT_SQLITE_SCHEMA_VERSION,
|
|
});
|
|
expect(
|
|
await database.get(
|
|
"SELECT count(*) AS count FROM sqlite_schema WHERE type = 'table' AND name = 'migration_probe'",
|
|
),
|
|
).toEqual({ count: 0 });
|
|
|
|
const successfulMigrations: readonly SqliteMigration[] = [
|
|
...SQLITE_MIGRATIONS,
|
|
{ version: nextVersion, statements: ["CREATE TABLE migration_probe (value TEXT) STRICT"] },
|
|
];
|
|
await applySqliteMigrations(database, successfulMigrations);
|
|
expect(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({
|
|
version: nextVersion,
|
|
});
|
|
expect(await database.get("SELECT record, commit_seq FROM entries WHERE id = 2")).toEqual({
|
|
record: JSON.stringify({
|
|
id: 2,
|
|
conversationId: ROOT_CONVERSATION_ID,
|
|
kind: "retained",
|
|
data: { retained: true },
|
|
}),
|
|
commit_seq: 1,
|
|
});
|
|
expect(await database.get("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1")).toEqual({
|
|
next_id: "3",
|
|
next_seq: 2,
|
|
});
|
|
await database.close();
|
|
});
|
|
});
|