summaryrefslogtreecommitdiffhomepage
path: root/packages/storage-sqlite/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 23:22:03 +0900
committerAdam Malczewski <[email protected]>2026-06-04 23:22:03 +0900
commit9b611d614d123462e50492d78202dae696b99aa2 (patch)
tree7e0c85b60dd0becddbb04c29fdf14855c10c090f /packages/storage-sqlite/src
parent974ce6f46c25a522a42c6bd04fd62ce2d031aad5 (diff)
downloaddispatch-9b611d614d123462e50492d78202dae696b99aa2.tar.gz
dispatch-9b611d614d123462e50492d78202dae696b99aa2.zip
feat(core-ext): storage-sqlite, auth-apikey, provider-openai-compat
- storage-sqlite: bun:sqlite StorageNamespace backend + migrations (21 bun tests) - auth-apikey: pure resolver from env → ApiKeyCredentials (4 tests) - provider-openai-compat: OpenAI-compatible SSE stream → ProviderEvents - orchestrator fixes: provider imports (@dispatch/kernel), missing dep, exactOptionalPropertyTypes (omit-when-undefined), root tsconfig refs - vitest excludes storage-sqlite (bun:sqlite); test:bun runs it under bun
Diffstat (limited to 'packages/storage-sqlite/src')
-rw-r--r--packages/storage-sqlite/src/extension.ts17
-rw-r--r--packages/storage-sqlite/src/index.ts5
-rw-r--r--packages/storage-sqlite/src/migrate.ts14
-rw-r--r--packages/storage-sqlite/src/storage.test.ts218
-rw-r--r--packages/storage-sqlite/src/storage.ts92
5 files changed, 346 insertions, 0 deletions
diff --git a/packages/storage-sqlite/src/extension.ts b/packages/storage-sqlite/src/extension.ts
new file mode 100644
index 0000000..63a71af
--- /dev/null
+++ b/packages/storage-sqlite/src/extension.ts
@@ -0,0 +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 },
+ contributes: { services: ["storage"] },
+ activation: "eager",
+};
+
+export const extension: Extension = {
+ manifest,
+ activate: async (_host: HostAPI) => {},
+};
diff --git a/packages/storage-sqlite/src/index.ts b/packages/storage-sqlite/src/index.ts
new file mode 100644
index 0000000..4fbedfc
--- /dev/null
+++ b/packages/storage-sqlite/src/index.ts
@@ -0,0 +1,5 @@
+export { extension, manifest } from "./extension.js";
+export type { Migration } from "./migrate.js";
+export { computePending } from "./migrate.js";
+export type { SqliteStorageBackend, StorageFactory } from "./storage.js";
+export { createSqliteStorage } from "./storage.js";
diff --git a/packages/storage-sqlite/src/migrate.ts b/packages/storage-sqlite/src/migrate.ts
new file mode 100644
index 0000000..bdd32b8
--- /dev/null
+++ b/packages/storage-sqlite/src/migrate.ts
@@ -0,0 +1,14 @@
+export interface Migration {
+ readonly version: number;
+ readonly name: string;
+ readonly up: string;
+}
+
+export function computePending(
+ appliedVersions: ReadonlySet<number>,
+ migrations: readonly Migration[],
+): readonly Migration[] {
+ 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
new file mode 100644
index 0000000..fc14498
--- /dev/null
+++ b/packages/storage-sqlite/src/storage.test.ts
@@ -0,0 +1,218 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Migration } from "./migrate.js";
+import { computePending } from "./migrate.js";
+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]);
+ });
+});
+
+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);
+ });
+ });
+});
+
+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);
+ });
+});
diff --git a/packages/storage-sqlite/src/storage.ts b/packages/storage-sqlite/src/storage.ts
new file mode 100644
index 0000000..2499664
--- /dev/null
+++ b/packages/storage-sqlite/src/storage.ts
@@ -0,0 +1,92 @@
+import { Database } from "bun:sqlite";
+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;
+}
+
+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(`
+ CREATE TABLE IF NOT EXISTS kv (
+ namespace TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY (namespace, key)
+ );
+ `);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS _migrations (
+ namespace TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ applied_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (namespace, version)
+ );
+ `);
+
+ 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);
+ },
+ };
+ }
+
+ 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();
+ }
+ }
+
+ function close(): void {
+ db.close();
+ }
+
+ return { storage, migrate, close };
+}