diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 83eff2638..74fbf4779 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -45,12 +45,13 @@ const AddIntegrationResponse = Schema.Struct({ name: Schema.String, }); -// The full opaque integration config, surfaced for the configure UX. Carries -// the `authenticationTemplate` the configure / custom-method flow reads/writes. +// The integration config surfaced for the configure UX. Carries the +// `authenticationTemplate` the configure / custom-method flow reads/writes. +// The introspection snapshot is deliberately NOT served: it's a multi-MB +// build artifact in the plugin blob store, and no client reads it. const GraphqlConfigView = Schema.Struct({ endpoint: Schema.String, name: Schema.String, - introspectionJson: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), authenticationTemplate: Schema.Array(GraphqlAuthMethod), diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 48ff53c6e..1e7a8304e 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -66,7 +66,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra ? { endpoint: config.endpoint, name: config.name, - introspectionJson: config.introspectionJson, headers: config.headers ? { ...config.headers } : undefined, queryParams: config.queryParams ? { ...config.queryParams } : undefined, authenticationTemplate: [...config.authenticationTemplate], diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 340a94667..683f783d1 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -414,18 +414,18 @@ const introspectHeadersForConnection = ( return { headers, queryParams }; }; -/** Resolve a config's introspection snapshot text: legacy rows inline it in - * `introspectionJson`; new rows point at the plugin blob store via - * `introspectionHash`. Null when the integration has no snapshot (live - * introspection territory). */ +/** Resolve a config's introspection snapshot text from the plugin blob store + * (`introspectionHash`). Null when the integration has no snapshot (live + * introspection territory). Pre-blob rows that inlined the JSON are + * rewritten by the introspection-to-blob migrations before this code reads + * them. */ const loadIntrospectionJson = ( storage: GraphqlStore, config: GraphqlIntegrationConfig, -): Effect.Effect => { - if (config.introspectionJson != null) return Effect.succeed(config.introspectionJson); - if (config.introspectionHash != null) return storage.getIntrospection(config.introspectionHash); - return Effect.succeed(null); -}; +): Effect.Effect => + config.introspectionHash != null + ? storage.getIntrospection(config.introspectionHash) + : Effect.succeed(null); /** Introspect a config live or from its stored snapshot, applying connection * auth. A non-null `introspectionJson` (loaded via `loadIntrospectionJson`) @@ -558,9 +558,6 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { GraphqlIntegrationConfig.make({ endpoint: input.endpoint, name: input.name?.trim() || slugFromEndpoint(input.endpoint), - ...(input.introspectionJson !== undefined - ? { introspectionJson: input.introspectionJson } - : {}), ...(input.headers !== undefined ? { headers: input.headers } : {}), ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}), authenticationTemplate: input.authenticationTemplate @@ -602,7 +599,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { // their operation bindings) are produced lazily when a connection is // created (`resolveTools`) / a tool is first invoked (`invokeTool`), // using that connection's credential. - if (baseConfig.introspectionJson === undefined) { + if (input.introspectionJson === undefined) { yield* ctx.transaction( ctx.core.integrations.register({ slug, @@ -617,7 +614,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { // Pre-supplied introspection JSON: parse it offline (no network) and // persist the operation bindings + snapshot so production stays offline. - const introspection = yield* parseIntrospectionJson(baseConfig.introspectionJson); + const introspection = yield* parseIntrospectionJson(input.introspectionJson); const { result } = yield* extract(introspection); const prepared = prepareOperations(result.fields, introspection); @@ -629,9 +626,8 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { // carries only its hash. const snapshotJson = JSON.stringify({ data: introspection }); const introspectionHash = yield* sha256Hex(snapshotJson); - const { introspectionJson: _inline, ...withoutInline } = baseConfig; const config = GraphqlIntegrationConfig.make({ - ...withoutInline, + ...baseConfig, introspectionHash, }); @@ -677,9 +673,6 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { const next = GraphqlIntegrationConfig.make({ endpoint: input.endpoint ?? current.endpoint, name: input.name?.trim() || current.name, - ...(current.introspectionJson !== undefined - ? { introspectionJson: current.introspectionJson } - : {}), ...(current.introspectionHash !== undefined ? { introspectionHash: current.introspectionHash } : {}), diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index a0523a032..15792819f 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -172,12 +172,10 @@ export const GraphqlIntegrationConfig = Schema.Struct({ endpoint: Schema.String, /** Display name for the integration. */ name: Schema.String, - /** Inlined introspection JSON text. Legacy rows only: new snapshots live in - * the plugin blob store and the config carries `introspectionHash` instead, - * so a multi-MB schema never rides along on catalog reads. */ - introspectionJson: Schema.optional(Schema.String), /** Hex SHA-256 of the introspection JSON snapshot — the content address of - * the blob (`introspection/` in the plugin blob store). */ + * the blob (`introspection/` in the plugin blob store). Rows that + * predate the blob store (inline `introspectionJson` text) are rewritten + * by the introspection-to-blob migrations before this schema sees them. */ introspectionHash: Schema.optional(Schema.String), /** Static headers applied to every request (and to add-time introspection). */ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index eb7894b45..c6d4e6c07 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -15,13 +15,16 @@ import type { Authentication } from "./types"; // // In v2 there are NO credential bindings, NO per-source secret slots, and NO // StoredSource credential config. The config carries only: -// - the content hash of the spec blob (legacy rows inline the text instead) -// and/or the source URL to (re)fetch from, +// - the content hash of the spec blob and/or the source URL to (re)fetch +// from, // - the optional base URL override, // - the auth templates a connection's value is rendered through. // The resolved spec text itself lives in the plugin blob store, keyed // `spec/` — it's a build input for resolveTools/refresh, not data -// any list/invoke path should pay to load. +// any list/invoke path should pay to load. Rows that predate the blob store +// (inline `spec` text) are rewritten before this schema sees them: cloud by +// the out-of-band migrate-specs-to-blobs script, the libSQL hosts by the +// boot-time ledger migration. // --------------------------------------------------------------------------- const OAuthAuthenticationSchema = Schema.Struct({ @@ -35,10 +38,6 @@ const OAuthAuthenticationSchema = Schema.Struct({ export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]); export const OpenApiIntegrationConfigSchema = Schema.Struct({ - /** Inlined OpenAPI document text. Legacy rows only: new registrations store - * the resolved text in the plugin blob store and carry `specHash` instead, - * so multi-MB documents never ride along on catalog reads. */ - spec: Schema.optional(Schema.String), /** Hex SHA-256 of the resolved spec text — the content address of the spec * blob (`spec/` in the plugin blob store). */ specHash: Schema.optional(Schema.String), diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index e80e81d9f..26f8b0a7a 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -542,19 +542,15 @@ export const describeOpenApiIntegrationDisplay = ( // --------------------------------------------------------------------------- // Spec text resolution — the stored config carries the spec's content hash -// (`specHash` → blob `spec/`); legacy rows still inline the text in -// `spec`. Every reader goes through this loader so both shapes work until the -// inline rows are backfilled. +// (`specHash` → blob `spec/`). Pre-blob rows that inlined the text are +// rewritten by the spec-to-blob migrations before this code reads them. // --------------------------------------------------------------------------- const loadSpecText = ( storage: OpenapiStore, config: OpenApiIntegrationConfig, -): Effect.Effect => { - if (config.spec != null) return Effect.succeed(config.spec); - if (config.specHash != null) return storage.getSpec(config.specHash); - return Effect.succeed(null); -}; +): Effect.Effect => + config.specHash != null ? storage.getSpec(config.specHash) : Effect.succeed(null); // --------------------------------------------------------------------------- // Spec → tool definitions (shared by addSpec, resolveTools, and detect) diff --git a/packages/plugins/openapi/src/sdk/spec-blob.test.ts b/packages/plugins/openapi/src/sdk/spec-blob.test.ts index e40720fba..171728c2a 100644 --- a/packages/plugins/openapi/src/sdk/spec-blob.test.ts +++ b/packages/plugins/openapi/src/sdk/spec-blob.test.ts @@ -85,7 +85,7 @@ describe("OpenAPI plugin — spec blob storage", () => { }); const config = yield* executor.openapi.getConfig("blob_api"); - expect(config?.spec).toBeUndefined(); + expect(Object.keys(config ?? {})).not.toContain("spec"); expect(config?.specHash).toBe(yield* sha256Hex(text)); }), ), @@ -126,41 +126,50 @@ describe("OpenAPI plugin — spec blob storage", () => { ), ); - it.effect("resolveTools still derives tools from a legacy inline-spec config", () => + it.effect("resolveTools reads the spec from the store, never an inline field", () => Effect.gen(function* () { const plugin = openApiPlugin({ httpClientLayer: FetchHttpClient.layer }); - // A legacy row inlines the spec; the loader must never touch the store. + const text = specText(); + const hash = yield* sha256Hex(text); const storage: OpenapiStore = { putOperations: () => Effect.void, getOperation: () => Effect.succeed(null), listOperations: () => Effect.succeed([]), removeOperations: () => Effect.void, putSpec: () => Effect.void, - getSpec: () => Effect.succeed(null), + getSpec: (specHash) => Effect.succeed(specHash === hash ? text : null), }; - const result = yield* plugin.resolveTools!({ - integration: { - slug: IntegrationSlug.make("legacy_api"), - description: "legacy", - kind: "openapi", - canRemove: true, - canRefresh: false, - authMethods: [], - }, - config: { spec: specText() } as IntegrationConfig, - connection: { - owner: "org", - integration: IntegrationSlug.make("legacy_api"), - name: ConnectionName.make("main"), - }, - template: null, - storage, - getValue: () => Effect.succeed(null), - getValues: () => Effect.succeed({}), - }); - - expect(result.tools.map((tool) => String(tool.name))).toContain("items.echoHeaders"); + const resolve = (config: IntegrationConfig) => + plugin.resolveTools!({ + integration: { + slug: IntegrationSlug.make("pointer_api"), + description: "pointer", + kind: "openapi", + canRemove: true, + canRefresh: false, + authMethods: [], + }, + config, + connection: { + owner: "org", + integration: IntegrationSlug.make("pointer_api"), + name: ConnectionName.make("main"), + }, + template: null, + storage, + getValue: () => Effect.succeed(null), + getValues: () => Effect.succeed({}), + }); + + const fromPointer = yield* resolve({ specHash: hash } as IntegrationConfig); + expect(fromPointer.tools.map((tool) => String(tool.name))).toContain("items.echoHeaders"); + + // A pre-migration row that still inlines `spec` yields no tools: the + // spec-to-blob migrations rewrite those rows before this code runs, so + // the runtime carries no inline-read path. + const fromInline = yield* resolve({ spec: text } as IntegrationConfig); + expect(fromInline.tools).toHaveLength(0); }), );