diff --git a/mcp-server/src/__tests__/db-client.test.ts b/mcp-server/src/__tests__/db-client.test.ts new file mode 100644 index 0000000..4c82993 --- /dev/null +++ b/mcp-server/src/__tests__/db-client.test.ts @@ -0,0 +1,128 @@ +// Boot-factory tests for services/db-client.ts. This is the foundation the +// upcoming service refactor (each `(supabaseUrl, supabaseKey)` constructor +// migrating to a shared `DbClient` instance) will lean on, so the env-flag +// behaviour is pinned here before any service starts depending on it. +// +// Adapter contract is tested separately (pglite-adapter.test.ts walks every +// query verb shape against a real PGlite instance); these tests only assert +// the boot path returns the right backend kind and a usable handle. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; + +import { PostgrestClient } from "@supabase/postgrest-js"; + +import { createDbClient } from "../services/db-client.js"; +import { PGliteAdapter } from "../native/pglite.js"; + +async function withEnv(env: Record, run: () => Promise): Promise { + const prev: Record = {}; + for (const k of Object.keys(env)) { + prev[k] = process.env[k]; + if (env[k] === undefined) delete process.env[k]; + else process.env[k] = env[k]; + } + try { + return await run(); + } finally { + for (const k of Object.keys(prev)) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } +} + +test("createDbClient: PostgREST mode (default — env unset)", async () => { + await withEnv({ MYCELIUM_USE_PGLITE: undefined }, async () => { + const boot = await createDbClient({ + supabaseUrl: "http://localhost:54321", + supabaseKey: "test-key", + }); + try { + assert.equal(boot.mode, "postgrest"); + assert.ok(boot.client instanceof PostgrestClient); + } finally { + await boot.close(); + } + }); +}); + +test("createDbClient: PostgREST mode is selected for any non-1 value", async () => { + for (const value of ["0", "false", "true", "yes", ""]) { + await withEnv({ MYCELIUM_USE_PGLITE: value }, async () => { + const boot = await createDbClient({ + supabaseUrl: "http://localhost:54321", + supabaseKey: "", + }); + try { + assert.equal(boot.mode, "postgrest", `value=${JSON.stringify(value)} should not select pglite`); + } finally { + await boot.close(); + } + }); + } +}); + +test("createDbClient: PGlite mode boots an adapter with usable .from()", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mycelium-db-client-test-")); + await withEnv( + { + MYCELIUM_USE_PGLITE: "1", + MYCELIUM_DATA_DIR: tmp, + }, + async () => { + const boot = await createDbClient({ + supabaseUrl: "ignored-when-pglite", + supabaseKey: "ignored-when-pglite", + pglite: { skipMigrations: true }, + }); + try { + assert.equal(boot.mode, "pglite"); + assert.ok(boot.client instanceof PGliteAdapter); + + // Smoke: adapter responds to a trivial query through the chain. + // No migrations were applied, so we touch a temporary table to + // prove .from() / .insert() / .select() round-trip. + const adapter = boot.client as PGliteAdapter; + await adapter.raw().exec(` + CREATE TABLE db_client_smoke (id INT PRIMARY KEY, label TEXT) + `); + const ins = await adapter + .from("db_client_smoke") + .insert({ id: 1, label: "ok" }) + .select() + .single(); + assert.equal(ins.error, null); + assert.equal((ins.data as { label: string }).label, "ok"); + } finally { + await boot.close(); + } + }, + ); + await fs.rm(tmp, { recursive: true, force: true }); +}); + +test("createDbClient: close() in PGlite mode releases the underlying handle", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mycelium-db-client-test-")); + await withEnv( + { + MYCELIUM_USE_PGLITE: "1", + MYCELIUM_DATA_DIR: tmp, + }, + async () => { + const boot = await createDbClient({ + supabaseUrl: "x", + supabaseKey: "y", + pglite: { skipMigrations: true }, + }); + const adapter = boot.client as PGliteAdapter; + await boot.close(); + // After close, the underlying PGlite handle reports closed. + assert.equal(adapter.raw().closed, true); + }, + ); + await fs.rm(tmp, { recursive: true, force: true }); +}); diff --git a/mcp-server/src/services/db-client.ts b/mcp-server/src/services/db-client.ts new file mode 100644 index 0000000..86fd1c7 --- /dev/null +++ b/mcp-server/src/services/db-client.ts @@ -0,0 +1,82 @@ +// One-stop boot for the database client services in src/services/ depend on. +// +// Two backends share the same PostgREST surface (`.from(table).select|insert| +// update|delete().eq|in|or|order|limit().single|maybeSingle()` plus +// `.rpc(name, args)` returning `{ data, error }`): +// +// - `PostgrestClient` from @supabase/postgrest-js — Docker / hosted mode, +// hits the PostgREST endpoint at SUPABASE_URL. +// - `PGliteAdapter` from src/native/pglite.ts — native mode, +// speaks the same chain shape directly to an in-process PGlite WASM +// Postgres. The adapter contract tests (pglite-adapter.test.ts) gate +// the surface so it stays drop-in compatible. +// +// Why this lives separate from src/index.ts: +// The next native-app PR migrates ~30 service constructors away from +// `(supabaseUrl, supabaseKey)` and onto a shared `DbClient` instance. +// Keeping the factory here means each migrated service imports a single +// stable type and the env-flag plumbing is exercised by a small unit +// test, not the MCP server boot smoke test. +// +// Selection: MYCELIUM_USE_PGLITE=1 picks the native adapter; anything else +// (unset, "0", "false", any other value) keeps PostgREST. + +import { PostgrestClient } from "@supabase/postgrest-js"; + +import type { PGliteAdapter } from "../native/pglite.js"; + +export type DbClient = PostgrestClient | PGliteAdapter; +export type DbClientMode = "pglite" | "postgrest"; + +export interface DbBootOptions { + supabaseUrl: string; + supabaseKey: string; + /** + * Pass-through to createNativeDb() when MYCELIUM_USE_PGLITE=1. Tests use + * `skipMigrations: true` to keep the adapter wakeup under a second; the + * 79-migration walk is exercised separately in pglite-migration-walk.test.ts. + */ + pglite?: { + dataDir?: string; + migrationsDir?: string; + skipMigrations?: boolean; + }; +} + +export interface DbBootResult { + client: DbClient; + mode: DbClientMode; + /** Tear down any owned resources (PGlite handle, etc). No-op in PostgREST mode. */ + close(): Promise; +} + +/** + * MYCELIUM_USE_PGLITE=1 routes through the in-process PGlite adapter; any + * other value keeps the PostgREST client pointed at SUPABASE_URL. Returns a + * `close()` so callers can tear the native handle down on shutdown. + */ +export async function createDbClient(opts: DbBootOptions): Promise { + if (process.env.MYCELIUM_USE_PGLITE === "1") { + const { createNativeDb } = await import("../native/factory.js"); + const db = await createNativeDb(opts.pglite ?? {}); + return { + client: db.adapter, + mode: "pglite", + close: () => db.close(), + }; + } + + const client = new PostgrestClient(opts.supabaseUrl, { + headers: opts.supabaseKey + ? { Authorization: `Bearer ${opts.supabaseKey}`, apikey: opts.supabaseKey } + : {}, + }); + return { + client, + mode: "postgrest", + close: async () => { + // PostgrestClient holds no sockets of its own — fetch handles those + // per-request — so there is nothing to release. + }, + }; +}