summaryrefslogtreecommitdiffhomepage
path: root/packages/storage-sqlite/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/storage-sqlite/src')
-rw-r--r--packages/storage-sqlite/src/extension.ts20
-rw-r--r--packages/storage-sqlite/src/migrate.ts16
-rw-r--r--packages/storage-sqlite/src/storage.test.ts408
-rw-r--r--packages/storage-sqlite/src/storage.ts120
4 files changed, 282 insertions, 282 deletions
diff --git a/packages/storage-sqlite/src/extension.ts b/packages/storage-sqlite/src/extension.ts
index 32297e5..cbf2ec0 100644
--- a/packages/storage-sqlite/src/extension.ts
+++ b/packages/storage-sqlite/src/extension.ts
@@ -1,17 +1,17 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
export const manifest: Manifest = {
- id: "storage-sqlite",
- name: "SQLite Storage Backend",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- capabilities: { db: true },
- activation: "eager",
+ id: "storage-sqlite",
+ name: "SQLite Storage Backend",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ capabilities: { db: true },
+ activation: "eager",
};
export const extension: Extension = {
- manifest,
- // No-op: the SQLite backend is a kernel bootstrap dep injected via HostDeps.storageFactory, not a bus service.
- activate: async (_host: HostAPI) => {},
+ manifest,
+ // No-op: the SQLite backend is a kernel bootstrap dep injected via HostDeps.storageFactory, not a bus service.
+ activate: async (_host: HostAPI) => {},
};
diff --git a/packages/storage-sqlite/src/migrate.ts b/packages/storage-sqlite/src/migrate.ts
index bdd32b8..43f418d 100644
--- a/packages/storage-sqlite/src/migrate.ts
+++ b/packages/storage-sqlite/src/migrate.ts
@@ -1,14 +1,14 @@
export interface Migration {
- readonly version: number;
- readonly name: string;
- readonly up: string;
+ readonly version: number;
+ readonly name: string;
+ readonly up: string;
}
export function computePending(
- appliedVersions: ReadonlySet<number>,
- migrations: readonly Migration[],
+ appliedVersions: ReadonlySet<number>,
+ migrations: readonly Migration[],
): readonly Migration[] {
- return migrations
- .filter((m) => !appliedVersions.has(m.version))
- .sort((a, b) => a.version - b.version);
+ return migrations
+ .filter((m) => !appliedVersions.has(m.version))
+ .sort((a, b) => a.version - b.version);
}
diff --git a/packages/storage-sqlite/src/storage.test.ts b/packages/storage-sqlite/src/storage.test.ts
index fc14498..bfadc67 100644
--- a/packages/storage-sqlite/src/storage.test.ts
+++ b/packages/storage-sqlite/src/storage.test.ts
@@ -5,214 +5,214 @@ import type { SqliteStorageBackend } from "./storage.js";
import { createSqliteStorage } from "./storage.js";
describe("computePending (pure)", () => {
- it("returns all migrations when none applied", () => {
- const migrations: Migration[] = [
- { version: 2, name: "b", up: "" },
- { version: 1, name: "a", up: "" },
- ];
- const result = computePending(new Set(), migrations);
- expect(result.map((m) => m.version)).toEqual([1, 2]);
- });
-
- it("skips already-applied versions", () => {
- const migrations: Migration[] = [
- { version: 1, name: "a", up: "" },
- { version: 2, name: "b", up: "" },
- { version: 3, name: "c", up: "" },
- ];
- const result = computePending(new Set([1, 3]), migrations);
- expect(result.map((m) => m.version)).toEqual([2]);
- });
-
- it("returns empty when all applied", () => {
- const migrations: Migration[] = [{ version: 1, name: "a", up: "" }];
- const result = computePending(new Set([1]), migrations);
- expect(result).toEqual([]);
- });
-
- it("sorts by version ascending", () => {
- const migrations: Migration[] = [
- { version: 3, name: "c", up: "" },
- { version: 1, name: "a", up: "" },
- { version: 2, name: "b", up: "" },
- ];
- const result = computePending(new Set(), migrations);
- expect(result.map((m) => m.version)).toEqual([1, 2, 3]);
- });
+ it("returns all migrations when none applied", () => {
+ const migrations: Migration[] = [
+ { version: 2, name: "b", up: "" },
+ { version: 1, name: "a", up: "" },
+ ];
+ const result = computePending(new Set(), migrations);
+ expect(result.map((m) => m.version)).toEqual([1, 2]);
+ });
+
+ it("skips already-applied versions", () => {
+ const migrations: Migration[] = [
+ { version: 1, name: "a", up: "" },
+ { version: 2, name: "b", up: "" },
+ { version: 3, name: "c", up: "" },
+ ];
+ const result = computePending(new Set([1, 3]), migrations);
+ expect(result.map((m) => m.version)).toEqual([2]);
+ });
+
+ it("returns empty when all applied", () => {
+ const migrations: Migration[] = [{ version: 1, name: "a", up: "" }];
+ const result = computePending(new Set([1]), migrations);
+ expect(result).toEqual([]);
+ });
+
+ it("sorts by version ascending", () => {
+ const migrations: Migration[] = [
+ { version: 3, name: "c", up: "" },
+ { version: 1, name: "a", up: "" },
+ { version: 2, name: "b", up: "" },
+ ];
+ const result = computePending(new Set(), migrations);
+ expect(result.map((m) => m.version)).toEqual([1, 2, 3]);
+ });
});
describe("createSqliteStorage", () => {
- let backend: SqliteStorageBackend;
-
- beforeEach(() => {
- backend = createSqliteStorage({ path: ":memory:" });
- });
-
- afterEach(() => {
- backend.close();
- });
-
- describe("StorageNamespace get/set/delete/has", () => {
- it("returns null for missing key", async () => {
- const ns = backend.storage("test");
- expect(await ns.get("missing")).toBeNull();
- });
-
- it("roundtrips set then get", async () => {
- const ns = backend.storage("test");
- await ns.set("key1", "value1");
- expect(await ns.get("key1")).toBe("value1");
- });
-
- it("overwrites existing value", async () => {
- const ns = backend.storage("test");
- await ns.set("key1", "v1");
- await ns.set("key1", "v2");
- expect(await ns.get("key1")).toBe("v2");
- });
-
- it("has returns false for missing, true for existing", async () => {
- const ns = backend.storage("test");
- expect(await ns.has("key1")).toBe(false);
- await ns.set("key1", "val");
- expect(await ns.has("key1")).toBe(true);
- });
-
- it("delete removes the key", async () => {
- const ns = backend.storage("test");
- await ns.set("key1", "val");
- await ns.delete("key1");
- expect(await ns.get("key1")).toBeNull();
- expect(await ns.has("key1")).toBe(false);
- });
-
- it("delete on missing key is a no-op", async () => {
- const ns = backend.storage("test");
- await ns.delete("nonexistent");
- });
- });
-
- describe("keys", () => {
- it("returns all keys in namespace", async () => {
- const ns = backend.storage("test");
- await ns.set("a", "1");
- await ns.set("b", "2");
- await ns.set("c", "3");
- const keys = await ns.keys();
- expect(keys.toSorted()).toEqual(["a", "b", "c"]);
- });
-
- it("filters by prefix", async () => {
- const ns = backend.storage("test");
- await ns.set("foo:1", "a");
- await ns.set("foo:2", "b");
- await ns.set("bar:1", "c");
- const keys = await ns.keys("foo");
- expect(keys.toSorted()).toEqual(["foo:1", "foo:2"]);
- });
-
- it("returns empty for no matches", async () => {
- const ns = backend.storage("test");
- await ns.set("a", "1");
- expect(await ns.keys("zzz")).toEqual([]);
- });
- });
-
- describe("namespace isolation", () => {
- it("same key in different namespaces does not collide", async () => {
- const ns1 = backend.storage("ns1");
- const ns2 = backend.storage("ns2");
- await ns1.set("key", "value1");
- await ns2.set("key", "value2");
- expect(await ns1.get("key")).toBe("value1");
- expect(await ns2.get("key")).toBe("value2");
- });
-
- it("delete in one namespace does not affect another", async () => {
- const ns1 = backend.storage("ns1");
- const ns2 = backend.storage("ns2");
- await ns1.set("key", "v1");
- await ns2.set("key", "v2");
- await ns1.delete("key");
- expect(await ns1.has("key")).toBe(false);
- expect(await ns2.get("key")).toBe("v2");
- });
-
- it("keys are scoped to namespace", async () => {
- const ns1 = backend.storage("ns1");
- const ns2 = backend.storage("ns2");
- await ns1.set("a", "1");
- await ns1.set("b", "2");
- await ns2.set("c", "3");
- expect((await ns1.keys()).toSorted()).toEqual(["a", "b"]);
- expect(await ns2.keys()).toEqual(["c"]);
- });
- });
-
- describe("migrate", () => {
- it("runs pending migrations in order", async () => {
- const migrations: Migration[] = [
- { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
- { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
- ];
- await backend.migrate("ext1", migrations);
-
- const ns = backend.storage("ext1");
- await ns.set("test", "val");
- expect(await ns.get("test")).toBe("val");
- });
-
- it("skips already-applied migrations", async () => {
- const migrations: Migration[] = [
- { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
- ];
- await backend.migrate("ext1", migrations);
- await backend.migrate("ext1", [
- ...migrations,
- { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
- ]);
- });
-
- it("is idempotent — calling migrate twice with same migrations is safe", async () => {
- const migrations: Migration[] = [
- { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
- ];
- await backend.migrate("ext1", migrations);
- await backend.migrate("ext1", migrations);
- });
-
- it("migrations are per-namespace", async () => {
- const m1: Migration[] = [
- { version: 1, name: "create_t1", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY);" },
- ];
- const m2: Migration[] = [
- { version: 1, name: "create_t2", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY);" },
- ];
- await backend.migrate("ext1", m1);
- await backend.migrate("ext2", m2);
- });
- });
+ let backend: SqliteStorageBackend;
+
+ beforeEach(() => {
+ backend = createSqliteStorage({ path: ":memory:" });
+ });
+
+ afterEach(() => {
+ backend.close();
+ });
+
+ describe("StorageNamespace get/set/delete/has", () => {
+ it("returns null for missing key", async () => {
+ const ns = backend.storage("test");
+ expect(await ns.get("missing")).toBeNull();
+ });
+
+ it("roundtrips set then get", async () => {
+ const ns = backend.storage("test");
+ await ns.set("key1", "value1");
+ expect(await ns.get("key1")).toBe("value1");
+ });
+
+ it("overwrites existing value", async () => {
+ const ns = backend.storage("test");
+ await ns.set("key1", "v1");
+ await ns.set("key1", "v2");
+ expect(await ns.get("key1")).toBe("v2");
+ });
+
+ it("has returns false for missing, true for existing", async () => {
+ const ns = backend.storage("test");
+ expect(await ns.has("key1")).toBe(false);
+ await ns.set("key1", "val");
+ expect(await ns.has("key1")).toBe(true);
+ });
+
+ it("delete removes the key", async () => {
+ const ns = backend.storage("test");
+ await ns.set("key1", "val");
+ await ns.delete("key1");
+ expect(await ns.get("key1")).toBeNull();
+ expect(await ns.has("key1")).toBe(false);
+ });
+
+ it("delete on missing key is a no-op", async () => {
+ const ns = backend.storage("test");
+ await ns.delete("nonexistent");
+ });
+ });
+
+ describe("keys", () => {
+ it("returns all keys in namespace", async () => {
+ const ns = backend.storage("test");
+ await ns.set("a", "1");
+ await ns.set("b", "2");
+ await ns.set("c", "3");
+ const keys = await ns.keys();
+ expect(keys.toSorted()).toEqual(["a", "b", "c"]);
+ });
+
+ it("filters by prefix", async () => {
+ const ns = backend.storage("test");
+ await ns.set("foo:1", "a");
+ await ns.set("foo:2", "b");
+ await ns.set("bar:1", "c");
+ const keys = await ns.keys("foo");
+ expect(keys.toSorted()).toEqual(["foo:1", "foo:2"]);
+ });
+
+ it("returns empty for no matches", async () => {
+ const ns = backend.storage("test");
+ await ns.set("a", "1");
+ expect(await ns.keys("zzz")).toEqual([]);
+ });
+ });
+
+ describe("namespace isolation", () => {
+ it("same key in different namespaces does not collide", async () => {
+ const ns1 = backend.storage("ns1");
+ const ns2 = backend.storage("ns2");
+ await ns1.set("key", "value1");
+ await ns2.set("key", "value2");
+ expect(await ns1.get("key")).toBe("value1");
+ expect(await ns2.get("key")).toBe("value2");
+ });
+
+ it("delete in one namespace does not affect another", async () => {
+ const ns1 = backend.storage("ns1");
+ const ns2 = backend.storage("ns2");
+ await ns1.set("key", "v1");
+ await ns2.set("key", "v2");
+ await ns1.delete("key");
+ expect(await ns1.has("key")).toBe(false);
+ expect(await ns2.get("key")).toBe("v2");
+ });
+
+ it("keys are scoped to namespace", async () => {
+ const ns1 = backend.storage("ns1");
+ const ns2 = backend.storage("ns2");
+ await ns1.set("a", "1");
+ await ns1.set("b", "2");
+ await ns2.set("c", "3");
+ expect((await ns1.keys()).toSorted()).toEqual(["a", "b"]);
+ expect(await ns2.keys()).toEqual(["c"]);
+ });
+ });
+
+ describe("migrate", () => {
+ it("runs pending migrations in order", async () => {
+ const migrations: Migration[] = [
+ { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
+ { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
+ ];
+ await backend.migrate("ext1", migrations);
+
+ const ns = backend.storage("ext1");
+ await ns.set("test", "val");
+ expect(await ns.get("test")).toBe("val");
+ });
+
+ it("skips already-applied migrations", async () => {
+ const migrations: Migration[] = [
+ { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
+ ];
+ await backend.migrate("ext1", migrations);
+ await backend.migrate("ext1", [
+ ...migrations,
+ { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
+ ]);
+ });
+
+ it("is idempotent — calling migrate twice with same migrations is safe", async () => {
+ const migrations: Migration[] = [
+ { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
+ ];
+ await backend.migrate("ext1", migrations);
+ await backend.migrate("ext1", migrations);
+ });
+
+ it("migrations are per-namespace", async () => {
+ const m1: Migration[] = [
+ { version: 1, name: "create_t1", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY);" },
+ ];
+ const m2: Migration[] = [
+ { version: 1, name: "create_t2", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY);" },
+ ];
+ await backend.migrate("ext1", m1);
+ await backend.migrate("ext2", m2);
+ });
+ });
});
describe("createSqliteStorage — persistence across reopen", () => {
- it("migrations survive close and reopen", async () => {
- const tmpPath = `/tmp/storage-sqlite-test-${Date.now()}.db`;
- const migrations: Migration[] = [
- { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
- ];
-
- const first = createSqliteStorage({ path: tmpPath });
- await first.migrate("ext1", migrations);
- first.close();
-
- const second = createSqliteStorage({ path: tmpPath });
- await second.migrate("ext1", [
- ...migrations,
- { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
- ]);
- second.close();
-
- const { unlinkSync } = await import("node:fs");
- unlinkSync(tmpPath);
- });
+ it("migrations survive close and reopen", async () => {
+ const tmpPath = `/tmp/storage-sqlite-test-${Date.now()}.db`;
+ const migrations: Migration[] = [
+ { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" },
+ ];
+
+ const first = createSqliteStorage({ path: tmpPath });
+ await first.migrate("ext1", migrations);
+ first.close();
+
+ const second = createSqliteStorage({ path: tmpPath });
+ await second.migrate("ext1", [
+ ...migrations,
+ { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" },
+ ]);
+ second.close();
+
+ const { unlinkSync } = await import("node:fs");
+ unlinkSync(tmpPath);
+ });
});
diff --git a/packages/storage-sqlite/src/storage.ts b/packages/storage-sqlite/src/storage.ts
index 2499664..94a826d 100644
--- a/packages/storage-sqlite/src/storage.ts
+++ b/packages/storage-sqlite/src/storage.ts
@@ -3,17 +3,17 @@ import type { StorageNamespace } from "@dispatch/kernel";
import { computePending, type Migration } from "./migrate.js";
export interface SqliteStorageBackend {
- readonly storage: (namespace: string) => StorageNamespace;
- readonly migrate: (namespace: string, migrations: readonly Migration[]) => Promise<void>;
- readonly close: () => void;
+ readonly storage: (namespace: string) => StorageNamespace;
+ readonly migrate: (namespace: string, migrations: readonly Migration[]) => Promise<void>;
+ readonly close: () => void;
}
export type StorageFactory = (opts: { path: string }) => SqliteStorageBackend;
export function createSqliteStorage(opts: { path: string }): SqliteStorageBackend {
- const db = new Database(opts.path);
- db.exec("PRAGMA journal_mode = WAL;");
- db.exec(`
+ const db = new Database(opts.path);
+ db.exec("PRAGMA journal_mode = WAL;");
+ db.exec(`
CREATE TABLE IF NOT EXISTS kv (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
@@ -21,7 +21,7 @@ export function createSqliteStorage(opts: { path: string }): SqliteStorageBacken
PRIMARY KEY (namespace, key)
);
`);
- db.exec(`
+ db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
namespace TEXT NOT NULL,
version INTEGER NOT NULL,
@@ -31,62 +31,62 @@ export function createSqliteStorage(opts: { path: string }): SqliteStorageBacken
);
`);
- const getStmt = db.prepare("SELECT value FROM kv WHERE namespace = ?1 AND key = ?2");
- const setStmt = db.prepare(
- "INSERT OR REPLACE INTO kv (namespace, key, value) VALUES (?1, ?2, ?3)",
- );
- const deleteStmt = db.prepare("DELETE FROM kv WHERE namespace = ?1 AND key = ?2");
- const hasStmt = db.prepare("SELECT 1 FROM kv WHERE namespace = ?1 AND key = ?2");
- const keysAllStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1");
- const keysPrefixStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1 AND key LIKE ?2");
- const appliedVersionsStmt = db.prepare("SELECT version FROM _migrations WHERE namespace = ?1");
- const recordMigrationStmt = db.prepare(
- "INSERT INTO _migrations (namespace, version, name) VALUES (?1, ?2, ?3)",
- );
+ const getStmt = db.prepare("SELECT value FROM kv WHERE namespace = ?1 AND key = ?2");
+ const setStmt = db.prepare(
+ "INSERT OR REPLACE INTO kv (namespace, key, value) VALUES (?1, ?2, ?3)",
+ );
+ const deleteStmt = db.prepare("DELETE FROM kv WHERE namespace = ?1 AND key = ?2");
+ const hasStmt = db.prepare("SELECT 1 FROM kv WHERE namespace = ?1 AND key = ?2");
+ const keysAllStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1");
+ const keysPrefixStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1 AND key LIKE ?2");
+ const appliedVersionsStmt = db.prepare("SELECT version FROM _migrations WHERE namespace = ?1");
+ const recordMigrationStmt = db.prepare(
+ "INSERT INTO _migrations (namespace, version, name) VALUES (?1, ?2, ?3)",
+ );
- function storage(namespace: string): StorageNamespace {
- return {
- get: async (key: string) => {
- const row = getStmt.get(namespace, key) as { value: string } | null;
- return row?.value ?? null;
- },
- set: async (key: string, value: string) => {
- setStmt.run(namespace, key, value);
- },
- delete: async (key: string) => {
- deleteStmt.run(namespace, key);
- },
- has: async (key: string) => {
- return hasStmt.get(namespace, key) !== null;
- },
- keys: async (prefix?: string) => {
- if (prefix !== undefined) {
- const rows = keysPrefixStmt.all(namespace, `${prefix}%`) as { key: string }[];
- return rows.map((r) => r.key);
- }
- const rows = keysAllStmt.all(namespace) as { key: string }[];
- return rows.map((r) => r.key);
- },
- };
- }
+ function storage(namespace: string): StorageNamespace {
+ return {
+ get: async (key: string) => {
+ const row = getStmt.get(namespace, key) as { value: string } | null;
+ return row?.value ?? null;
+ },
+ set: async (key: string, value: string) => {
+ setStmt.run(namespace, key, value);
+ },
+ delete: async (key: string) => {
+ deleteStmt.run(namespace, key);
+ },
+ has: async (key: string) => {
+ return hasStmt.get(namespace, key) !== null;
+ },
+ keys: async (prefix?: string) => {
+ if (prefix !== undefined) {
+ const rows = keysPrefixStmt.all(namespace, `${prefix}%`) as { key: string }[];
+ return rows.map((r) => r.key);
+ }
+ const rows = keysAllStmt.all(namespace) as { key: string }[];
+ return rows.map((r) => r.key);
+ },
+ };
+ }
- async function migrate(namespace: string, migrations: readonly Migration[]): Promise<void> {
- const rows = appliedVersionsStmt.all(namespace) as { version: number }[];
- const applied = new Set(rows.map((r) => r.version));
- const pending = computePending(applied, migrations);
+ async function migrate(namespace: string, migrations: readonly Migration[]): Promise<void> {
+ const rows = appliedVersionsStmt.all(namespace) as { version: number }[];
+ const applied = new Set(rows.map((r) => r.version));
+ const pending = computePending(applied, migrations);
- for (const m of pending) {
- const runInTransaction = db.transaction(() => {
- db.exec(m.up);
- recordMigrationStmt.run(namespace, m.version, m.name);
- });
- runInTransaction();
- }
- }
+ for (const m of pending) {
+ const runInTransaction = db.transaction(() => {
+ db.exec(m.up);
+ recordMigrationStmt.run(namespace, m.version, m.name);
+ });
+ runInTransaction();
+ }
+ }
- function close(): void {
- db.close();
- }
+ function close(): void {
+ db.close();
+ }
- return { storage, migrate, close };
+ return { storage, migrate, close };
}