mcp-session.ts was building its own combinedSchema from cloudSchema only, dropping every executor-schema table — every /mcp request failed with [storage-drizzle] unknown model "source". Export combinedSchema from services/db.ts and consume it in both DbService.Live and the MCP Durable Object so drift can't happen. Regression coverage: - db.schema.test.ts asserts combinedSchema (and drizzle's _.fullSchema) contain every cloud + executor export - mcp-session.e2e.node.test.ts drives a real @modelcontextprotocol/sdk Client through listTools / callTool / form elicitation round-trips
Showing the changes to apps/cloud/src/services (2 of 7 files). Show all
Unified · Split
| @@ -0,0 +1,61 @@ | ||
| 1 | +// Regression: every cloud drizzle instance must be constructed with the | |
| 2 | +// merge of cloud + executor schemas. If ANY call site builds its own | |
| 3 | +// `{ schema }` without spreading both, `db._.fullSchema` comes back | |
| 4 | +// missing tables and the drizzle adapter throws | |
| 5 | +// `unknown model "source"` at the first request that touches scoped data. | |
| 6 | +// | |
| 7 | +// We hit this in prod (MCP endpoint) when `mcp-session.ts` built | |
| 8 | +// `combinedSchema = { ...cloudSchema }` and forgot to spread the executor | |
| 9 | +// schema — web-app requests worked because they went through | |
| 10 | +// `DbService.Live`, but the MCP Durable Object constructed its own. | |
| 11 | +// | |
| 12 | +// This test asserts the only sanctioned constant (`combinedSchema` from | |
| 13 | +// `./db`) actually contains every executor-schema export AND that drizzle | |
| 14 | +// surfaces them all under `db._.fullSchema`. | |
| 15 | + | |
| 16 | +import { describe, expect, it } from "vitest"; | |
| 17 | +import { drizzle } from "drizzle-orm/postgres-js"; | |
| 18 | +import postgres from "postgres"; | |
| 19 | + | |
| 20 | +import * as cloudSchema from "./schema"; | |
| 21 | +import * as executorSchema from "./executor-schema"; | |
| 22 | +import { combinedSchema } from "./db"; | |
| 23 | + | |
| 24 | +describe("combinedSchema", () => { | |
| 25 | + it("spreads every cloud + executor schema export", () => { | |
| 26 | + const expected = new Set([ | |
| 27 | + ...Object.keys(cloudSchema), | |
| 28 | + ...Object.keys(executorSchema), | |
| 29 | + ]); | |
| 30 | + for (const key of expected) { | |
| 31 | + expect(combinedSchema, `combinedSchema missing "${key}"`).toHaveProperty(key); | |
| 32 | + } | |
| 33 | + }); | |
| 34 | + | |
| 35 | + // Executor-schema drives the scope-sharded tables (source/tool/etc). If | |
| 36 | + // any of these go missing the drizzle adapter's `getTable` lookup throws. | |
| 37 | + it("includes scope-sharded executor tables", () => { | |
| 38 | + for (const key of Object.keys(executorSchema)) { | |
| 39 | + expect(combinedSchema, `combinedSchema missing "${key}"`).toHaveProperty(key); | |
| 40 | + } | |
| 41 | + }); | |
| 42 | + | |
| 43 | + // The prod bug was actually at the drizzle layer: spread + __exportAll | |
| 44 | + // getters could theoretically drop tables if evaluated before their | |
| 45 | + // declarations. Construct a drizzle instance and walk its fullSchema | |
| 46 | + // to catch that class of bug too. | |
| 47 | + it("drizzle(combinedSchema) exposes every table under _.fullSchema", () => { | |
| 48 | + // postgres() lazily connects — safe to build with a dummy url, we | |
| 49 | + // never .query() so no socket is opened. | |
| 50 | + const sql = postgres("postgres://u:p@127.0.0.1:1/x", { max: 1 }); | |
| 51 | + try { | |
| 52 | + const db = drizzle(sql, { schema: combinedSchema }); | |
| 53 | + const fullSchema = (db as unknown as { _: { fullSchema: Record<string, unknown> } })._.fullSchema; | |
| 54 | + for (const key of Object.keys(executorSchema)) { | |
| 55 | + expect(fullSchema, `fullSchema missing "${key}"`).toHaveProperty(key); | |
| 56 | + } | |
| 57 | + } finally { | |
| 58 | + sql.end({ timeout: 0 }).catch(() => undefined); | |
| 59 | + } | |
| 60 | + }); | |
| 61 | +}); | |
| @@ -20,7 +20,11 @@ import * as cloudSchema from "./schema"; · 19 unmodified lines | ||
| 20 | 20 | import * as executorSchema from "./executor-schema"; |
| 21 | 21 | import { server } from "../env"; |
| 22 | 22 | |
| 23 | −const schema = { ...cloudSchema, ...executorSchema }; | |
| 23 | +// Exported so every drizzle() call in the cloud app shares one schema | |
| 24 | +// object. Historically `mcp-session.ts` built its own and forgot to spread | |
| 25 | +// `executorSchema`, producing runtime "unknown model source" errors that | |
| 26 | +// only surfaced in prod. See apps/cloud/src/services/db.schema.test.ts. | |
| 27 | +export const combinedSchema = { ...cloudSchema, ...executorSchema }; | |
| 24 | 28 | |
| 25 | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 26 | 30 | export type DrizzleDb = PgDatabase<any, any, any>; |
| @@ -64,7 +68,7 @@ export class DbService extends Context.Tag("@executor/cloud/DbService")< · 37 unmodified lines | ||
| 64 | 68 | Effect.acquireRelease( |
| 65 | 69 | Effect.sync((): DbServiceShape => { |
| 66 | 70 | const sql = makeSql(); |
| 67 | − return { sql, db: drizzle(sql, { schema }) as DrizzleDb }; | |
| 71 | + return { sql, db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb }; | |
| 68 | 72 | }), |
| 69 | 73 | ({ sql }) => |
| 70 | 74 | // Fire-and-forget: the Terminate round-trip sometimes hangs, and |