Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/core/sdk/src/core-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,25 @@ export type ConnectionRow = FumaRow<CoreSchema["connection"]>;
export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;
export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;
export type ToolRow = FumaRow<CoreSchema["tool"]>;
/** 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<ToolRow, "input_schema" | "output_schema">;
/** 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<CoreSchema["definition"]>;
export type ToolPolicyRow = FumaRow<CoreSchema["tool_policy"]>;
export type PluginStorageRow = FumaRow<CoreSchema["plugin_storage"]>;
Expand Down
60 changes: 45 additions & 15 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -513,7 +515,13 @@ const connectionItemIds = (row: ConnectionRow): Record<string, string> => {
return decoded as Record<string, string>;
};

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<Pick<ToolRow, "input_schema" | "output_schema">>,
annotations?: ToolAnnotations,
): Tool => {
const owner = row.owner as Owner;
const integration = IntegrationSlug.make(row.integration);
const connection = ConnectionName.make(row.connection);
Expand All @@ -539,16 +547,29 @@ const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => {
type AnyCb = ConditionBuilder<Record<string, AnyColumn>>;
type CoreTableName = keyof CoreSchema & string;
type CoreRow<TName extends CoreTableName> = FumaRow<CoreSchema[TName]>;
type CoreColumn<TName extends CoreTableName> = keyof CoreRow<TName> & string;
type CoreWhere = (b: AnyCb) => Condition | boolean;
type CoreFindManyOptions = {
type CoreFindManyOptions<TName extends CoreTableName = CoreTableName> = {
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<TName>[];
};
type CoreFindFirstOptions = Omit<CoreFindManyOptions, "limit" | "offset">;
type CoreFindFirstOptions<TName extends CoreTableName = CoreTableName> = Omit<
CoreFindManyOptions<TName>,
"limit" | "offset"
>;
/** The narrowed row a projected query returns: the selected columns keep
* their types, everything else is absent. */
type CoreProjectedRow<TName extends CoreTableName, TSelect> = TSelect extends readonly (infer K)[]
? Pick<CoreRow<TName>, Extract<K, keyof CoreRow<TName>>>
: CoreRow<TName>;

type LooseStorageDb = {
readonly count: (tableName: string, options?: unknown) => Promise<number>;
Expand Down Expand Up @@ -603,20 +624,20 @@ const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({
fuma.use(`${tableName}.deleteMany`, (db) =>
asLooseStorageDb(db).deleteMany(tableName, options),
),
findFirst: <TName extends CoreTableName>(
findFirst: <TName extends CoreTableName, const TOptions extends CoreFindFirstOptions<TName>>(
tableName: TName,
options: CoreFindFirstOptions,
): Effect.Effect<CoreRow<TName> | null, StorageFailure> =>
options: TOptions,
): Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure> =>
fuma.use(`${tableName}.findFirst`, (db) =>
asLooseStorageDb(db).findFirst(tableName, options),
) as Effect.Effect<CoreRow<TName> | null, StorageFailure>,
findMany: <TName extends CoreTableName>(
) as Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure>,
findMany: <TName extends CoreTableName, const TOptions extends CoreFindManyOptions<TName>>(
tableName: TName,
options: CoreFindManyOptions = {},
): Effect.Effect<readonly CoreRow<TName>[], StorageFailure> =>
options: TOptions = {} as TOptions,
): Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure> =>
fuma.use(`${tableName}.findMany`, (db) =>
asLooseStorageDb(db).findMany(tableName, options),
) as Effect.Effect<readonly CoreRow<TName>[], StorageFailure>,
) as Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure>,
updateMany: <TName extends CoreTableName>(
tableName: TName,
options: {
Expand Down Expand Up @@ -2300,6 +2321,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea

const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>
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(
Expand All @@ -2311,6 +2335,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
? true
: b("connection", "=", String(filter.connection)),
),
select: TOOL_INVOCATION_COLUMNS,
});
const includeBlocked = filter?.includeBlocked ?? false;
const policyRows = yield* core.findMany("tool_policy", {});
Expand Down Expand Up @@ -2650,7 +2675,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea

const TOOL_SUGGESTION_LIMIT = 5;

const toolSuggestions = (rows: readonly ToolRow[]): readonly ToolAddress[] =>
const toolSuggestions = (rows: readonly ToolInvocationRow[]): readonly ToolAddress[] =>
rows.map((row) => rowToTool(row).address);

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

const searchToolRowsForConnection = (
parsed: ParsedToolAddress,
): Effect.Effect<readonly ToolRow[], StorageFailure> =>
): Effect.Effect<readonly ToolInvocationRow[], StorageFailure> =>
core.findMany("tool", {
where: (b: AnyCb) =>
b.and(
Expand All @@ -2674,15 +2699,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
),
orderBy: ["name", "asc"],
limit: TOOL_SUGGESTION_LIMIT,
select: TOOL_INVOCATION_COLUMNS,
});

const findToolRowsForConnection = (
parsed: ParsedToolAddress,
): Effect.Effect<readonly ToolRow[], StorageFailure> =>
): Effect.Effect<readonly ToolInvocationRow[], StorageFailure> =>
core.findMany("tool", {
where: toolRowsForConnectionWhere(parsed),
orderBy: ["name", "asc"],
limit: TOOL_SUGGESTION_LIMIT,
select: TOOL_INVOCATION_COLUMNS,
});

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

// Find the tool row.
// Find the tool row — projected: invoke needs routing/policy fields
// only, never the multi-KB input/output schema JSON (`tools.schema`
// is the schema-bearing surface).
const row = yield* core.findFirst("tool", {
where: (b: AnyCb) =>
b.and(
Expand All @@ -2752,6 +2781,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
b("connection", "=", String(parsed.connection)),
b("name", "=", String(parsed.tool)),
),
select: TOOL_INVOCATION_COLUMNS,
});
if (!row) {
const searchMatches = yield* searchToolRowsForConnection(parsed);
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export {
type OAuthClientRow,
type OAuthSessionRow,
type ToolRow,
type ToolInvocationRow,
type DefinitionRow,
type ToolPolicyRow,
type PluginStorageRow,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
IntegrationDisplayDescriptor,
RegisterIntegrationInput,
} from "./integration";
import type { ToolRow } from "./core-schema";
import type { ToolInvocationRow } from "./core-schema";
import type {
AuthTemplateSlug,
ConnectionName,
Expand Down Expand Up @@ -339,7 +339,7 @@ export interface InvokeToolInput<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
/** 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;
Expand Down Expand Up @@ -460,7 +460,7 @@ export interface PluginSpec<
readonly ctx: PluginCtx<TStore>;
readonly integration: IntegrationSlug;
readonly connection: ConnectionName;
readonly toolRows: readonly ToolRow[];
readonly toolRows: readonly ToolInvocationRow[];
}) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;

/** Plugin-side cleanup when a connection is removed. */
Expand Down
9 changes: 5 additions & 4 deletions packages/plugins/openapi/src/sdk/non-json-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down
121 changes: 121 additions & 0 deletions packages/plugins/openapi/src/sdk/tool-row-projection.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}),
),
);
});
Loading