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(); + }), + ), + ); +});