diff options
Diffstat (limited to 'packages/storage-sqlite/src')
| -rw-r--r-- | packages/storage-sqlite/src/extension.ts | 20 | ||||
| -rw-r--r-- | packages/storage-sqlite/src/migrate.ts | 16 | ||||
| -rw-r--r-- | packages/storage-sqlite/src/storage.test.ts | 408 | ||||
| -rw-r--r-- | packages/storage-sqlite/src/storage.ts | 120 |
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 }; } |
