mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(durable): support namespaced SQLite storage (#10937)
* feat(durable): namespace SQLite storage tables Render migrations and runtime queries from one schema map, add optional prefixes, and simplify the Cloudflare Durable Object adapter. * feat(durable): expose SQLite prefixes through storage helpers * docs: explain shared Durable Object storage * Update changelog
This commit is contained in:
parent
6a1f799b67
commit
f7e3cb5433
10 changed files with 537 additions and 180 deletions
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `SqliteStorage.open(database, { prefix })` can namespace every table and index; `openNodeSqliteStorage()` and `openDurableObjectSqliteStorage()` forward it, for databases shared with host tables.
|
||||
|
||||
### Changed
|
||||
|
||||
- Cloudflare Durable Object SQLite adapters created for the same storage object now coordinate their operations, allowing multiple Pi storages to safely share one Durable Object database.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The system baseline written after a compaction now leads the model context when the kept history includes an assistant message, as does a first baseline written after earlier answers, instead of following the new input; Anthropic and OpenAI Codex requests sent it as a mid-conversation system message without an initial prompt or tool set. Other system messages after an assistant message stay in place ([#10904](https://github.com/earendil-works/pi/issues/10904)).
|
||||
|
|
|
|||
|
|
@ -662,6 +662,23 @@ const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": U
|
|||
| JSONL | `openNodeJsonlStorage(directory, context)` from `@earendil-works/pi-durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents. |
|
||||
| Cloudflare Durable Object | `openDurableObjectSqliteStorage(ctx.storage)` from `@earendil-works/pi-durable/storage/sqlite/cloudflare` | The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions. |
|
||||
|
||||
SQLite storage can namespace its tables and indexes when sharing a database: pass `{ prefix: "pi_" }` to
|
||||
`SqliteStorage.open(database, options)`, `openNodeSqliteStorage(path, options)`, or
|
||||
`openDurableObjectSqliteStorage(ctx.storage, options)`. Storage renders the prefixed names into its SQL, so adapters
|
||||
need no support. A prefix must be a SQLite identifier and cannot begin with `sqlite_`; a host may reserve more, such
|
||||
as `_cf_` in Durable Objects. Changing the prefix does not migrate existing data.
|
||||
|
||||
Multiple Pi storages opened through the Cloudflare adapter with the same storage object automatically share an
|
||||
operation queue. To share the database with host tables, create an adapter and use it for both Pi and all host queries;
|
||||
direct storage calls bypass the queue and may be rolled back with a Pi transaction:
|
||||
|
||||
```typescript
|
||||
const database = new DurableObjectSqliteDatabase(ctx.storage);
|
||||
const piStorage = await SqliteStorage.open(database, { prefix: "pi_" });
|
||||
await database.exec("CREATE TABLE IF NOT EXISTS host_notes (text TEXT)");
|
||||
await database.run("INSERT INTO host_notes (text) VALUES (?)", text);
|
||||
```
|
||||
|
||||
One process owns a storage at a time; there is no cross-process locking. The portable SQLite and JSONL cores (`/storage/sqlite`, `/storage/jsonl`) run without Node APIs, for example on Bun or in Cloudflare Durable Objects, given an asynchronous `SqliteDatabase` facade or a `FileSystem` from `@earendil-works/pi-durable/env`.
|
||||
|
||||
SQLite adapters implement promise-based `exec`, `run`, `get`, `all`, `transaction`, and `close`. `run`, `get`, and `all` take SQL text plus positional bindings; adapters may cache prepared statements by SQL text. A transaction callback receives a transaction handle; all work in the transaction must use it, and the handle expires when the callback settles. Adapters must queue unrelated operations and other transactions until the transaction finishes, so calling `database` itself inside the callback never settles:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SqliteDatabase, SqliteExecutor, SqliteValue } from "./database.ts";
|
||||
import { SqliteStorage } from "./storage.ts";
|
||||
import { SqliteStorage, type SqliteStorageOptions } from "./storage.ts";
|
||||
|
||||
/** Values a Durable Object SQL binding accepts and returns. */
|
||||
type DurableObjectSqlValue = ArrayBuffer | string | number | null;
|
||||
|
|
@ -10,22 +10,61 @@ type DurableObjectSqlValue = ArrayBuffer | string | number | null;
|
|||
*/
|
||||
export type DurableObjectSqliteStorage = {
|
||||
readonly sql: {
|
||||
exec(query: string, ...bindings: DurableObjectSqlValue[]): { toArray(): Record<string, DurableObjectSqlValue>[] };
|
||||
exec(
|
||||
query: string,
|
||||
...bindings: DurableObjectSqlValue[]
|
||||
): {
|
||||
next():
|
||||
| { readonly done?: false; readonly value: Record<string, DurableObjectSqlValue> }
|
||||
| { readonly done: true; readonly value?: undefined };
|
||||
toArray(): Record<string, DurableObjectSqlValue>[];
|
||||
};
|
||||
};
|
||||
transaction<T>(closure: () => Promise<T>): Promise<T>;
|
||||
};
|
||||
|
||||
/** Runs operations one at a time in call order, so a transaction excludes every other operation. */
|
||||
/** Runs synchronous operations immediately when idle and holds queued calls behind a transaction. */
|
||||
class SerialQueue {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private pending = 0;
|
||||
|
||||
run<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(operation);
|
||||
this.tail = result.catch(() => {});
|
||||
if (this.pending > 0) return this.enqueue(operation);
|
||||
try {
|
||||
return Promise.resolve(operation());
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
hold<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return this.enqueue(operation);
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
this.pending++;
|
||||
const result = this.tail.then(operation).finally(() => {
|
||||
this.pending--;
|
||||
});
|
||||
this.tail = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const storageQueues = new WeakMap<DurableObjectSqliteStorage, SerialQueue>();
|
||||
|
||||
function queueFor(storage: DurableObjectSqliteStorage): SerialQueue {
|
||||
let queue = storageQueues.get(storage);
|
||||
if (queue === undefined) {
|
||||
queue = new SerialQueue();
|
||||
storageQueues.set(storage, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** Durable Object SQL binds numbers as doubles: a `bigint` outside the safe integer range would lose precision. */
|
||||
function bindValue(value: SqliteValue): DurableObjectSqlValue {
|
||||
if (value instanceof Uint8Array) return value.slice().buffer as ArrayBuffer;
|
||||
|
|
@ -38,15 +77,13 @@ function bindValue(value: SqliteValue): DurableObjectSqlValue {
|
|||
|
||||
const bind = (params: readonly SqliteValue[]): DurableObjectSqlValue[] => params.map(bindValue);
|
||||
|
||||
/** Rows as returned, with `BLOB` columns converted to `Uint8Array` in place; no other copy is made. */
|
||||
function rows<T>(rows: Record<string, DurableObjectSqlValue>[]): T[] {
|
||||
for (const row of rows) {
|
||||
for (const key in row) {
|
||||
const value = row[key];
|
||||
if (value instanceof ArrayBuffer) (row as Record<string, unknown>)[key] = new Uint8Array(value);
|
||||
}
|
||||
/** A row as returned, with `BLOB` columns converted to `Uint8Array` in place; no other copy is made. */
|
||||
function row<T>(row: Record<string, DurableObjectSqlValue>): T {
|
||||
for (const key in row) {
|
||||
const value = row[key];
|
||||
if (value instanceof ArrayBuffer) (row as Record<string, unknown>)[key] = new Uint8Array(value);
|
||||
}
|
||||
return rows as T[];
|
||||
return row as T;
|
||||
}
|
||||
|
||||
/** Executes SQL through `ctx.storage.sql`, which has no separate prepare step. */
|
||||
|
|
@ -69,12 +106,16 @@ class DurableObjectSqliteExecutor implements SqliteExecutor {
|
|||
|
||||
async get<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T | undefined> {
|
||||
this.check();
|
||||
return rows<T>(this.storage.sql.exec(sql, ...bind(params)).toArray())[0];
|
||||
const result = this.storage.sql.exec(sql, ...bind(params)).next();
|
||||
return result.done ? undefined : row<T>(result.value);
|
||||
}
|
||||
|
||||
async all<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T[]> {
|
||||
this.check();
|
||||
return rows<T>(this.storage.sql.exec(sql, ...bind(params)).toArray());
|
||||
return this.storage.sql
|
||||
.exec(sql, ...bind(params))
|
||||
.toArray()
|
||||
.map((value) => row<T>(value));
|
||||
}
|
||||
|
||||
/** Throws when this handle may no longer run SQL. */
|
||||
|
|
@ -91,11 +132,17 @@ class DurableObjectSqliteTransaction extends DurableObjectSqliteExecutor {
|
|||
}
|
||||
|
||||
/**
|
||||
* `SqliteDatabase` adapter for the SQLite storage of a Cloudflare Durable Object. Integers are JavaScript numbers;
|
||||
* binding a `bigint` outside the safe integer range throws.
|
||||
* `SqliteDatabase` adapter for the SQLite storage of a Cloudflare Durable Object. Adapters given the same storage
|
||||
* object share a queue; direct calls to the underlying storage bypass it. Integers are JavaScript numbers; binding a
|
||||
* `bigint` outside the safe integer range throws.
|
||||
*/
|
||||
export class DurableObjectSqliteDatabase extends DurableObjectSqliteExecutor implements SqliteDatabase {
|
||||
private readonly queue = new SerialQueue();
|
||||
private readonly queue: SerialQueue;
|
||||
|
||||
constructor(storage: DurableObjectSqliteStorage) {
|
||||
super(storage);
|
||||
this.queue = queueFor(storage);
|
||||
}
|
||||
|
||||
override exec(sql: string): Promise<void> {
|
||||
return this.queue.run(() => super.exec(sql));
|
||||
|
|
@ -115,7 +162,7 @@ export class DurableObjectSqliteDatabase extends DurableObjectSqliteExecutor imp
|
|||
|
||||
/** `ctx.storage.transaction()` commits when the callback resolves and rolls back when it rejects. */
|
||||
transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T> {
|
||||
return this.queue.run(() =>
|
||||
return this.queue.hold(() =>
|
||||
this.storage.transaction(async () => {
|
||||
const transaction = new DurableObjectSqliteTransaction(this.storage);
|
||||
try {
|
||||
|
|
@ -133,7 +180,13 @@ export class DurableObjectSqliteDatabase extends DurableObjectSqliteExecutor imp
|
|||
}
|
||||
}
|
||||
|
||||
/** Open durable storage on a SQLite-backed Durable Object's `ctx.storage`. */
|
||||
export function openDurableObjectSqliteStorage(storage: DurableObjectSqliteStorage): Promise<SqliteStorage> {
|
||||
return SqliteStorage.open(new DurableObjectSqliteDatabase(storage));
|
||||
/**
|
||||
* Open durable storage on a SQLite-backed Durable Object, optionally under a table prefix. Calls given the same
|
||||
* storage object share an operation queue.
|
||||
*/
|
||||
export function openDurableObjectSqliteStorage(
|
||||
storage: DurableObjectSqliteStorage,
|
||||
options: SqliteStorageOptions = {},
|
||||
): Promise<SqliteStorage> {
|
||||
return SqliteStorage.open(new DurableObjectSqliteDatabase(storage), options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ export {
|
|||
SQLITE_MIGRATIONS,
|
||||
type SqliteMigration,
|
||||
} from "./migrations.ts";
|
||||
export { SqliteStorage } from "./storage.ts";
|
||||
export { SqliteStorage, type SqliteStorageOptions } from "./storage.ts";
|
||||
|
|
|
|||
|
|
@ -1,91 +1,96 @@
|
|||
import type { SqliteDatabase } from "./database.ts";
|
||||
import { DEFAULT_SQLITE_SCHEMA, type SqliteSchema } from "./schema.ts";
|
||||
|
||||
export type SqliteMigration = {
|
||||
readonly version: number;
|
||||
readonly statements: readonly string[];
|
||||
};
|
||||
|
||||
// next_id is TEXT because node:sqlite rejects INTEGER results outside JavaScript's safe integer range.
|
||||
const INITIAL_SCHEMA: readonly string[] = [
|
||||
`CREATE TABLE durable_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
next_id TEXT NOT NULL,
|
||||
next_seq INTEGER NOT NULL
|
||||
) STRICT`,
|
||||
`INSERT INTO durable_metadata (singleton, next_id, next_seq) VALUES (1, '2', 1)`,
|
||||
`CREATE TABLE record_ids (
|
||||
id INTEGER PRIMARY KEY,
|
||||
record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document'))
|
||||
) STRICT`,
|
||||
`CREATE TABLE conversations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_conversation_id INTEGER,
|
||||
owner_task_id INTEGER,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
"CREATE INDEX conversations_by_owner_conversation ON conversations (owner_conversation_id, id)",
|
||||
"CREATE INDEX conversations_by_owner_task ON conversations (owner_task_id, id)",
|
||||
`CREATE TABLE entries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
head INTEGER,
|
||||
commit_seq INTEGER NOT NULL,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
"CREATE INDEX entries_by_conversation ON entries (conversation_id, id DESC)",
|
||||
"CREATE INDEX entry_heads_by_conversation ON entries (conversation_id, id DESC) WHERE head IS NOT NULL",
|
||||
`CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')),
|
||||
abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)),
|
||||
background INTEGER NOT NULL CHECK (background IN (0, 1)),
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
"CREATE INDEX tasks_by_status ON tasks (status, id)",
|
||||
"CREATE INDEX tasks_by_conversation ON tasks (conversation_id, id)",
|
||||
"CREATE INDEX tasks_by_kind ON tasks (kind, id)",
|
||||
"CREATE INDEX tasks_by_abort_requested ON tasks (abort_requested, id)",
|
||||
"CREATE INDEX tasks_by_background ON tasks (background, id)",
|
||||
`CREATE TABLE submissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
request_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')),
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
"CREATE INDEX submissions_by_request ON submissions (conversation_id, request_id)",
|
||||
"CREATE INDEX submissions_by_conversation ON submissions (conversation_id, id)",
|
||||
"CREATE INDEX submissions_by_status ON submissions (status, id)",
|
||||
`CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
family INTEGER NOT NULL CHECK (family IN (0, 1)),
|
||||
key_value TEXT NOT NULL,
|
||||
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')),
|
||||
owner_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
retired_at INTEGER,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX documents_by_address
|
||||
ON documents (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`,
|
||||
"CREATE INDEX documents_by_scope ON documents (scope_kind, owner_id, id)",
|
||||
"CREATE INDEX documents_by_scope_kind ON documents (scope_kind, owner_id, kind, id)",
|
||||
`CREATE TABLE document_revisions (
|
||||
document_id INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')),
|
||||
version INTEGER NOT NULL,
|
||||
content TEXT NOT NULL CHECK (json_valid(content)),
|
||||
PRIMARY KEY (document_id, seq)
|
||||
) STRICT`,
|
||||
"CREATE INDEX document_revisions_by_kind ON document_revisions (document_id, kind, seq DESC)",
|
||||
];
|
||||
/** Build immutable, ordered schema history for a set of SQLite identifiers. */
|
||||
export function createSqliteMigrations(schema: SqliteSchema): readonly SqliteMigration[] {
|
||||
const { tables, indexes } = schema;
|
||||
// next_id is TEXT because node:sqlite rejects INTEGER results outside JavaScript's safe integer range.
|
||||
const initialSchema: readonly string[] = [
|
||||
`CREATE TABLE ${tables.durableMetadata} (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
next_id TEXT NOT NULL,
|
||||
next_seq INTEGER NOT NULL
|
||||
) STRICT`,
|
||||
`INSERT INTO ${tables.durableMetadata} (singleton, next_id, next_seq) VALUES (1, '2', 1)`,
|
||||
`CREATE TABLE ${tables.recordIds} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document'))
|
||||
) STRICT`,
|
||||
`CREATE TABLE ${tables.conversations} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_conversation_id INTEGER,
|
||||
owner_task_id INTEGER,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.conversationsByOwnerConversation} ON ${tables.conversations} (owner_conversation_id, id)`,
|
||||
`CREATE INDEX ${indexes.conversationsByOwnerTask} ON ${tables.conversations} (owner_task_id, id)`,
|
||||
`CREATE TABLE ${tables.entries} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
head INTEGER,
|
||||
commit_seq INTEGER NOT NULL,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.entriesByConversation} ON ${tables.entries} (conversation_id, id DESC)`,
|
||||
`CREATE INDEX ${indexes.entryHeadsByConversation} ON ${tables.entries} (conversation_id, id DESC) WHERE head IS NOT NULL`,
|
||||
`CREATE TABLE ${tables.tasks} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')),
|
||||
abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)),
|
||||
background INTEGER NOT NULL CHECK (background IN (0, 1)),
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`,
|
||||
`CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`,
|
||||
`CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`,
|
||||
`CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`,
|
||||
`CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)`,
|
||||
`CREATE TABLE ${tables.submissions} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
request_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')),
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.submissionsByRequest} ON ${tables.submissions} (conversation_id, request_id)`,
|
||||
`CREATE INDEX ${indexes.submissionsByConversation} ON ${tables.submissions} (conversation_id, id)`,
|
||||
`CREATE INDEX ${indexes.submissionsByStatus} ON ${tables.submissions} (status, id)`,
|
||||
`CREATE TABLE ${tables.documents} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
family INTEGER NOT NULL CHECK (family IN (0, 1)),
|
||||
key_value TEXT NOT NULL,
|
||||
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')),
|
||||
owner_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
retired_at INTEGER,
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.documentsByAddress}
|
||||
ON ${tables.documents} (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`,
|
||||
`CREATE INDEX ${indexes.documentsByScope} ON ${tables.documents} (scope_kind, owner_id, id)`,
|
||||
`CREATE INDEX ${indexes.documentsByScopeKind} ON ${tables.documents} (scope_kind, owner_id, kind, id)`,
|
||||
`CREATE TABLE ${tables.documentRevisions} (
|
||||
document_id INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')),
|
||||
version INTEGER NOT NULL,
|
||||
content TEXT NOT NULL CHECK (json_valid(content)),
|
||||
PRIMARY KEY (document_id, seq)
|
||||
) STRICT`,
|
||||
`CREATE INDEX ${indexes.documentRevisionsByKind} ON ${tables.documentRevisions} (document_id, kind, seq DESC)`,
|
||||
];
|
||||
return [{ version: 1, statements: initialSchema }];
|
||||
}
|
||||
|
||||
/** Immutable, ordered schema history. Append new migrations after the initial schema ships. */
|
||||
export const SQLITE_MIGRATIONS: readonly SqliteMigration[] = [{ version: 1, statements: INITIAL_SCHEMA }];
|
||||
export const SQLITE_MIGRATIONS: readonly SqliteMigration[] = createSqliteMigrations(DEFAULT_SQLITE_SCHEMA);
|
||||
|
||||
export const CURRENT_SQLITE_SCHEMA_VERSION = SQLITE_MIGRATIONS.at(-1)?.version ?? 0;
|
||||
|
||||
|
|
@ -95,6 +100,15 @@ type SchemaRow = { readonly version: number };
|
|||
export async function applySqliteMigrations(
|
||||
database: SqliteDatabase,
|
||||
migrations: readonly SqliteMigration[] = SQLITE_MIGRATIONS,
|
||||
): Promise<void> {
|
||||
await applySqliteMigrationsForSchema(database, DEFAULT_SQLITE_SCHEMA, migrations);
|
||||
}
|
||||
|
||||
/** Apply migrations rendered for a particular schema. */
|
||||
export async function applySqliteMigrationsForSchema(
|
||||
database: SqliteDatabase,
|
||||
schema: SqliteSchema,
|
||||
migrations: readonly SqliteMigration[] = createSqliteMigrations(schema),
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < migrations.length; index++) {
|
||||
if (migrations[index]?.version !== index + 1) {
|
||||
|
|
@ -102,13 +116,14 @@ export async function applySqliteMigrations(
|
|||
}
|
||||
}
|
||||
|
||||
const { durableSchema } = schema.tables;
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.exec(`CREATE TABLE IF NOT EXISTS durable_schema (
|
||||
await transaction.exec(`CREATE TABLE IF NOT EXISTS ${durableSchema} (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
version INTEGER NOT NULL CHECK (version >= 0)
|
||||
) STRICT`);
|
||||
await transaction.run("INSERT OR IGNORE INTO durable_schema (singleton, version) VALUES (1, 0)");
|
||||
const row = await transaction.get<SchemaRow>("SELECT version FROM durable_schema WHERE singleton = 1");
|
||||
await transaction.run(`INSERT OR IGNORE INTO ${durableSchema} (singleton, version) VALUES (1, 0)`);
|
||||
const row = await transaction.get<SchemaRow>(`SELECT version FROM ${durableSchema} WHERE singleton = 1`);
|
||||
if (row === undefined) throw new Error("Durable SQLite schema metadata is missing");
|
||||
const currentVersion = migrations.at(-1)?.version ?? 0;
|
||||
if (row.version > currentVersion) {
|
||||
|
|
@ -119,7 +134,7 @@ export async function applySqliteMigrations(
|
|||
for (const migration of migrations) {
|
||||
if (migration.version <= row.version) continue;
|
||||
for (const statement of migration.statements) await transaction.exec(statement);
|
||||
await transaction.run("UPDATE durable_schema SET version = ? WHERE singleton = 1", migration.version);
|
||||
await transaction.run(`UPDATE ${durableSchema} SET version = ? WHERE singleton = 1`, migration.version);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,20 @@ import { dirname } from "node:path";
|
|||
import type { StatementSync } from "node:sqlite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { SqliteDatabase, SqliteExecutor, SqliteValue } from "./database.ts";
|
||||
import { SqliteStorage } from "./storage.ts";
|
||||
import { validateSqliteSchemaPrefix } from "./schema.ts";
|
||||
import { SqliteStorage, type SqliteStorageOptions } from "./storage.ts";
|
||||
|
||||
/** Node SQLite connection settings for a durable storage file. */
|
||||
export type NodeSqliteStorageOptions = {
|
||||
export type NodeSqliteDatabaseOptions = {
|
||||
/** SQLite WAL auto-checkpoint threshold. SQLite and this adapter default to 1,000 pages; 0 disables it. */
|
||||
readonly walAutoCheckpointPages?: number;
|
||||
/** Time SQLite waits for a competing file lock. SQLite defaults to 0; this adapter defaults to 5,000 ms. */
|
||||
readonly busyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Node SQLite connection settings plus durable storage options. */
|
||||
export type NodeSqliteStorageOptions = NodeSqliteDatabaseOptions & SqliteStorageOptions;
|
||||
|
||||
const DEFAULT_WAL_AUTO_CHECKPOINT_PAGES = 1_000;
|
||||
const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
|
||||
|
||||
|
|
@ -179,7 +183,7 @@ export class NodeSqliteDatabase extends NodeSqliteExecutor implements SqliteData
|
|||
/** Open and configure a Node-backed SQLite database facade. */
|
||||
export async function openNodeSqliteDatabase(
|
||||
path: string,
|
||||
options: NodeSqliteStorageOptions = {},
|
||||
options: NodeSqliteDatabaseOptions = {},
|
||||
): Promise<NodeSqliteDatabase> {
|
||||
const checkpointPages = options.walAutoCheckpointPages ?? DEFAULT_WAL_AUTO_CHECKPOINT_PAGES;
|
||||
const timeout = options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
|
||||
|
|
@ -201,10 +205,12 @@ export async function openNodeSqliteDatabase(
|
|||
}
|
||||
}
|
||||
|
||||
/** Open or create file-backed durable storage using Node's built-in SQLite. */
|
||||
/** Open or create file-backed durable storage using Node's built-in SQLite, optionally under a table prefix. */
|
||||
export async function openNodeSqliteStorage(
|
||||
path: string,
|
||||
options: NodeSqliteStorageOptions = {},
|
||||
): Promise<SqliteStorage> {
|
||||
return SqliteStorage.open(await openNodeSqliteDatabase(path, options));
|
||||
const { prefix, ...databaseOptions } = options;
|
||||
if (prefix !== undefined) validateSqliteSchemaPrefix(prefix);
|
||||
return SqliteStorage.open(await openNodeSqliteDatabase(path, databaseOptions), { prefix });
|
||||
}
|
||||
|
|
|
|||
69
packages/durable/src/storage/sqlite/schema.ts
Normal file
69
packages/durable/src/storage/sqlite/schema.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Immutable identifier history for migrations and runtime queries. Never change or remove an existing value; a
|
||||
* rename adds a new entry so historical migrations keep rendering their original object names.
|
||||
*/
|
||||
const TABLE_NAMES = {
|
||||
durableSchema: "durable_schema",
|
||||
durableMetadata: "durable_metadata",
|
||||
recordIds: "record_ids",
|
||||
conversations: "conversations",
|
||||
entries: "entries",
|
||||
tasks: "tasks",
|
||||
submissions: "submissions",
|
||||
documents: "documents",
|
||||
documentRevisions: "document_revisions",
|
||||
} as const;
|
||||
|
||||
const INDEX_NAMES = {
|
||||
conversationsByOwnerConversation: "conversations_by_owner_conversation",
|
||||
conversationsByOwnerTask: "conversations_by_owner_task",
|
||||
entriesByConversation: "entries_by_conversation",
|
||||
entryHeadsByConversation: "entry_heads_by_conversation",
|
||||
tasksByStatus: "tasks_by_status",
|
||||
tasksByConversation: "tasks_by_conversation",
|
||||
tasksByKind: "tasks_by_kind",
|
||||
tasksByAbortRequested: "tasks_by_abort_requested",
|
||||
tasksByBackground: "tasks_by_background",
|
||||
submissionsByRequest: "submissions_by_request",
|
||||
submissionsByConversation: "submissions_by_conversation",
|
||||
submissionsByStatus: "submissions_by_status",
|
||||
documentsByAddress: "documents_by_address",
|
||||
documentsByScope: "documents_by_scope",
|
||||
documentsByScopeKind: "documents_by_scope_kind",
|
||||
documentRevisionsByKind: "document_revisions_by_kind",
|
||||
} as const;
|
||||
|
||||
type SqliteIdentifiers<T extends Record<string, string>> = { readonly [K in keyof T]: string };
|
||||
|
||||
export type SqliteSchema = {
|
||||
readonly tables: SqliteIdentifiers<typeof TABLE_NAMES>;
|
||||
readonly indexes: SqliteIdentifiers<typeof INDEX_NAMES>;
|
||||
};
|
||||
|
||||
/** Validate a prefix before using it as part of a SQLite identifier. */
|
||||
export function validateSqliteSchemaPrefix(prefix: string): void {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
|
||||
throw new Error(`Invalid SQLite schema prefix ${JSON.stringify(prefix)}`);
|
||||
}
|
||||
if (prefix.toLowerCase().startsWith("sqlite_")) {
|
||||
throw new Error(`SQLite schema prefix ${JSON.stringify(prefix)} is reserved`);
|
||||
}
|
||||
}
|
||||
|
||||
function prefixIdentifiers<T extends Record<string, string>>(names: T, prefix: string): SqliteIdentifiers<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(names).map(([key, name]) => [key, `${prefix}${name}`]),
|
||||
) as SqliteIdentifiers<T>;
|
||||
}
|
||||
|
||||
/** Build the identifiers used by every migration and storage query. */
|
||||
export function createSqliteSchema(prefix?: string): SqliteSchema {
|
||||
if (prefix !== undefined) validateSqliteSchemaPrefix(prefix);
|
||||
const actualPrefix = prefix ?? "";
|
||||
return {
|
||||
tables: prefixIdentifiers(TABLE_NAMES, actualPrefix),
|
||||
indexes: prefixIdentifiers(INDEX_NAMES, actualPrefix),
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_SQLITE_SCHEMA = createSqliteSchema();
|
||||
|
|
@ -34,7 +34,8 @@ import type {
|
|||
} from "../../types.ts";
|
||||
import { nextCursor, type ScanStart, scanStart } from "../scan.ts";
|
||||
import type { SqliteDatabase, SqliteExecutor, SqliteValue } from "./database.ts";
|
||||
import { applySqliteMigrations } from "./migrations.ts";
|
||||
import { applySqliteMigrationsForSchema } from "./migrations.ts";
|
||||
import { createSqliteSchema, type SqliteSchema } from "./schema.ts";
|
||||
|
||||
type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
type TableName = "conversation" | "entry" | "task" | "submission" | "document";
|
||||
|
|
@ -60,6 +61,14 @@ type ScopeColumns = {
|
|||
readonly ownerId: number;
|
||||
};
|
||||
|
||||
export type SqliteStorageOptions = {
|
||||
/**
|
||||
* Prefix for every table and index in the durable schema. It must be a SQLite identifier and must not use
|
||||
* SQLite's reserved `sqlite_` prefix. Existing data is not migrated when this changes.
|
||||
*/
|
||||
readonly prefix?: string;
|
||||
};
|
||||
|
||||
const parseJson = <T>(value: string): T => JSON.parse(value) as T;
|
||||
const encodeJson = (value: unknown): string => JSON.stringify(value) as string;
|
||||
// Some SQLite bindings replace lone UTF-16 surrogates. JSON encoding keeps indexed identities lossless.
|
||||
|
|
@ -142,26 +151,29 @@ const writeId = (write: StorageWrite): Id<string> | undefined => {
|
|||
/** Portable SQLite implementation of the Pico storage contract. */
|
||||
export class SqliteStorage implements Storage {
|
||||
private readonly db: SqliteDatabase;
|
||||
private readonly schema: SqliteSchema;
|
||||
private nextId: number;
|
||||
private closed = false;
|
||||
private closing: Promise<void> | undefined;
|
||||
private admittedReads = 0;
|
||||
private readsDrained: (() => void) | undefined;
|
||||
|
||||
private constructor(db: SqliteDatabase, nextId: number) {
|
||||
private constructor(db: SqliteDatabase, schema: SqliteSchema, nextId: number) {
|
||||
this.db = db;
|
||||
this.schema = schema;
|
||||
this.nextId = nextId;
|
||||
}
|
||||
|
||||
/** Initialize storage over an owned SQLite database facade. */
|
||||
static async open(db: SqliteDatabase): Promise<SqliteStorage> {
|
||||
static async open(db: SqliteDatabase, options: SqliteStorageOptions = {}): Promise<SqliteStorage> {
|
||||
try {
|
||||
await applySqliteMigrations(db);
|
||||
const schema = createSqliteSchema(options.prefix);
|
||||
await applySqliteMigrationsForSchema(db, schema);
|
||||
const metadata = await db.get<MetadataRow>(
|
||||
"SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1",
|
||||
`SELECT next_id, next_seq FROM ${schema.tables.durableMetadata} WHERE singleton = 1`,
|
||||
);
|
||||
if (metadata === undefined) throw new Error("Durable SQLite metadata is missing");
|
||||
return new SqliteStorage(db, Number(metadata.next_id));
|
||||
return new SqliteStorage(db, schema, Number(metadata.next_id));
|
||||
} catch (error) {
|
||||
try {
|
||||
await db.close();
|
||||
|
|
@ -178,7 +190,7 @@ export class SqliteStorage implements Storage {
|
|||
const candidateNextId = this.candidateNextId(writes);
|
||||
const seq = await this.db.transaction(async (transaction) => {
|
||||
const metadata = await transaction.get<MetadataRow>(
|
||||
"SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1",
|
||||
`SELECT next_id, next_seq FROM ${this.schema.tables.durableMetadata} WHERE singleton = 1`,
|
||||
);
|
||||
if (metadata === undefined) throw new Error("Durable SQLite metadata is missing");
|
||||
const committedSeq = seqFromNumber(metadata.next_seq);
|
||||
|
|
@ -187,7 +199,7 @@ export class SqliteStorage implements Storage {
|
|||
for (const write of writes) await this.applyTableWrite(transaction, write, committedSeq);
|
||||
await this.applyDocumentActions(transaction, documentActions, committedSeq);
|
||||
await transaction.run(
|
||||
"UPDATE durable_metadata SET next_id = ?, next_seq = ? WHERE singleton = 1",
|
||||
`UPDATE ${this.schema.tables.durableMetadata} SET next_id = ?, next_seq = ? WHERE singleton = 1`,
|
||||
String(Math.max(Number(metadata.next_id), candidateNextId)),
|
||||
committedSeq + 1,
|
||||
);
|
||||
|
|
@ -205,7 +217,7 @@ export class SqliteStorage implements Storage {
|
|||
|
||||
async conversation(id: ConversationId, _context: Context): Promise<ConversationRecord | undefined> {
|
||||
this.assertOpen();
|
||||
const row = await this.db.get<JsonRow>("SELECT record FROM conversations WHERE id = ?", id);
|
||||
const row = await this.db.get<JsonRow>(`SELECT record FROM ${this.schema.tables.conversations} WHERE id = ?`, id);
|
||||
return row === undefined ? undefined : parseJson<ConversationRecord>(row.record);
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +242,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM conversations WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.conversations} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
return page(
|
||||
|
|
@ -289,7 +301,10 @@ export class SqliteStorage implements Storage {
|
|||
conversation = await this.readConversation(conversationId);
|
||||
if (conversation === undefined) throw new StorageRequestError(`Unknown conversation: ${conversationId}`);
|
||||
}
|
||||
const row = await this.db.get<EntryJsonRow>("SELECT record, commit_seq FROM entries WHERE id = ?", id);
|
||||
const row = await this.db.get<EntryJsonRow>(
|
||||
`SELECT record, commit_seq FROM ${this.schema.tables.entries} WHERE id = ?`,
|
||||
id,
|
||||
);
|
||||
if (row === undefined) return undefined;
|
||||
const entry = parseJson<EntryRecord>(row.record);
|
||||
if (conversation !== undefined) {
|
||||
|
|
@ -315,11 +330,11 @@ export class SqliteStorage implements Storage {
|
|||
const row =
|
||||
upper === undefined
|
||||
? await this.db.get<JsonRow>(
|
||||
"SELECT record FROM entries WHERE conversation_id = ? AND head IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
`SELECT record FROM ${this.schema.tables.entries} WHERE conversation_id = ? AND head IS NOT NULL ORDER BY id DESC LIMIT 1`,
|
||||
conversation.id,
|
||||
)
|
||||
: await this.db.get<JsonRow>(
|
||||
"SELECT record FROM entries WHERE conversation_id = ? AND head IS NOT NULL AND id <= ? ORDER BY id DESC LIMIT 1",
|
||||
`SELECT record FROM ${this.schema.tables.entries} WHERE conversation_id = ? AND head IS NOT NULL AND id <= ? ORDER BY id DESC LIMIT 1`,
|
||||
conversation.id,
|
||||
upper,
|
||||
);
|
||||
|
|
@ -355,7 +370,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1 - values.length);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM entries WHERE ${clauses.join(" AND ")} ORDER BY id DESC LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.entries} WHERE ${clauses.join(" AND ")} ORDER BY id DESC LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
values.push(...rows.map((row) => parseJson<EntryRecord>(row.record)));
|
||||
|
|
@ -400,7 +415,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1 - values.length);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM entries WHERE ${clauses.join(" AND ")} ORDER BY id ASC LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.entries} WHERE ${clauses.join(" AND ")} ORDER BY id ASC LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
values.push(...rows.map((row) => parseJson<EntryRecord>(row.record)));
|
||||
|
|
@ -411,7 +426,7 @@ export class SqliteStorage implements Storage {
|
|||
|
||||
async task(id: TaskId, _context: Context): Promise<StoredTask | undefined> {
|
||||
this.assertOpen();
|
||||
const row = await this.db.get<JsonRow>("SELECT record FROM tasks WHERE id = ?", id);
|
||||
const row = await this.db.get<JsonRow>(`SELECT record FROM ${this.schema.tables.tasks} WHERE id = ?`, id);
|
||||
return row === undefined ? undefined : parseJson<StoredTask>(row.record);
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +463,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM tasks WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.tasks} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
return page(
|
||||
|
|
@ -460,7 +475,7 @@ export class SqliteStorage implements Storage {
|
|||
|
||||
async submission(id: SubmissionId, _context: Context): Promise<SubmissionRecord | undefined> {
|
||||
this.assertOpen();
|
||||
const row = await this.db.get<JsonRow>("SELECT record FROM submissions WHERE id = ?", id);
|
||||
const row = await this.db.get<JsonRow>(`SELECT record FROM ${this.schema.tables.submissions} WHERE id = ?`, id);
|
||||
return row === undefined ? undefined : parseJson<SubmissionRecord>(row.record);
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +500,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM submissions WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.submissions} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
return page(
|
||||
|
|
@ -502,7 +517,7 @@ export class SqliteStorage implements Storage {
|
|||
): Promise<SubmissionRecord | undefined> {
|
||||
this.assertOpen();
|
||||
const row = await this.db.get<JsonRow>(
|
||||
"SELECT record FROM submissions WHERE conversation_id = ? AND request_id = ?",
|
||||
`SELECT record FROM ${this.schema.tables.submissions} WHERE conversation_id = ? AND request_id = ?`,
|
||||
conversationId,
|
||||
encodeIndexedString(requestId),
|
||||
);
|
||||
|
|
@ -518,10 +533,10 @@ export class SqliteStorage implements Storage {
|
|||
const parts = addressParts(address);
|
||||
const sql =
|
||||
at === "current"
|
||||
? `SELECT record FROM documents
|
||||
? `SELECT record FROM ${this.schema.tables.documents}
|
||||
WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ?
|
||||
AND retired_at IS NULL ORDER BY created_at DESC LIMIT 1`
|
||||
: `SELECT record FROM documents
|
||||
: `SELECT record FROM ${this.schema.tables.documents}
|
||||
WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ?
|
||||
AND created_at <= ? AND (retired_at IS NULL OR retired_at > ?)
|
||||
ORDER BY created_at DESC LIMIT 1`;
|
||||
|
|
@ -559,7 +574,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
params.push(limit + 1);
|
||||
const rows = await this.db.all<JsonRow>(
|
||||
`SELECT record FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id LIMIT ?`,
|
||||
`SELECT record FROM ${this.schema.tables.documents} WHERE ${clauses.join(" AND ")} ORDER BY id LIMIT ?`,
|
||||
...params,
|
||||
);
|
||||
return page(
|
||||
|
|
@ -600,7 +615,7 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
|
||||
private async readConversation(id: ConversationId): Promise<ConversationRecord | undefined> {
|
||||
const row = await this.db.get<JsonRow>("SELECT record FROM conversations WHERE id = ?", id);
|
||||
const row = await this.db.get<JsonRow>(`SELECT record FROM ${this.schema.tables.conversations} WHERE id = ?`, id);
|
||||
return row === undefined ? undefined : parseJson<ConversationRecord>(row.record);
|
||||
}
|
||||
|
||||
|
|
@ -609,7 +624,7 @@ export class SqliteStorage implements Storage {
|
|||
id: DocumentId,
|
||||
at: DocumentPoint,
|
||||
): Promise<StoredDocument | undefined> {
|
||||
const row = await executor.get<JsonRow>("SELECT record FROM documents WHERE id = ?", id);
|
||||
const row = await executor.get<JsonRow>(`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`, id);
|
||||
if (row === undefined) return undefined;
|
||||
const record = parseJson<DocumentRecord>(row.record);
|
||||
if (at !== "current" && isCurrentOnly(record)) {
|
||||
|
|
@ -618,7 +633,7 @@ export class SqliteStorage implements Storage {
|
|||
if (!isAliveAt(record, at)) return undefined;
|
||||
const upper = at === "current" ? Number.MAX_SAFE_INTEGER : at;
|
||||
const base = await executor.get<RevisionRow>(
|
||||
`SELECT seq, kind, version, content FROM document_revisions
|
||||
`SELECT seq, kind, version, content FROM ${this.schema.tables.documentRevisions}
|
||||
WHERE document_id = ? AND kind = 'base' AND seq <= ? ORDER BY seq DESC LIMIT 1`,
|
||||
id,
|
||||
upper,
|
||||
|
|
@ -626,7 +641,7 @@ export class SqliteStorage implements Storage {
|
|||
if (base === undefined) throw new Error(`Document ${id} is missing a required base`);
|
||||
let value = parseJson<JsonObject>(base.content);
|
||||
const tail = await executor.all<RevisionRow>(
|
||||
`SELECT seq, kind, version, content FROM document_revisions
|
||||
`SELECT seq, kind, version, content FROM ${this.schema.tables.documentRevisions}
|
||||
WHERE document_id = ? AND seq > ? AND seq <= ? ORDER BY seq`,
|
||||
id,
|
||||
base.seq,
|
||||
|
|
@ -657,8 +672,9 @@ export class SqliteStorage implements Storage {
|
|||
const document = write.type === "document.create" || write.type === "document.copy";
|
||||
const table: TableName = document ? "document" : write.type;
|
||||
const id = document ? write.record.id : write.value.id;
|
||||
const existing = (await executor.get<RecordIdRow>("SELECT record_type FROM record_ids WHERE id = ?", id))
|
||||
?.record_type;
|
||||
const existing = (
|
||||
await executor.get<RecordIdRow>(`SELECT record_type FROM ${this.schema.tables.recordIds} WHERE id = ?`, id)
|
||||
)?.record_type;
|
||||
const earlier = claimed.get(id);
|
||||
if (table === "conversation" || table === "entry" || table === "document") {
|
||||
if (existing !== undefined) throw new Error(`ID ${id} already belongs to ${existing}`);
|
||||
|
|
@ -728,14 +744,14 @@ export class SqliteStorage implements Storage {
|
|||
if (action.copy !== undefined && actions.has(action.copy.id)) {
|
||||
throw new Error(`Document copy ${id} source is changed in the copy batch`);
|
||||
}
|
||||
const row = await executor.get<JsonRow>("SELECT record FROM documents WHERE id = ?", id);
|
||||
const row = await executor.get<JsonRow>(`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`, id);
|
||||
const existing = row === undefined ? undefined : parseJson<DocumentRecord>(row.record);
|
||||
if (action.create === undefined && existing === undefined) throw new Error(`Unknown document: ${id}`);
|
||||
if (action.create !== undefined && existing !== undefined) throw new Error(`Document ${id} already exists`);
|
||||
if (existing?.retiredAt !== undefined) throw new Error(`Document ${id} is retired`);
|
||||
if (action.content?.kind === "delta") {
|
||||
const previous = await executor.get<{ readonly version: number }>(
|
||||
"SELECT version FROM document_revisions WHERE document_id = ? ORDER BY seq DESC LIMIT 1",
|
||||
`SELECT version FROM ${this.schema.tables.documentRevisions} WHERE document_id = ? ORDER BY seq DESC LIMIT 1`,
|
||||
id,
|
||||
);
|
||||
if (previous === undefined) throw new Error(`Document ${id} delta has no base`);
|
||||
|
|
@ -763,7 +779,7 @@ export class SqliteStorage implements Storage {
|
|||
const parts = addressParts(address);
|
||||
const id = (
|
||||
await executor.get<IdRow>(
|
||||
`SELECT id FROM documents
|
||||
`SELECT id FROM ${this.schema.tables.documents}
|
||||
WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? AND retired_at IS NULL
|
||||
LIMIT 1`,
|
||||
parts.kind,
|
||||
|
|
@ -781,7 +797,7 @@ export class SqliteStorage implements Storage {
|
|||
case "conversation":
|
||||
await this.claimId(executor, write.value.id, "conversation");
|
||||
await executor.run(
|
||||
"INSERT INTO conversations (id, owner_conversation_id, owner_task_id, record) VALUES (?, ?, ?, ?)",
|
||||
`INSERT INTO ${this.schema.tables.conversations} (id, owner_conversation_id, owner_task_id, record) VALUES (?, ?, ?, ?)`,
|
||||
write.value.id,
|
||||
write.value.owner?.conversationId ?? null,
|
||||
write.value.owner?.taskId ?? null,
|
||||
|
|
@ -791,7 +807,7 @@ export class SqliteStorage implements Storage {
|
|||
case "entry":
|
||||
await this.claimId(executor, write.value.id, "entry");
|
||||
await executor.run(
|
||||
"INSERT INTO entries (id, conversation_id, head, commit_seq, record) VALUES (?, ?, ?, ?, ?)",
|
||||
`INSERT INTO ${this.schema.tables.entries} (id, conversation_id, head, commit_seq, record) VALUES (?, ?, ?, ?, ?)`,
|
||||
write.value.id,
|
||||
write.value.conversationId,
|
||||
write.value.head ?? null,
|
||||
|
|
@ -802,7 +818,7 @@ export class SqliteStorage implements Storage {
|
|||
case "task":
|
||||
await this.claimId(executor, write.value.id, "task");
|
||||
await executor.run(
|
||||
`INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record)
|
||||
`INSERT INTO ${this.schema.tables.tasks} (id, conversation_id, kind, status, abort_requested, background, record)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id, kind = excluded.kind,
|
||||
status = excluded.status, abort_requested = excluded.abort_requested,
|
||||
|
|
@ -819,7 +835,7 @@ export class SqliteStorage implements Storage {
|
|||
case "submission":
|
||||
await this.claimId(executor, write.value.id, "submission");
|
||||
await executor.run(
|
||||
`INSERT INTO submissions (id, conversation_id, request_id, status, record) VALUES (?, ?, ?, ?, ?)
|
||||
`INSERT INTO ${this.schema.tables.submissions} (id, conversation_id, request_id, status, record) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id,
|
||||
request_id = excluded.request_id, status = excluded.status, record = excluded.record`,
|
||||
write.value.id,
|
||||
|
|
@ -838,7 +854,11 @@ export class SqliteStorage implements Storage {
|
|||
}
|
||||
|
||||
private async claimId(executor: SqliteExecutor, id: Id<string>, table: TableName): Promise<void> {
|
||||
await executor.run("INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (?, ?)", id, table);
|
||||
await executor.run(
|
||||
`INSERT OR IGNORE INTO ${this.schema.tables.recordIds} (id, record_type) VALUES (?, ?)`,
|
||||
id,
|
||||
table,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyDocumentActions(
|
||||
|
|
@ -878,7 +898,7 @@ export class SqliteStorage implements Storage {
|
|||
const parts = addressParts(record);
|
||||
await this.claimId(executor, id, "document");
|
||||
await executor.run(
|
||||
`INSERT INTO documents
|
||||
`INSERT INTO ${this.schema.tables.documents}
|
||||
(id, kind, family, key_value, scope_kind, owner_id, created_at, retired_at, record)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id,
|
||||
|
|
@ -892,17 +912,20 @@ export class SqliteStorage implements Storage {
|
|||
encodeJson(record),
|
||||
);
|
||||
} else {
|
||||
const row = (await executor.get<JsonRow>("SELECT record FROM documents WHERE id = ?", id))!;
|
||||
const row = (await executor.get<JsonRow>(
|
||||
`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`,
|
||||
id,
|
||||
))!;
|
||||
record = parseJson<DocumentRecord>(row.record);
|
||||
}
|
||||
|
||||
if (content !== undefined) {
|
||||
if (content.kind === "base" && isCurrentOnly(record)) {
|
||||
await executor.run("DELETE FROM document_revisions WHERE document_id = ?", id);
|
||||
await executor.run(`DELETE FROM ${this.schema.tables.documentRevisions} WHERE document_id = ?`, id);
|
||||
}
|
||||
const encodedContent = content.kind === "base" ? encodeJson(content.value) : encodeJson(content.ops);
|
||||
await executor.run(
|
||||
"INSERT INTO document_revisions (document_id, seq, kind, version, content) VALUES (?, ?, ?, ?, ?)",
|
||||
`INSERT INTO ${this.schema.tables.documentRevisions} (document_id, seq, kind, version, content) VALUES (?, ?, ?, ?, ?)`,
|
||||
id,
|
||||
seq,
|
||||
content.kind,
|
||||
|
|
@ -915,14 +938,14 @@ export class SqliteStorage implements Storage {
|
|||
if (action.create === undefined) {
|
||||
record = { ...record, retiredAt: seq };
|
||||
await executor.run(
|
||||
"UPDATE documents SET retired_at = ?, record = ? WHERE id = ?",
|
||||
`UPDATE ${this.schema.tables.documents} SET retired_at = ?, record = ? WHERE id = ?`,
|
||||
seq,
|
||||
encodeJson(record),
|
||||
id,
|
||||
);
|
||||
}
|
||||
if (isCurrentOnly(record)) {
|
||||
await executor.run("DELETE FROM document_revisions WHERE document_id = ?", id);
|
||||
await executor.run(`DELETE FROM ${this.schema.tables.documentRevisions} WHERE document_id = ?`, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,62 @@
|
|||
import { DatabaseSync } from "node:sqlite";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { registerStorageConformance } from "@earendil-works/pi-durable/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { idFromNumber } from "../src/ids.ts";
|
||||
import {
|
||||
DurableObjectSqliteDatabase,
|
||||
type DurableObjectSqliteStorage,
|
||||
openDurableObjectSqliteStorage,
|
||||
} from "../src/storage/sqlite/cloudflare.ts";
|
||||
import { type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts";
|
||||
|
||||
type SqlValue = ArrayBuffer | string | number | null;
|
||||
|
||||
/**
|
||||
* A stand-in for a SQLite-backed Durable Object's `ctx.storage` over `node:sqlite`, with its value types: `BLOB`
|
||||
* bindings and results are `ArrayBuffer`s, and `exec()` without bindings may run several statements.
|
||||
* bindings and results are `ArrayBuffer`s, and `exec()` without bindings may run several statements. It keeps this
|
||||
* portable package free of Workers test dependencies; a Cloudflare host can run the same storage conformance suite
|
||||
* against its binding with `runInDurableObject()` from `cloudflare:test`.
|
||||
*/
|
||||
function durableObjectStorage(database = new DatabaseSync(":memory:")): DurableObjectSqliteStorage {
|
||||
function durableObjectStorage(
|
||||
database = new DatabaseSync(":memory:"),
|
||||
onCursorOperation?: (operation: "next" | "toArray") => void,
|
||||
): DurableObjectSqliteStorage {
|
||||
const toNode = (value: SqlValue) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value);
|
||||
const fromNode = (value: unknown): SqlValue =>
|
||||
value instanceof Uint8Array ? (value.slice().buffer as ArrayBuffer) : (value as SqlValue);
|
||||
const cursor = (rows: Record<string, SqlValue>[]) => {
|
||||
let index = 0;
|
||||
return {
|
||||
next(): { done: false; value: Record<string, SqlValue> } | { done: true } {
|
||||
onCursorOperation?.("next");
|
||||
const value = rows[index++];
|
||||
return value === undefined ? { done: true } : { done: false, value };
|
||||
},
|
||||
toArray(): Record<string, SqlValue>[] {
|
||||
onCursorOperation?.("toArray");
|
||||
const remaining = rows.slice(index);
|
||||
index = rows.length;
|
||||
return remaining;
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
sql: {
|
||||
exec(query, ...bindings) {
|
||||
if (bindings.length === 0 && query.trim().replace(/;\s*$/, "").includes(";")) {
|
||||
database.exec(query);
|
||||
return { toArray: () => [] };
|
||||
return cursor([]);
|
||||
}
|
||||
const statement = database.prepare(query);
|
||||
if (statement.columns().length === 0) {
|
||||
statement.run(...bindings.map(toNode));
|
||||
return { toArray: () => [] };
|
||||
return cursor([]);
|
||||
}
|
||||
const rows = statement.all(...bindings.map(toNode)) as Record<string, unknown>[];
|
||||
return {
|
||||
toArray: () =>
|
||||
rows.map((row) =>
|
||||
Object.fromEntries(Object.entries(row).map(([key, value]) => [key, fromNode(value)])),
|
||||
),
|
||||
};
|
||||
const rows = (statement.all(...bindings.map(toNode)) as Record<string, unknown>[]).map((row) =>
|
||||
Object.fromEntries(Object.entries(row).map(([key, value]) => [key, fromNode(value)])),
|
||||
);
|
||||
return cursor(rows);
|
||||
},
|
||||
},
|
||||
async transaction(closure) {
|
||||
|
|
@ -52,11 +73,77 @@ function durableObjectStorage(database = new DatabaseSync(":memory:")): DurableO
|
|||
};
|
||||
}
|
||||
|
||||
registerStorageConformance({ describe, expect, it }, "SqliteStorage on a Durable Object", async (use) =>
|
||||
use(await openDurableObjectSqliteStorage(durableObjectStorage())),
|
||||
registerStorageConformance({ describe, expect, it }, "SqliteStorage on a prefixed Durable Object", async (use) =>
|
||||
use(await openDurableObjectSqliteStorage(durableObjectStorage(), { prefix: "pi_" })),
|
||||
);
|
||||
|
||||
describe("Durable Object SQLite adapter", () => {
|
||||
it("keeps schema names unprefixed by default", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
await openDurableObjectSqliteStorage(durableObjectStorage(sqlite));
|
||||
expect(sqlite.prepare("SELECT version FROM durable_schema").get()).toEqual({ version: 1 });
|
||||
expect(sqlite.prepare("SELECT name FROM sqlite_master WHERE name = 'pi_durable_schema'").get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefixes pi's tables and indexes without changing host tables", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const storage = durableObjectStorage(sqlite);
|
||||
const database = new DurableObjectSqliteDatabase(storage);
|
||||
await database.exec("CREATE TABLE host_notes (text TEXT)");
|
||||
await database.exec("CREATE TABLE tasks (text TEXT)");
|
||||
await openDurableObjectSqliteStorage(storage, { prefix: "pi_" });
|
||||
const names = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'")
|
||||
.all()
|
||||
.map((row) => String(row.name));
|
||||
expect(names).toContain("host_notes");
|
||||
expect(names).toContain("tasks");
|
||||
expect(names).not.toContain("pi_host_notes");
|
||||
const piNames = names.filter((name) => !["host_notes", "tasks"].includes(name));
|
||||
expect(piNames.length).toBeGreaterThan(10);
|
||||
expect(piNames.every((name) => name.startsWith("pi_"))).toBe(true);
|
||||
expect(piNames).toContain("pi_tasks");
|
||||
expect(piNames).toContain("pi_durable_schema");
|
||||
});
|
||||
|
||||
it("isolates concurrently opened prefixes on one Durable Object", async () => {
|
||||
const storage = durableObjectStorage();
|
||||
const [first, second] = await Promise.all([
|
||||
openDurableObjectSqliteStorage(storage, { prefix: "first_" }),
|
||||
openDurableObjectSqliteStorage(storage, { prefix: "second_" }),
|
||||
]);
|
||||
const entryId = idFromNumber<EntryId>(2);
|
||||
await Promise.all([
|
||||
first.commit(
|
||||
[
|
||||
{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } },
|
||||
{ type: "entry", value: { id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "first" } },
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
second.commit(
|
||||
[
|
||||
{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } },
|
||||
{ type: "entry", value: { id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "second" } },
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
]);
|
||||
expect((await first.entry(entryId, BACKGROUND_CONTEXT))?.entry.kind).toBe("first");
|
||||
expect((await second.entry(entryId, BACKGROUND_CONTEXT))?.entry.kind).toBe("second");
|
||||
await Promise.all([first.close(BACKGROUND_CONTEXT), second.close(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
|
||||
it("rejects invalid and reserved prefixes", async () => {
|
||||
await expect(openDurableObjectSqliteStorage(durableObjectStorage(), { prefix: "" })).rejects.toThrow("Invalid");
|
||||
await expect(openDurableObjectSqliteStorage(durableObjectStorage(), { prefix: "bad-prefix" })).rejects.toThrow(
|
||||
"Invalid",
|
||||
);
|
||||
await expect(openDurableObjectSqliteStorage(durableObjectStorage(), { prefix: "sqlite_pi_" })).rejects.toThrow(
|
||||
"reserved",
|
||||
);
|
||||
});
|
||||
|
||||
it("binds and returns BLOB values as Uint8Array", async () => {
|
||||
const database = new DurableObjectSqliteDatabase(durableObjectStorage());
|
||||
await database.exec("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB, n INTEGER)");
|
||||
|
|
@ -67,6 +154,17 @@ describe("Durable Object SQLite adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reads only the cursor's first row for get", async () => {
|
||||
const operations: string[] = [];
|
||||
const database = new DurableObjectSqliteDatabase(
|
||||
durableObjectStorage(new DatabaseSync(":memory:"), (operation) => operations.push(operation)),
|
||||
);
|
||||
await database.exec("CREATE TABLE items (id INTEGER PRIMARY KEY)");
|
||||
await database.run("INSERT INTO items (id) VALUES (1), (2)");
|
||||
expect(await database.get("SELECT id FROM items ORDER BY id")).toEqual({ id: 1 });
|
||||
expect(operations).toEqual(["next"]);
|
||||
});
|
||||
|
||||
it("rejects a bigint binding outside the safe integer range", async () => {
|
||||
const database = new DurableObjectSqliteDatabase(durableObjectStorage());
|
||||
await database.exec("CREATE TABLE numbers (n INTEGER)");
|
||||
|
|
@ -92,6 +190,15 @@ describe("Durable Object SQLite adapter", () => {
|
|||
expect(order).toEqual(["transaction", "close"]);
|
||||
});
|
||||
|
||||
it("runs an uncontended statement before returning its promise", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const database = new DurableObjectSqliteDatabase(durableObjectStorage(sqlite));
|
||||
await database.exec("CREATE TABLE items (id INTEGER PRIMARY KEY)");
|
||||
const written = database.run("INSERT INTO items (id) VALUES (?)", 1);
|
||||
expect(sqlite.prepare("SELECT id FROM items").all()).toEqual([{ id: 1 }]);
|
||||
await written;
|
||||
});
|
||||
|
||||
it("rolls back a rejected transaction and rejects its handle afterwards", async () => {
|
||||
const database = new DurableObjectSqliteDatabase(durableObjectStorage());
|
||||
await database.exec("CREATE TABLE items (id INTEGER PRIMARY KEY)");
|
||||
|
|
@ -107,24 +214,43 @@ describe("Durable Object SQLite adapter", () => {
|
|||
await expect(handle!.run("INSERT INTO items (id) VALUES (?)", 2)).rejects.toThrow("no longer active");
|
||||
});
|
||||
|
||||
it("queues operations behind an active transaction", async () => {
|
||||
const database = new DurableObjectSqliteDatabase(durableObjectStorage());
|
||||
it("runs operations from another adapter after a transaction, outside its rollback", async () => {
|
||||
const storage = durableObjectStorage();
|
||||
const database = new DurableObjectSqliteDatabase(storage);
|
||||
const other = new DurableObjectSqliteDatabase(storage);
|
||||
await database.exec("CREATE TABLE items (id INTEGER PRIMARY KEY)");
|
||||
const order: string[] = [];
|
||||
const { promise: gate, resolve } = Promise.withResolvers<void>();
|
||||
const transaction = database.transaction(async (handle) => {
|
||||
await handle.run("INSERT INTO items (id) VALUES (?)", 1);
|
||||
await gate;
|
||||
order.push("transaction");
|
||||
});
|
||||
const read = database.all("SELECT id FROM items").then((rows) => {
|
||||
order.push("read");
|
||||
return rows;
|
||||
throw new Error("abort");
|
||||
});
|
||||
const outside = other.run("INSERT INTO items (id) VALUES (?)", 2);
|
||||
const read = other.all("SELECT id FROM items");
|
||||
resolve();
|
||||
await transaction;
|
||||
expect(await read).toEqual([{ id: 1 }]);
|
||||
expect(order).toEqual(["transaction", "read"]);
|
||||
await expect(transaction).rejects.toThrow("abort");
|
||||
await outside;
|
||||
expect(await read).toEqual([{ id: 2 }]);
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it("includes direct underlying calls in an active transaction", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const storage = durableObjectStorage(sqlite);
|
||||
const database = new DurableObjectSqliteDatabase(storage);
|
||||
await database.exec("CREATE TABLE items (id INTEGER PRIMARY KEY)");
|
||||
const { promise: opened, resolve: markOpened } = Promise.withResolvers<void>();
|
||||
const { promise: gate, resolve } = Promise.withResolvers<void>();
|
||||
const transaction = database.transaction(async (handle) => {
|
||||
await handle.run("INSERT INTO items (id) VALUES (?)", 1);
|
||||
markOpened();
|
||||
await gate;
|
||||
throw new Error("abort");
|
||||
});
|
||||
await opened;
|
||||
storage.sql.exec("INSERT INTO items (id) VALUES (?)", 2);
|
||||
resolve();
|
||||
await expect(transaction).rejects.toThrow("abort");
|
||||
expect(sqlite.prepare("SELECT id FROM items").all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -190,6 +190,46 @@ describe("Pico SqliteStorage", () => {
|
|||
).toBe(3);
|
||||
});
|
||||
|
||||
it("namespaces tables and indexes with a prefix across reopen", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-sqlite-"));
|
||||
tempDirectories.add(directory);
|
||||
const path = join(directory, "storage.sqlite");
|
||||
const host = new DatabaseSync(path);
|
||||
try {
|
||||
host.exec("CREATE TABLE tasks (text TEXT)");
|
||||
} finally {
|
||||
host.close();
|
||||
}
|
||||
const storage = await openNodeSqliteStorage(path, { prefix: "pi_" });
|
||||
expect(await createRoot(storage)).toBe(1);
|
||||
await storage.close(context);
|
||||
|
||||
const reopened = await openNodeSqliteStorage(path, { prefix: "pi_" });
|
||||
openStorages.add(reopened);
|
||||
expect(await reopened.conversation(ROOT_CONVERSATION_ID, context)).toEqual({ id: ROOT_CONVERSATION_ID });
|
||||
const database = new DatabaseSync(path);
|
||||
try {
|
||||
const names = database
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'")
|
||||
.all()
|
||||
.map((row) => String(row.name));
|
||||
expect(names.filter((name) => !name.startsWith("pi_"))).toEqual(["tasks"]);
|
||||
expect(names).toContain("pi_tasks");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid prefix before creating the database", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-sqlite-"));
|
||||
tempDirectories.add(directory);
|
||||
const databaseDirectory = join(directory, "nested");
|
||||
await expect(
|
||||
openNodeSqliteStorage(join(databaseDirectory, "storage.sqlite"), { prefix: "bad-prefix" }),
|
||||
).rejects.toThrow("Invalid SQLite schema prefix");
|
||||
await expect(stat(databaseDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects persisted metadata corruption on reopen", async () => {
|
||||
const { storage, path } = await createSqliteStorage();
|
||||
await storage.close(context);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue