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 | import * as executorSchema from "./executor-schema"; | 20 | import * as executorSchema from "./executor-schema"; |
| 21 | import { server } from "../env"; | 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 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 26 | export type DrizzleDb = PgDatabase<any, any, any>; | 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 | Effect.acquireRelease( | 68 | Effect.acquireRelease( |
| 65 | Effect.sync((): DbServiceShape => { | 69 | Effect.sync((): DbServiceShape => { |
| 66 | const sql = makeSql(); | 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 | ({ sql }) => | 73 | ({ sql }) => |
| 70 | // Fire-and-forget: the Terminate round-trip sometimes hangs, and | 74 | // Fire-and-forget: the Terminate round-trip sometimes hangs, and |