Skip to content

Commit fa0afd7

Browse files
committed
Project the heavy schema columns off the tool hot paths
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.
1 parent 26b23dd commit fa0afd7

6 files changed

Lines changed: 194 additions & 22 deletions

File tree

packages/core/sdk/src/core-schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,25 @@ export type ConnectionRow = FumaRow<CoreSchema["connection"]>;
288288
export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;
289289
export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;
290290
export type ToolRow = FumaRow<CoreSchema["tool"]>;
291+
/** The tool-row projection the invoke/list hot paths load: everything except
292+
* the heavy `input_schema`/`output_schema` JSON, which only `tools.schema`
293+
* (describe) needs. Plugin `invokeTool` receives this shape — operation
294+
* details ride in plugin storage or `annotations`, not the row schemas. */
295+
export type ToolInvocationRow = Omit<ToolRow, "input_schema" | "output_schema">;
296+
/** The columns backing {@link ToolInvocationRow}, for `select` projections. */
297+
export const TOOL_INVOCATION_COLUMNS = [
298+
"tenant",
299+
"owner",
300+
"subject",
301+
"integration",
302+
"connection",
303+
"plugin_id",
304+
"name",
305+
"description",
306+
"annotations",
307+
"created_at",
308+
"updated_at",
309+
] as const satisfies readonly (keyof ToolRow)[];
291310
export type DefinitionRow = FumaRow<CoreSchema["definition"]>;
292311
export type ToolPolicyRow = FumaRow<CoreSchema["tool_policy"]>;
293312
export type PluginStorageRow = FumaRow<CoreSchema["plugin_storage"]>;

packages/core/sdk/src/executor.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import type {
2828
import {
2929
coreSchema,
3030
isToolPolicyAction,
31+
TOOL_INVOCATION_COLUMNS,
3132
type ConnectionRow,
3233
type CoreSchema,
3334
type IntegrationRow,
3435
type OAuthClientRow,
36+
type ToolInvocationRow,
3537
type ToolRow,
3638
type ToolPolicyRow,
3739
} from "./core-schema";
@@ -513,7 +515,13 @@ const connectionItemIds = (row: ConnectionRow): Record<string, string> => {
513515
return decoded as Record<string, string>;
514516
};
515517

516-
const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => {
518+
// Accepts a projected row (the invoke/list paths select away the heavy
519+
// schema columns); `Tool.inputSchema`/`outputSchema` are optional and stay
520+
// absent for those callers — `tools.schema` is the schema-bearing surface.
521+
const rowToTool = (
522+
row: ToolInvocationRow & Partial<Pick<ToolRow, "input_schema" | "output_schema">>,
523+
annotations?: ToolAnnotations,
524+
): Tool => {
517525
const owner = row.owner as Owner;
518526
const integration = IntegrationSlug.make(row.integration);
519527
const connection = ConnectionName.make(row.connection);
@@ -539,16 +547,29 @@ const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => {
539547
type AnyCb = ConditionBuilder<Record<string, AnyColumn>>;
540548
type CoreTableName = keyof CoreSchema & string;
541549
type CoreRow<TName extends CoreTableName> = FumaRow<CoreSchema[TName]>;
550+
type CoreColumn<TName extends CoreTableName> = keyof CoreRow<TName> & string;
542551
type CoreWhere = (b: AnyCb) => Condition | boolean;
543-
type CoreFindManyOptions = {
552+
type CoreFindManyOptions<TName extends CoreTableName = CoreTableName> = {
544553
readonly where?: CoreWhere;
545554
readonly limit?: number;
546555
readonly offset?: number;
547556
readonly orderBy?:
548557
| readonly [string, "asc" | "desc"]
549558
| readonly (readonly [string, "asc" | "desc"])[];
559+
/** Column projection (fumadb `select`). Omit for all columns. Use on hot
560+
* paths whose rows carry heavy JSON columns the caller discards — e.g. a
561+
* tool row is ~KBs of schemas but invoke routing needs only the names. */
562+
readonly select?: readonly CoreColumn<TName>[];
550563
};
551-
type CoreFindFirstOptions = Omit<CoreFindManyOptions, "limit" | "offset">;
564+
type CoreFindFirstOptions<TName extends CoreTableName = CoreTableName> = Omit<
565+
CoreFindManyOptions<TName>,
566+
"limit" | "offset"
567+
>;
568+
/** The narrowed row a projected query returns: the selected columns keep
569+
* their types, everything else is absent. */
570+
type CoreProjectedRow<TName extends CoreTableName, TSelect> = TSelect extends readonly (infer K)[]
571+
? Pick<CoreRow<TName>, Extract<K, keyof CoreRow<TName>>>
572+
: CoreRow<TName>;
552573

553574
type LooseStorageDb = {
554575
readonly count: (tableName: string, options?: unknown) => Promise<number>;
@@ -603,20 +624,20 @@ const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({
603624
fuma.use(`${tableName}.deleteMany`, (db) =>
604625
asLooseStorageDb(db).deleteMany(tableName, options),
605626
),
606-
findFirst: <TName extends CoreTableName>(
627+
findFirst: <TName extends CoreTableName, const TOptions extends CoreFindFirstOptions<TName>>(
607628
tableName: TName,
608-
options: CoreFindFirstOptions,
609-
): Effect.Effect<CoreRow<TName> | null, StorageFailure> =>
629+
options: TOptions,
630+
): Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure> =>
610631
fuma.use(`${tableName}.findFirst`, (db) =>
611632
asLooseStorageDb(db).findFirst(tableName, options),
612-
) as Effect.Effect<CoreRow<TName> | null, StorageFailure>,
613-
findMany: <TName extends CoreTableName>(
633+
) as Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure>,
634+
findMany: <TName extends CoreTableName, const TOptions extends CoreFindManyOptions<TName>>(
614635
tableName: TName,
615-
options: CoreFindManyOptions = {},
616-
): Effect.Effect<readonly CoreRow<TName>[], StorageFailure> =>
636+
options: TOptions = {} as TOptions,
637+
): Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure> =>
617638
fuma.use(`${tableName}.findMany`, (db) =>
618639
asLooseStorageDb(db).findMany(tableName, options),
619-
) as Effect.Effect<readonly CoreRow<TName>[], StorageFailure>,
640+
) as Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure>,
620641
updateMany: <TName extends CoreTableName>(
621642
tableName: TName,
622643
options: {
@@ -2300,6 +2321,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
23002321

23012322
const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>
23022323
Effect.gen(function* () {
2324+
// Projected: the list surface is metadata (address, description,
2325+
// annotations) — loading every tool's input/output schema JSON made
2326+
// an unbounded list scale with schema bytes, not tool count.
23032327
const rows = yield* core.findMany("tool", {
23042328
where: (b: AnyCb) =>
23052329
b.and(
@@ -2311,6 +2335,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
23112335
? true
23122336
: b("connection", "=", String(filter.connection)),
23132337
),
2338+
select: TOOL_INVOCATION_COLUMNS,
23142339
});
23152340
const includeBlocked = filter?.includeBlocked ?? false;
23162341
const policyRows = yield* core.findMany("tool_policy", {});
@@ -2650,7 +2675,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
26502675

26512676
const TOOL_SUGGESTION_LIMIT = 5;
26522677

2653-
const toolSuggestions = (rows: readonly ToolRow[]): readonly ToolAddress[] =>
2678+
const toolSuggestions = (rows: readonly ToolInvocationRow[]): readonly ToolAddress[] =>
26542679
rows.map((row) => rowToTool(row).address);
26552680

26562681
const toolRowsForConnectionWhere = (parsed: ParsedToolAddress) => (b: AnyCb) =>
@@ -2662,7 +2687,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
26622687

26632688
const searchToolRowsForConnection = (
26642689
parsed: ParsedToolAddress,
2665-
): Effect.Effect<readonly ToolRow[], StorageFailure> =>
2690+
): Effect.Effect<readonly ToolInvocationRow[], StorageFailure> =>
26662691
core.findMany("tool", {
26672692
where: (b: AnyCb) =>
26682693
b.and(
@@ -2674,15 +2699,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
26742699
),
26752700
orderBy: ["name", "asc"],
26762701
limit: TOOL_SUGGESTION_LIMIT,
2702+
select: TOOL_INVOCATION_COLUMNS,
26772703
});
26782704

26792705
const findToolRowsForConnection = (
26802706
parsed: ParsedToolAddress,
2681-
): Effect.Effect<readonly ToolRow[], StorageFailure> =>
2707+
): Effect.Effect<readonly ToolInvocationRow[], StorageFailure> =>
26822708
core.findMany("tool", {
26832709
where: toolRowsForConnectionWhere(parsed),
26842710
orderBy: ["name", "asc"],
26852711
limit: TOOL_SUGGESTION_LIMIT,
2712+
select: TOOL_INVOCATION_COLUMNS,
26862713
});
26872714

26882715
const execute = (
@@ -2743,7 +2770,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
27432770
return yield* new ToolNotFoundError({ address });
27442771
}
27452772

2746-
// Find the tool row.
2773+
// Find the tool row — projected: invoke needs routing/policy fields
2774+
// only, never the multi-KB input/output schema JSON (`tools.schema`
2775+
// is the schema-bearing surface).
27472776
const row = yield* core.findFirst("tool", {
27482777
where: (b: AnyCb) =>
27492778
b.and(
@@ -2752,6 +2781,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
27522781
b("connection", "=", String(parsed.connection)),
27532782
b("name", "=", String(parsed.tool)),
27542783
),
2784+
select: TOOL_INVOCATION_COLUMNS,
27552785
});
27562786
if (!row) {
27572787
const searchMatches = yield* searchToolRowsForConnection(parsed);

packages/core/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export {
122122
type OAuthClientRow,
123123
type OAuthSessionRow,
124124
type ToolRow,
125+
type ToolInvocationRow,
125126
type DefinitionRow,
126127
type ToolPolicyRow,
127128
type PluginStorageRow,

packages/core/sdk/src/plugin.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
IntegrationDisplayDescriptor,
1515
RegisterIntegrationInput,
1616
} from "./integration";
17-
import type { ToolRow } from "./core-schema";
17+
import type { ToolInvocationRow } from "./core-schema";
1818
import type {
1919
AuthTemplateSlug,
2020
ConnectionName,
@@ -339,7 +339,7 @@ export interface InvokeToolInput<TStore = unknown> {
339339
readonly ctx: PluginCtx<TStore>;
340340
/** Already-loaded per-connection tool row (carries integration, connection,
341341
* owner, name, schemas). */
342-
readonly toolRow: ToolRow;
342+
readonly toolRow: ToolInvocationRow;
343343
/** The resolved credential to apply to the outbound request. */
344344
readonly credential: ToolInvocationCredential;
345345
readonly args: unknown;
@@ -460,7 +460,7 @@ export interface PluginSpec<
460460
readonly ctx: PluginCtx<TStore>;
461461
readonly integration: IntegrationSlug;
462462
readonly connection: ConnectionName;
463-
readonly toolRows: readonly ToolRow[];
463+
readonly toolRows: readonly ToolInvocationRow[];
464464
}) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
465465

466466
/** Plugin-side cleanup when a connection is removed. */

packages/plugins/openapi/src/sdk/non-json-body.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,10 +297,11 @@ describe("OpenAPI non-JSON request body dispatch", () => {
297297
baseUrl: "https://example.com",
298298
});
299299

300-
const tools = yield* executor.tools.list();
301-
const submit = tools.find((t) => String(t.address) === String(conn.address("body.submit")));
302-
expect(submit).toBeDefined();
303-
const schema = submit!.inputSchema as {
300+
// `tools.schema` is the schema-bearing surface — `tools.list` is
301+
// metadata-only (the hot path projects the schema columns away).
302+
const view = yield* executor.tools.schema(conn.address("body.submit"));
303+
expect(view).not.toBeNull();
304+
const schema = view!.inputSchema as {
304305
properties?: {
305306
contentType?: { enum?: string[]; default?: string };
306307
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// ---------------------------------------------------------------------------
2+
// Tool-row projection coverage. The invoke and list hot paths select away the
3+
// heavy input_schema/output_schema JSON (TOOL_INVOCATION_COLUMNS) — a tool row
4+
// is ~KBs of schemas, but routing/policy needs only the names. These tests pin
5+
// the contract through a real dynamic integration:
6+
// - tools.list returns metadata without the schemas,
7+
// - invoke works end-to-end off the projected row,
8+
// - tools.schema (describe) still serves the full schemas.
9+
// ---------------------------------------------------------------------------
10+
11+
import { describe, expect, it } from "@effect/vitest";
12+
import { Effect, Schema } from "effect";
13+
import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http";
14+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
15+
16+
import {
17+
createExecutor,
18+
AuthTemplateSlug,
19+
ConnectionName,
20+
IntegrationSlug,
21+
ToolAddress,
22+
} from "@executor-js/sdk";
23+
import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
24+
import { variable } from "@executor-js/sdk/http-auth";
25+
26+
import { openApiPlugin } from "./plugin";
27+
import { serveOpenApiHttpApiTestServer, unwrapInvocation } from "../testing";
28+
29+
const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>
30+
[openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const;
31+
32+
const EchoHeaders = Schema.Struct({
33+
"x-api-key": Schema.optional(Schema.String),
34+
});
35+
36+
const EchoGroup = HttpApiGroup.make("items").add(
37+
HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }),
38+
);
39+
const TestApi = HttpApi.make("testApi").add(EchoGroup);
40+
41+
const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>
42+
handlers.handle("echoHeaders", () =>
43+
Effect.gen(function* () {
44+
const req = yield* HttpServerRequest.HttpServerRequest;
45+
return EchoHeaders.make({ "x-api-key": req.headers["x-api-key"] });
46+
}),
47+
),
48+
);
49+
50+
const apiKeyTemplate = {
51+
slug: AuthTemplateSlug.make("apiKey"),
52+
type: "apiKey" as const,
53+
headers: { "x-api-key": [variable("token")] },
54+
};
55+
56+
const setup = Effect.gen(function* () {
57+
const server = yield* serveOpenApiHttpApiTestServer({
58+
api: TestApi,
59+
handlersLayer: EchoGroupLive,
60+
});
61+
const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
62+
yield* executor.openapi.addSpec({
63+
spec: { kind: "blob", value: server.specJson },
64+
slug: "proj_api",
65+
baseUrl: server.baseUrl,
66+
authenticationTemplate: [apiKeyTemplate],
67+
});
68+
yield* executor.connections.create({
69+
owner: "org",
70+
name: ConnectionName.make("main"),
71+
integration: IntegrationSlug.make("proj_api"),
72+
template: AuthTemplateSlug.make("apiKey"),
73+
value: "secret-key-123",
74+
});
75+
return executor;
76+
});
77+
78+
const TOOL_ADDRESS = ToolAddress.make("tools.proj_api.org.main.items.echoHeaders");
79+
80+
describe("tool-row projection on hot paths", () => {
81+
it.effect("tools.list returns metadata without loading the schema columns", () =>
82+
Effect.scoped(
83+
Effect.gen(function* () {
84+
const executor = yield* setup;
85+
const tools = yield* executor.tools.list({
86+
integration: IntegrationSlug.make("proj_api"),
87+
});
88+
const tool = tools.find((entry) => entry.address === TOOL_ADDRESS);
89+
expect(tool).toBeDefined();
90+
expect(tool!.description.length).toBeGreaterThan(0);
91+
// The list surface is metadata-only: the projected query never loads
92+
// input/output schema JSON, so the Tool carries none.
93+
expect(tool!.inputSchema).toBeUndefined();
94+
expect(tool!.outputSchema).toBeUndefined();
95+
}),
96+
),
97+
);
98+
99+
it.effect("invoke routes and executes off the projected row", () =>
100+
Effect.scoped(
101+
Effect.gen(function* () {
102+
const executor = yield* setup;
103+
const result = unwrapInvocation(yield* executor.execute(TOOL_ADDRESS, {})).data as {
104+
"x-api-key"?: string;
105+
};
106+
expect(result["x-api-key"]).toBe("secret-key-123");
107+
}),
108+
),
109+
);
110+
111+
it.effect("tools.schema still serves the full schemas", () =>
112+
Effect.scoped(
113+
Effect.gen(function* () {
114+
const executor = yield* setup;
115+
const schema = yield* executor.tools.schema(TOOL_ADDRESS);
116+
expect(schema).not.toBeNull();
117+
expect(schema!.outputSchema).toBeDefined();
118+
}),
119+
),
120+
);
121+
});

0 commit comments

Comments
 (0)