Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions mcp-server/src/__tests__/db-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(env: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
const prev: Record<string, string | undefined> = {};
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 });
});
82 changes: 82 additions & 0 deletions mcp-server/src/services/db-client.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/**
* 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<DbBootResult> {
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.
},
};
}
Loading