From 72061616ad53cdce8662be3df3e883fecdaac9e3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 22:21:33 -0700 Subject: [PATCH] Project the heavy schema columns off the tool hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every invoke loaded the full tool row — including input_schema and output_schema JSON it never reads — and tools.list loaded every tool's schemas unbounded just to project metadata, so list cost scaled with schema bytes rather than tool count. The same shape of waste the spec inlining had, in miniature, across the tool table. The core DB wrapper now exposes fumadb's column projection (select), typed per table with the result narrowed to the chosen columns. The invoke lookup, tools.list, and the not-found suggestion queries select TOOL_INVOCATION_COLUMNS — everything but the two schema columns. The plugin invokeTool contract documents this as ToolInvocationRow (no plugin read the schemas off the row; operation details ride in plugin storage or annotations). tools.schema (describe) is unchanged and remains the schema-bearing surface. One test moved from asserting schemas on tools.list to tools.schema — that list behavior is exactly what this change removes. --- packages/core/sdk/src/core-schema.ts | 19 +++ packages/core/sdk/src/executor.ts | 60 ++++++--- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/plugin.ts | 6 +- .../openapi/src/sdk/non-json-body.test.ts | 9 +- .../src/sdk/tool-row-projection.test.ts | 121 ++++++++++++++++++ 6 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 packages/plugins/openapi/src/sdk/tool-row-projection.test.ts diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index f1a39260c..1e8ff2d60 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -288,6 +288,25 @@ export type ConnectionRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; export type ToolRow = FumaRow; +/** The tool-row projection the invoke/list hot paths load: everything except + * the heavy `input_schema`/`output_schema` JSON, which only `tools.schema` + * (describe) needs. Plugin `invokeTool` receives this shape — operation + * details ride in plugin storage or `annotations`, not the row schemas. */ +export type ToolInvocationRow = Omit; +/** The columns backing {@link ToolInvocationRow}, for `select` projections. */ +export const TOOL_INVOCATION_COLUMNS = [ + "tenant", + "owner", + "subject", + "integration", + "connection", + "plugin_id", + "name", + "description", + "annotations", + "created_at", + "updated_at", +] as const satisfies readonly (keyof ToolRow)[]; export type DefinitionRow = FumaRow; export type ToolPolicyRow = FumaRow; export type PluginStorageRow = FumaRow; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2b43ab815..c8d578bf8 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -28,10 +28,12 @@ import type { import { coreSchema, isToolPolicyAction, + TOOL_INVOCATION_COLUMNS, type ConnectionRow, type CoreSchema, type IntegrationRow, type OAuthClientRow, + type ToolInvocationRow, type ToolRow, type ToolPolicyRow, } from "./core-schema"; @@ -513,7 +515,13 @@ const connectionItemIds = (row: ConnectionRow): Record => { return decoded as Record; }; -const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => { +// Accepts a projected row (the invoke/list paths select away the heavy +// schema columns); `Tool.inputSchema`/`outputSchema` are optional and stay +// absent for those callers — `tools.schema` is the schema-bearing surface. +const rowToTool = ( + row: ToolInvocationRow & Partial>, + annotations?: ToolAnnotations, +): Tool => { const owner = row.owner as Owner; const integration = IntegrationSlug.make(row.integration); const connection = ConnectionName.make(row.connection); @@ -539,16 +547,29 @@ const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => { type AnyCb = ConditionBuilder>; type CoreTableName = keyof CoreSchema & string; type CoreRow = FumaRow; +type CoreColumn = keyof CoreRow & string; type CoreWhere = (b: AnyCb) => Condition | boolean; -type CoreFindManyOptions = { +type CoreFindManyOptions = { readonly where?: CoreWhere; readonly limit?: number; readonly offset?: number; readonly orderBy?: | readonly [string, "asc" | "desc"] | readonly (readonly [string, "asc" | "desc"])[]; + /** Column projection (fumadb `select`). Omit for all columns. Use on hot + * paths whose rows carry heavy JSON columns the caller discards — e.g. a + * tool row is ~KBs of schemas but invoke routing needs only the names. */ + readonly select?: readonly CoreColumn[]; }; -type CoreFindFirstOptions = Omit; +type CoreFindFirstOptions = Omit< + CoreFindManyOptions, + "limit" | "offset" +>; +/** The narrowed row a projected query returns: the selected columns keep + * their types, everything else is absent. */ +type CoreProjectedRow = TSelect extends readonly (infer K)[] + ? Pick, Extract>> + : CoreRow; type LooseStorageDb = { readonly count: (tableName: string, options?: unknown) => Promise; @@ -603,20 +624,20 @@ const makeCoreDb = (fuma: ReturnType) => ({ fuma.use(`${tableName}.deleteMany`, (db) => asLooseStorageDb(db).deleteMany(tableName, options), ), - findFirst: ( + findFirst: >( tableName: TName, - options: CoreFindFirstOptions, - ): Effect.Effect | null, StorageFailure> => + options: TOptions, + ): Effect.Effect | null, StorageFailure> => fuma.use(`${tableName}.findFirst`, (db) => asLooseStorageDb(db).findFirst(tableName, options), - ) as Effect.Effect | null, StorageFailure>, - findMany: ( + ) as Effect.Effect | null, StorageFailure>, + findMany: >( tableName: TName, - options: CoreFindManyOptions = {}, - ): Effect.Effect[], StorageFailure> => + options: TOptions = {} as TOptions, + ): Effect.Effect[], StorageFailure> => fuma.use(`${tableName}.findMany`, (db) => asLooseStorageDb(db).findMany(tableName, options), - ) as Effect.Effect[], StorageFailure>, + ) as Effect.Effect[], StorageFailure>, updateMany: ( tableName: TName, options: { @@ -2300,6 +2321,9 @@ export const createExecutor = => Effect.gen(function* () { + // Projected: the list surface is metadata (address, description, + // annotations) — loading every tool's input/output schema JSON made + // an unbounded list scale with schema bytes, not tool count. const rows = yield* core.findMany("tool", { where: (b: AnyCb) => b.and( @@ -2311,6 +2335,7 @@ export const createExecutor = + const toolSuggestions = (rows: readonly ToolInvocationRow[]): readonly ToolAddress[] => rows.map((row) => rowToTool(row).address); const toolRowsForConnectionWhere = (parsed: ParsedToolAddress) => (b: AnyCb) => @@ -2662,7 +2687,7 @@ export const createExecutor = => + ): Effect.Effect => core.findMany("tool", { where: (b: AnyCb) => b.and( @@ -2674,15 +2699,17 @@ export const createExecutor = => + ): Effect.Effect => core.findMany("tool", { where: toolRowsForConnectionWhere(parsed), orderBy: ["name", "asc"], limit: TOOL_SUGGESTION_LIMIT, + select: TOOL_INVOCATION_COLUMNS, }); const execute = ( @@ -2743,7 +2770,9 @@ export const createExecutor = b.and( @@ -2752,6 +2781,7 @@ export const createExecutor = { readonly ctx: PluginCtx; /** Already-loaded per-connection tool row (carries integration, connection, * owner, name, schemas). */ - readonly toolRow: ToolRow; + readonly toolRow: ToolInvocationRow; /** The resolved credential to apply to the outbound request. */ readonly credential: ToolInvocationCredential; readonly args: unknown; @@ -460,7 +460,7 @@ export interface PluginSpec< readonly ctx: PluginCtx; readonly integration: IntegrationSlug; readonly connection: ConnectionName; - readonly toolRows: readonly ToolRow[]; + readonly toolRows: readonly ToolInvocationRow[]; }) => Effect.Effect, unknown>; /** Plugin-side cleanup when a connection is removed. */ diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 07722b9e8..d357cdaf3 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -297,10 +297,11 @@ describe("OpenAPI non-JSON request body dispatch", () => { baseUrl: "https://example.com", }); - const tools = yield* executor.tools.list(); - const submit = tools.find((t) => String(t.address) === String(conn.address("body.submit"))); - expect(submit).toBeDefined(); - const schema = submit!.inputSchema as { + // `tools.schema` is the schema-bearing surface — `tools.list` is + // metadata-only (the hot path projects the schema columns away). + const view = yield* executor.tools.schema(conn.address("body.submit")); + expect(view).not.toBeNull(); + const schema = view!.inputSchema as { properties?: { contentType?: { enum?: string[]; default?: string }; }; diff --git a/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts b/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts new file mode 100644 index 000000000..aeb939874 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/tool-row-projection.test.ts @@ -0,0 +1,121 @@ +// --------------------------------------------------------------------------- +// Tool-row projection coverage. The invoke and list hot paths select away the +// heavy input_schema/output_schema JSON (TOOL_INVOCATION_COLUMNS) — a tool row +// is ~KBs of schemas, but routing/policy needs only the names. These tests pin +// the contract through a real dynamic integration: +// - tools.list returns metadata without the schemas, +// - invoke works end-to-end off the projected row, +// - tools.schema (describe) still serves the full schemas. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +import { + createExecutor, + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; +import { variable } from "@executor-js/sdk/http-auth"; + +import { openApiPlugin } from "./plugin"; +import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing"; + +const testPlugins = (httpClientLayer = FetchHttpClient.layer) => + [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; + +const EchoHeaders = Schema.Struct({ + "x-api-key": Schema.optional(Schema.String), +}); + +const EchoGroup = HttpApiGroup.make("items").add( + HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }), +); +const TestApi = HttpApi.make("testApi").add(EchoGroup); + +const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => + handlers.handle("echoHeaders", () => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return EchoHeaders.make({ "x-api-key": req.headers["x-api-key"] }); + }), + ), +); + +const apiKeyTemplate = { + slug: AuthTemplateSlug.make("apiKey"), + type: "apiKey" as const, + headers: { "x-api-key": [variable("token")] }, +}; + +const setup = Effect.gen(function* () { + const server = yield* serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: EchoGroupLive, + }); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: server.specJson }, + slug: "proj_api", + baseUrl: server.baseUrl, + authenticationTemplate: [apiKeyTemplate], + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("proj_api"), + template: AuthTemplateSlug.make("apiKey"), + value: "secret-key-123", + }); + return executor; +}); + +const TOOL_ADDRESS = ToolAddress.make("tools.proj_api.org.main.items.echoHeaders"); + +describe("tool-row projection on hot paths", () => { + it.effect("tools.list returns metadata without loading the schema columns", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* setup; + const tools = yield* executor.tools.list({ + integration: IntegrationSlug.make("proj_api"), + }); + const tool = tools.find((entry) => entry.address === TOOL_ADDRESS); + expect(tool).toBeDefined(); + expect(tool!.description.length).toBeGreaterThan(0); + // The list surface is metadata-only: the projected query never loads + // input/output schema JSON, so the Tool carries none. + expect(tool!.inputSchema).toBeUndefined(); + expect(tool!.outputSchema).toBeUndefined(); + }), + ), + ); + + it.effect("invoke routes and executes off the projected row", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* setup; + const result = unwrapInvocation(yield* executor.execute(TOOL_ADDRESS, {})).data as { + "x-api-key"?: string; + }; + expect(result["x-api-key"]).toBe("secret-key-123"); + }), + ), + ); + + it.effect("tools.schema still serves the full schemas", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* setup; + const schema = yield* executor.tools.schema(TOOL_ADDRESS); + expect(schema).not.toBeNull(); + expect(schema!.outputSchema).toBeDefined(); + }), + ), + ); +});