Skip to content

Commit bd677e0

Browse files
committed
Store OpenAPI specs and GraphQL snapshots in the blob seam, not integration.config
integration.config inlined the full resolved OpenAPI spec text (and any GraphQL introspection snapshot), so every integrations list loaded multi-MB build artifacts it immediately discarded — in production the worst tenant paid 48 MB per list call and /api/integrations p95 ran ~8s, with ~97% of slow traces inside the single integration.findMany span. The resolved text now lives in the plugin blob store, content-addressed (spec/<sha256>, introspection/<sha256>) and org-owned; the config keeps only the hash. Day-to-day reads (list, invoke) touch nothing but the small relational rows: invoke still runs off stored operation bindings, and its store-miss fallback recompiles from the blob — the only invoke-path spec read left. ResolveToolsInput now carries the plugin's typed store so resolveTools can load the blob (it had no ctx). Legacy rows that still inline the text decode and resolve unchanged through a shared loader until the backfill rewrites them. Blobs are never deleted on integration removal: content addressing means another integration in the tenant may share the hash, and re-puts are idempotent. The openapi getConfig endpoint also stops serving the spec text — no client read it, and it shipped megabytes to the configure UI.
1 parent fa6a3ee commit bd677e0

14 files changed

Lines changed: 408 additions & 75 deletions

File tree

packages/core/sdk/src/blob.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ export const makeInMemoryBlobStore = (): BlobStore => {
157157
};
158158
};
159159

160+
/** Hex SHA-256 of a UTF-8 string — the content-address key plugins use for
161+
* write-once blobs (`put(key(hash), …)` is then idempotent and orphaned
162+
* writes are harmless). Web Crypto, so it runs on Workers/Bun/Node alike. */
163+
export const sha256Hex = (text: string): Effect.Effect<string> =>
164+
Effect.promise(async () => {
165+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
166+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
167+
"",
168+
);
169+
});
170+
160171
const blobId = (namespace: string, key: string): string => JSON.stringify([namespace, key]);
161172

162173
type BlobRow = {

packages/core/sdk/src/executor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,6 +1834,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
18341834
config: decodeJsonColumn(integrationRow.config),
18351835
connection: ref,
18361836
template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
1837+
storage: runtime.storage,
18371838
getValue: () => resolveConnectionValueByRef(ref),
18381839
getValues: () => resolveConnectionValuesByRef(ref),
18391840
})

packages/core/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export {
174174
pluginBlobStore,
175175
makeInMemoryBlobStore,
176176
makeFumaBlobStore,
177+
sha256Hex,
177178
type BlobStore,
178179
type PluginBlobStore,
179180
type OwnerPartitions,

packages/core/sdk/src/plugin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,16 @@ export interface PluginCtx<TStore = unknown> {
187187
// refresh / oauth.complete; the result is stamped with addresses and persisted.
188188
// ---------------------------------------------------------------------------
189189

190-
export interface ResolveToolsInput {
190+
export interface ResolveToolsInput<TStore = unknown> {
191191
/** The catalog record (public projection) whose connection is being resolved. */
192192
readonly integration: Integration;
193193
/** The plugin's stored opaque config for that integration. */
194194
readonly config: IntegrationConfig;
195+
/** The plugin's typed store — the same instance the extension ctx sees.
196+
* Lets spec-derived plugins load build artifacts kept behind the storage
197+
* facades (e.g. a content-addressed spec blob) instead of inlining them
198+
* in `config`. */
199+
readonly storage: TStore;
195200
/** The connection whose tools are being resolved. */
196201
readonly connection: ConnectionRef;
197202
/** Which of the integration's declared auth methods the connection binds
@@ -443,7 +448,7 @@ export interface PluginSpec<
443448
* create / refresh / oauth.complete; the result is stamped with addresses
444449
* and persisted per-connection. Omit for plugins with no dynamic tools. */
445450
readonly resolveTools?: (
446-
input: ResolveToolsInput,
451+
input: ResolveToolsInput<TStore>,
447452
) => Effect.Effect<ResolveToolsResult, StorageFailure>;
448453

449454
/** Invoke a dynamic tool. Called when the static-handler map doesn't have the

packages/plugins/graphql/src/sdk/plugin.ts

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IntegrationDetectionResult,
1111
IntegrationSlug,
1212
mergeAuthTemplates,
13+
sha256Hex,
1314
ToolName,
1415
ToolResult,
1516
type AuthMethodDescriptor,
@@ -413,17 +414,32 @@ const introspectHeadersForConnection = (
413414
return { headers, queryParams };
414415
};
415416

416-
/** Introspect a config live or from its stored JSON, applying connection auth.
417-
* `parseIntrospectionJson` short-circuits the network when a schema snapshot is
418-
* present; otherwise this introspects the endpoint with the rendered credential. */
417+
/** Resolve a config's introspection snapshot text: legacy rows inline it in
418+
* `introspectionJson`; new rows point at the plugin blob store via
419+
* `introspectionHash`. Null when the integration has no snapshot (live
420+
* introspection territory). */
421+
const loadIntrospectionJson = (
422+
storage: GraphqlStore,
423+
config: GraphqlIntegrationConfig,
424+
): Effect.Effect<string | null, StorageFailure> => {
425+
if (config.introspectionJson != null) return Effect.succeed(config.introspectionJson);
426+
if (config.introspectionHash != null) return storage.getIntrospection(config.introspectionHash);
427+
return Effect.succeed(null);
428+
};
429+
430+
/** Introspect a config live or from its stored snapshot, applying connection
431+
* auth. A non-null `introspectionJson` (loaded via `loadIntrospectionJson`)
432+
* short-circuits the network; otherwise this introspects the endpoint with
433+
* the rendered credential. */
419434
const introspectForConnection = (
420435
config: GraphqlIntegrationConfig,
436+
introspectionJson: string | null,
421437
values: Record<string, string | null>,
422438
templateSlug: AuthTemplateSlug | null,
423439
httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
424440
): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {
425-
if (config.introspectionJson) {
426-
return parseIntrospectionJson(config.introspectionJson);
441+
if (introspectionJson != null) {
442+
return parseIntrospectionJson(introspectionJson);
427443
}
428444
const auth = introspectHeadersForConnection(config, values, templateSlug);
429445
return introspect(
@@ -463,13 +479,15 @@ const materializeOperations = (
463479
Object.assign(queryParams, rendered.queryParams);
464480
}
465481

466-
const introspection = config.introspectionJson
467-
? yield* parseIntrospectionJson(config.introspectionJson)
468-
: yield* introspect(
469-
config.endpoint,
470-
Object.keys(headers).length > 0 ? headers : undefined,
471-
Object.keys(queryParams).length > 0 ? queryParams : undefined,
472-
).pipe(Effect.provide(httpClientLayer));
482+
const introspectionJson = yield* loadIntrospectionJson(ctx.storage, config);
483+
const introspection =
484+
introspectionJson != null
485+
? yield* parseIntrospectionJson(introspectionJson)
486+
: yield* introspect(
487+
config.endpoint,
488+
Object.keys(headers).length > 0 ? headers : undefined,
489+
Object.keys(queryParams).length > 0 ? queryParams : undefined,
490+
).pipe(Effect.provide(httpClientLayer));
473491

474492
const { result } = yield* extract(introspection).pipe(
475493
Effect.catch(() =>
@@ -577,55 +595,68 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => {
577595
});
578596

579597
const addIntegrationTransaction = (input: GraphqlAddIntegrationInput, slug: IntegrationSlug) =>
580-
ctx.transaction(
581-
Effect.gen(function* () {
582-
const baseConfig = buildConfig(input);
583-
584-
// No pre-supplied schema → register WITHOUT introspecting. Tools (and
585-
// their operation bindings) are produced lazily when a connection is
586-
// created (`resolveTools`) / a tool is first invoked (`invokeTool`),
587-
// using that connection's credential.
588-
if (baseConfig.introspectionJson === undefined) {
589-
yield* ctx.core.integrations.register({
598+
Effect.gen(function* () {
599+
const baseConfig = buildConfig(input);
600+
601+
// No pre-supplied schema → register WITHOUT introspecting. Tools (and
602+
// their operation bindings) are produced lazily when a connection is
603+
// created (`resolveTools`) / a tool is first invoked (`invokeTool`),
604+
// using that connection's credential.
605+
if (baseConfig.introspectionJson === undefined) {
606+
yield* ctx.transaction(
607+
ctx.core.integrations.register({
590608
slug,
591609
description: baseConfig.name,
592610
config: baseConfig,
593611
canRemove: true,
594612
canRefresh: true,
595-
});
596-
return { slug: String(slug), name: baseConfig.name, toolCount: 0 };
597-
}
613+
}),
614+
);
615+
return { slug: String(slug), name: baseConfig.name, toolCount: 0 };
616+
}
598617

599-
// Pre-supplied introspection JSON: parse it offline (no network) and
600-
// persist the operation bindings + snapshot so production stays offline.
601-
const introspection = yield* parseIntrospectionJson(baseConfig.introspectionJson);
602-
const { result } = yield* extract(introspection);
603-
const prepared = prepareOperations(result.fields, introspection);
604-
605-
// Snapshot the resolved schema so tool production never needs a live
606-
// HTTP layer (D6: tools are spec-derived and identical per connection).
607-
const config = GraphqlIntegrationConfig.make({
608-
...baseConfig,
609-
introspectionJson: JSON.stringify({ data: introspection }),
610-
});
618+
// Pre-supplied introspection JSON: parse it offline (no network) and
619+
// persist the operation bindings + snapshot so production stays offline.
620+
const introspection = yield* parseIntrospectionJson(baseConfig.introspectionJson);
621+
const { result } = yield* extract(introspection);
622+
const prepared = prepareOperations(result.fields, introspection);
623+
624+
// Snapshot the resolved schema so tool production never needs a live
625+
// HTTP layer (D6: tools are spec-derived and identical per connection).
626+
// The snapshot text goes to the plugin blob store (content-addressed,
627+
// written OUTSIDE the transaction — re-puts are idempotent and an
628+
// aborted register leaves only an unreferenced blob), and the config
629+
// carries only its hash.
630+
const snapshotJson = JSON.stringify({ data: introspection });
631+
const introspectionHash = yield* sha256Hex(snapshotJson);
632+
const { introspectionJson: _inline, ...withoutInline } = baseConfig;
633+
const config = GraphqlIntegrationConfig.make({
634+
...withoutInline,
635+
introspectionHash,
636+
});
611637

612-
yield* ctx.storage.replaceOperations(String(slug), toStoredOperations(slug, prepared));
638+
yield* ctx.storage.putIntrospection(introspectionHash, snapshotJson);
613639

614-
yield* ctx.core.integrations.register({
615-
slug,
616-
description: config.name,
617-
config,
618-
canRemove: true,
619-
canRefresh: true,
620-
});
640+
yield* ctx.transaction(
641+
Effect.gen(function* () {
642+
yield* ctx.storage.replaceOperations(String(slug), toStoredOperations(slug, prepared));
621643

622-
return {
623-
slug: String(slug),
624-
name: config.name,
625-
toolCount: prepared.length,
626-
};
627-
}),
628-
);
644+
yield* ctx.core.integrations.register({
645+
slug,
646+
description: config.name,
647+
config,
648+
canRemove: true,
649+
canRefresh: true,
650+
});
651+
}),
652+
);
653+
654+
return {
655+
slug: String(slug),
656+
name: config.name,
657+
toolCount: prepared.length,
658+
};
659+
});
629660

630661
const configureIntegration = (slug: string, input: GraphqlConfigureInput) =>
631662
Effect.gen(function* () {
@@ -649,6 +680,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => {
649680
...(current.introspectionJson !== undefined
650681
? { introspectionJson: current.introspectionJson }
651682
: {}),
683+
...(current.introspectionHash !== undefined
684+
? { introspectionHash: current.introspectionHash }
685+
: {}),
652686
...((input.headers ?? current.headers) !== undefined
653687
? { headers: input.headers ?? current.headers }
654688
: {}),
@@ -852,26 +886,30 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
852886
resolveTools: ({
853887
config,
854888
template,
889+
storage,
855890
getValues,
856891
}: {
857892
readonly config: IntegrationConfig;
858893
readonly template: AuthTemplateSlug | null;
894+
readonly storage: GraphqlStore;
859895
readonly getValues: () => Effect.Effect<Record<string, string | null>, unknown>;
860896
}) =>
861897
Effect.gen(function* () {
862898
const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);
863899
if (Option.isNone(decoded)) return { tools: [] };
864900
const graphqlConfig = decoded.value;
901+
const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig);
865902
// Live introspection (no stored snapshot) needs the connection's
866903
// credential inputs for auth-required endpoints; resolve them lazily.
867904
const values =
868-
graphqlConfig.introspectionJson === undefined
905+
introspectionJson == null
869906
? yield* getValues().pipe(
870907
Effect.catch(() => Effect.succeed({} as Record<string, string | null>)),
871908
)
872909
: ({} as Record<string, string | null>);
873910
const introspection = yield* introspectForConnection(
874911
graphqlConfig,
912+
introspectionJson,
875913
values,
876914
template,
877915
options?.httpClientLayer ?? httpClientLayerFallback,

packages/plugins/graphql/src/sdk/store.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ const rowToOperation = (row: PluginStorageEntry): StoredOperation | null => {
7171
};
7272
};
7373

74+
/** Blob key for an introspection snapshot's content hash. Content-addressed
75+
* so re-puts are idempotent and identical schemas share one blob per
76+
* partition. */
77+
export const introspectionBlobKey = (introspectionHash: string): string =>
78+
`introspection/${introspectionHash}`;
79+
7480
export interface GraphqlStore {
7581
/** Replace the stored operation bindings for an integration. */
7682
readonly replaceOperations: (
@@ -85,9 +91,20 @@ export interface GraphqlStore {
8591
integration: string,
8692
) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
8793
readonly removeOperations: (integration: string) => Effect.Effect<void, StorageFailure>;
94+
/** Persist an introspection JSON snapshot under its content hash. Org-owned
95+
* and content-addressed; never removed on integration removal because
96+
* another integration in the tenant may share the hash. */
97+
readonly putIntrospection: (
98+
introspectionHash: string,
99+
introspectionJson: string,
100+
) => Effect.Effect<void, StorageFailure>;
101+
/** Load an introspection snapshot by content hash; null when absent. */
102+
readonly getIntrospection: (
103+
introspectionHash: string,
104+
) => Effect.Effect<string | null, StorageFailure>;
88105
}
89106

90-
export const makeDefaultGraphqlStore = ({ pluginStorage }: StorageDeps): GraphqlStore => {
107+
export const makeDefaultGraphqlStore = ({ pluginStorage, blobs }: StorageDeps): GraphqlStore => {
91108
const listOperationRows = (integration: string) =>
92109
pluginStorage
93110
.list({
@@ -137,5 +154,12 @@ export const makeDefaultGraphqlStore = ({ pluginStorage }: StorageDeps): Graphql
137154
),
138155

139156
removeOperations,
157+
158+
putIntrospection: (introspectionHash, introspectionJson) =>
159+
blobs.put(introspectionBlobKey(introspectionHash), introspectionJson, {
160+
owner: CATALOG_OWNER,
161+
}),
162+
163+
getIntrospection: (introspectionHash) => blobs.get(introspectionBlobKey(introspectionHash)),
140164
};
141165
};

packages/plugins/graphql/src/sdk/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,13 @@ export const GraphqlIntegrationConfig = Schema.Struct({
172172
endpoint: Schema.String,
173173
/** Display name for the integration. */
174174
name: Schema.String,
175-
/** Optional introspection JSON text (when the endpoint doesn't support
176-
* live introspection). */
175+
/** Inlined introspection JSON text. Legacy rows only: new snapshots live in
176+
* the plugin blob store and the config carries `introspectionHash` instead,
177+
* so a multi-MB schema never rides along on catalog reads. */
177178
introspectionJson: Schema.optional(Schema.String),
179+
/** Hex SHA-256 of the introspection JSON snapshot — the content address of
180+
* the blob (`introspection/<hash>` in the plugin blob store). */
181+
introspectionHash: Schema.optional(Schema.String),
178182
/** Static headers applied to every request (and to add-time introspection). */
179183
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
180184
/** Static query parameters applied to every request. */

packages/plugins/openapi/src/api/group.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,12 @@ const IntegrationView = Schema.Struct({
100100
canRefresh: Schema.Boolean,
101101
});
102102

103-
// The full opaque integration config, surfaced for the configure UX. Unlike
103+
// The integration config surfaced for the configure UX. Unlike
104104
// `IntegrationView` (catalog identity only), this carries the
105-
// `authenticationTemplate` the configure flow reads/writes.
105+
// `authenticationTemplate` the configure flow reads/writes. The spec text is
106+
// deliberately NOT served: it's a multi-MB build artifact in the plugin blob
107+
// store, and no client reads it (the configure UI only touches the template).
106108
const OpenApiConfigView = Schema.Struct({
107-
spec: Schema.String,
108109
sourceUrl: Schema.optional(Schema.String),
109110
googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
110111
baseUrl: Schema.optional(Schema.String),

packages/plugins/openapi/src/api/handlers.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
8787
const config = yield* ext.getConfig(params.slug);
8888
return config
8989
? {
90-
spec: config.spec,
9190
sourceUrl: config.sourceUrl,
9291
googleDiscoveryUrls: config.googleDiscoveryUrls
9392
? [...config.googleDiscoveryUrls]

packages/plugins/openapi/src/sdk/config.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ import type { Authentication } from "./types";
1515
//
1616
// In v2 there are NO credential bindings, NO per-source secret slots, and NO
1717
// StoredSource credential config. The config carries only:
18-
// - the OpenAPI spec text (inlined) OR the source URL to (re)fetch from,
18+
// - the content hash of the spec blob (legacy rows inline the text instead)
19+
// and/or the source URL to (re)fetch from,
1920
// - the optional base URL override,
2021
// - the auth templates a connection's value is rendered through.
22+
// The resolved spec text itself lives in the plugin blob store, keyed
23+
// `spec/<specHash>` — it's a build input for resolveTools/refresh, not data
24+
// any list/invoke path should pay to load.
2125
// ---------------------------------------------------------------------------
2226

2327
const OAuthAuthenticationSchema = Schema.Struct({
@@ -31,8 +35,13 @@ const OAuthAuthenticationSchema = Schema.Struct({
3135
export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]);
3236

3337
export const OpenApiIntegrationConfigSchema = Schema.Struct({
34-
/** Inlined OpenAPI document text (resolved + parsed source of truth). */
35-
spec: Schema.String,
38+
/** Inlined OpenAPI document text. Legacy rows only: new registrations store
39+
* the resolved text in the plugin blob store and carry `specHash` instead,
40+
* so multi-MB documents never ride along on catalog reads. */
41+
spec: Schema.optional(Schema.String),
42+
/** Hex SHA-256 of the resolved spec text — the content address of the spec
43+
* blob (`spec/<hash>` in the plugin blob store). */
44+
specHash: Schema.optional(Schema.String),
3645
/** Origin URL the spec was fetched from, when known. Enables refresh. */
3746
sourceUrl: Schema.optional(Schema.String),
3847
/** Google Discovery bundle URLs, when the spec came from a Google bundle. */

0 commit comments

Comments
 (0)