* refactor(openapi,graphql): store invocation config on source, not per-tool Per-tool binding rows were duplicating the full InvocationConfig (baseUrl/endpoint + headers) on every entry. A source with N operations stored N copies of the same config in KV, and every updateSource call read + rewrote every binding row. Move invocationConfig to the source row (one per namespace) alongside the user-facing SourceConfig. Per-tool rows now store only { namespace, binding }. The invoker resolves config via getSource on the hot path; updateSource is a single putSource (O(1) writes). * feat(openapi,graphql): self-heal legacy source rows on read Pre-refactor source rows don't carry invocationConfig. Mark the field decode-optional on StoredSourceSchema, and in the KV stores rehydrate missing invocationConfig on read: - GraphQL: synthesize from source.config.endpoint + headers (lossless, endpoint is always user-provided). - OpenAPI: scan for a legacy binding row whose inlined config carries the resolved baseUrl. Fall back to source.config.baseUrl. getSource writes the healed row back; listSources rehydrates in memory only to avoid N writes per list. All migration paths are tagged with TODO(migration) for a later cleanup pass.
Unified · Split
| @@ -170,7 +170,17 @@ export const makeGraphqlInvoker = (opts: { · 169 unmodified lines | |||
| 170 | }); | 170 | }); |
| 171 | } | 171 | } |
| 172 | 172 | ||
| 173 | const { binding, config } = entry; | 173 | const source = yield* opts.operationStore.getSource(entry.namespace); |
| 174 | if (!source) { | ||
| 175 | return yield* new ToolInvocationError({ | ||
| 176 | toolId, | ||
| 177 | message: `No source found for namespace "${entry.namespace}"`, | ||
| 178 | cause: undefined, | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | |||
| 182 | const { binding } = entry; | ||
| 183 | const { invocationConfig: config } = source; | ||
| 174 | 184 | ||
| 175 | // Resolve secret-backed headers | 185 | // Resolve secret-backed headers |
| 176 | const resolvedHeaders = yield* resolveHeaders(config.headers, opts.secrets, opts.scopeId); | 186 | const resolvedHeaders = yield* resolveHeaders(config.headers, opts.secrets, opts.scopeId); |
| @@ -6,7 +6,7 @@ import { Effect, Schema } from "effect"; · 5 unmodified lines | |||
| 6 | import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; | 6 | import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; |
| 7 | 7 | ||
| 8 | import type { GraphqlOperationStore, StoredSource } from "./operation-store"; | 8 | import type { GraphqlOperationStore, StoredSource } from "./operation-store"; |
| 9 | import { OperationBinding, InvocationConfig } from "./types"; | 9 | import { InvocationConfig, OperationBinding } from "./types"; |
| 10 | import { StoredSourceSchema } from "./stored-source"; | 10 | import { StoredSourceSchema } from "./stored-source"; |
| 11 | 11 | ||
| 12 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| @@ -16,7 +16,6 @@ import { StoredSourceSchema } from "./stored-source"; · 3 unmodified lines | |||
| 16 | class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({ | 16 | class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({ |
| 17 | namespace: Schema.String, | 17 | namespace: Schema.String, |
| 18 | binding: OperationBinding, | 18 | binding: OperationBinding, |
| 19 | config: InvocationConfig, | ||
| 20 | }) {} | 19 | }) {} |
| 21 | 20 | ||
| 22 | const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); | 21 | const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); |
| @@ -29,18 +28,36 @@ const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchem · 6 unmodified lines | |||
| 29 | // Implementation | 28 | // Implementation |
| 30 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- |
| 31 | 30 | ||
| 31 | // TODO(migration): remove DecodedSource + rehydrate once all source rows | ||
| 32 | // have been migrated to carry invocationConfig. For GraphQL the endpoint | ||
| 33 | // is always user-provided in SourceConfig, so rehydration is lossless. | ||
| 34 | type DecodedSource = Omit<StoredSource, "invocationConfig"> & { | ||
| 35 | invocationConfig?: InvocationConfig; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const rehydrate = (source: DecodedSource): StoredSource => | ||
| 39 | source.invocationConfig | ||
| 40 | ? (source as StoredSource) | ||
| 41 | : { | ||
| 42 | ...source, | ||
| 43 | invocationConfig: new InvocationConfig({ | ||
| 44 | endpoint: source.config.endpoint, | ||
| 45 | headers: source.config.headers ?? {}, | ||
| 46 | }), | ||
| 47 | }; | ||
| 48 | |||
| 32 | const makeStore = (bindings: ScopedKv, sources: ScopedKv): GraphqlOperationStore => ({ | 49 | const makeStore = (bindings: ScopedKv, sources: ScopedKv): GraphqlOperationStore => ({ |
| 33 | get: (toolId) => | 50 | get: (toolId) => |
| 34 | Effect.gen(function* () { | 51 | Effect.gen(function* () { |
| 35 | const raw = yield* bindings.get(toolId); | 52 | const raw = yield* bindings.get(toolId); |
| 36 | if (!raw) return null; | 53 | if (!raw) return null; |
| 37 | const entry = decodeEntry(raw); | 54 | const entry = decodeEntry(raw); |
| 38 | return { binding: entry.binding, config: entry.config }; | 55 | return { binding: entry.binding, namespace: entry.namespace }; |
| 39 | }), | 56 | }), |
| 40 | 57 | ||
| 41 | put: (toolId, namespace, binding, config) => | 58 | put: (toolId, namespace, binding) => |
| 42 | bindings.set([ | 59 | bindings.set([ |
| 43 | { key: toolId, value: encodeEntry(new StoredEntry({ namespace, binding, config })) }, | 60 | { key: toolId, value: encodeEntry(new StoredEntry({ namespace, binding })) }, |
| 44 | ]), | 61 | ]), |
| 45 | 62 | ||
| 46 | remove: (toolId) => bindings.delete([toolId]).pipe(Effect.asVoid), | 63 | remove: (toolId) => bindings.delete([toolId]).pipe(Effect.asVoid), |
| @@ -75,14 +92,20 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): GraphqlOperationStore · 28 unmodified lines | |||
| 75 | listSources: () => | 92 | listSources: () => |
| 76 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 77 | const entries = yield* sources.list(); | 94 | const entries = yield* sources.list(); |
| 78 | return entries.map((e) => decodeSource(e.value) as StoredSource); | 95 | // TODO(migration): rehydrate in memory only — avoid N writes per list. |
| 96 | return entries.map((e) => rehydrate(decodeSource(e.value) as DecodedSource)); | ||
| 79 | }), | 97 | }), |
| 80 | 98 | ||
| 81 | getSource: (namespace) => | 99 | getSource: (namespace) => |
| 82 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 83 | const raw = yield* sources.get(namespace); | 101 | const raw = yield* sources.get(namespace); |
| 84 | if (!raw) return null; | 102 | if (!raw) return null; |
| 85 | return decodeSource(raw) as StoredSource; | 103 | const source = decodeSource(raw) as DecodedSource; |
| 104 | if (source.invocationConfig) return source as StoredSource; | ||
| 105 | // TODO(migration): self-heal — rehydrate and write back once. | ||
| 106 | const healed = rehydrate(source); | ||
| 107 | yield* sources.set([{ key: namespace, value: encodeSource(healed) }]); | ||
| 108 | return healed; | ||
| 86 | }), | 109 | }), |
| 87 | 110 | ||
| 88 | getSourceConfig: (namespace) => | 111 | getSourceConfig: (namespace) => |
| @@ -18,18 +18,19 @@ export interface StoredSource { · 17 unmodified lines | |||
| 18 | readonly namespace: string; | 18 | readonly namespace: string; |
| 19 | readonly name: string; | 19 | readonly name: string; |
| 20 | readonly config: SourceConfig; | 20 | readonly config: SourceConfig; |
| 21 | /** Pre-resolved runtime invocation config (endpoint, headers). */ | ||
| 22 | readonly invocationConfig: InvocationConfig; | ||
| 21 | } | 23 | } |
| 22 | 24 | ||
| 23 | export interface GraphqlOperationStore { | 25 | export interface GraphqlOperationStore { |
| 24 | readonly get: ( | 26 | readonly get: ( |
| 25 | toolId: ToolId, | 27 | toolId: ToolId, |
| 26 | ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>; | 28 | ) => Effect.Effect<{ binding: OperationBinding; namespace: string } | null>; |
| 27 | 29 | ||
| 28 | readonly put: ( | 30 | readonly put: ( |
| 29 | toolId: ToolId, | 31 | toolId: ToolId, |
| 30 | namespace: string, | 32 | namespace: string, |
| 31 | binding: OperationBinding, | 33 | binding: OperationBinding, |
| 32 | config: InvocationConfig, | ||
| 33 | ) => Effect.Effect<void>; | 34 | ) => Effect.Effect<void>; |
| 34 | 35 | ||
| 35 | readonly remove: (toolId: ToolId) => Effect.Effect<void>; | 36 | readonly remove: (toolId: ToolId) => Effect.Effect<void>; |
| @@ -370,7 +370,7 @@ export const graphqlPlugin = (options?: { · 369 unmodified lines | |||
| 370 | variableNames: extractedField.arguments.map((a) => a.name), | 370 | variableNames: extractedField.arguments.map((a) => a.name), |
| 371 | }); | 371 | }); |
| 372 | 372 | ||
| 373 | return operationStore.put(reg.id, namespace, binding, invocationConfig); | 373 | return operationStore.put(reg.id, namespace, binding); |
| 374 | }, | 374 | }, |
| 375 | { discard: true }, | 375 | { discard: true }, |
| 376 | ); | 376 | ); |
| @@ -386,6 +386,7 @@ export const graphqlPlugin = (options?: { · 9 unmodified lines | |||
| 386 | namespace: config.namespace, | 386 | namespace: config.namespace, |
| 387 | headers: config.headers, | 387 | headers: config.headers, |
| 388 | }, | 388 | }, |
| 389 | invocationConfig, | ||
| 389 | }); | 390 | }); |
| 390 | 391 | ||
| 391 | return { sourceId: namespace, toolCount: registrations.length }; | 392 | return { sourceId: namespace, toolCount: registrations.length }; |
| @@ -438,11 +439,11 @@ export const graphqlPlugin = (options?: { · 46 unmodified lines | |||
| 438 | 439 | ||
| 439 | updateSource: (namespace: string, input: GraphqlUpdateSourceInput) => | 440 | updateSource: (namespace: string, input: GraphqlUpdateSourceInput) => |
| 440 | Effect.gen(function* () { | 441 | Effect.gen(function* () { |
| 441 | const existingConfig = yield* operationStore.getSourceConfig(namespace); | 442 | const existing = yield* operationStore.getSource(namespace); |
| 442 | if (!existingConfig) return; | 443 | if (!existing) return; |
| 443 | 444 | ||
| 444 | const updatedConfig = { | 445 | const updatedConfig = { |
| 445 | ...existingConfig, | 446 | ...existing.config, |
| 446 | ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), | 447 | ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), |
| 447 | ...(input.headers !== undefined | 448 | ...(input.headers !== undefined |
| 448 | ? { headers: input.headers as Record<string, HeaderValueValue> } | 449 | ? { headers: input.headers as Record<string, HeaderValueValue> } |
| @@ -454,26 +455,11 @@ export const graphqlPlugin = (options?: { · 5 unmodified lines | |||
| 454 | headers: (updatedConfig.headers ?? {}) as Record<string, HeaderValueValue>, | 455 | headers: (updatedConfig.headers ?? {}) as Record<string, HeaderValueValue>, |
| 455 | }); | 456 | }); |
| 456 | 457 | ||
| 457 | const toolIds = yield* operationStore.listByNamespace(namespace); | ||
| 458 | for (const toolId of toolIds) { | ||
| 459 | const entry = yield* operationStore.get(toolId); | ||
| 460 | if (entry) { | ||
| 461 | yield* operationStore.put( | ||
| 462 | toolId, | ||
| 463 | namespace, | ||
| 464 | entry.binding, | ||
| 465 | newInvocationConfig, | ||
| 466 | ); | ||
| 467 | } | ||
| 468 | } | ||
| 469 | |||
| 470 | const sources = yield* operationStore.listSources(); | ||
| 471 | const existingMeta = sources.find((s) => s.namespace === namespace); | ||
| 472 | |||
| 473 | yield* operationStore.putSource({ | 458 | yield* operationStore.putSource({ |
| 474 | namespace, | 459 | namespace, |
| 475 | name: existingMeta?.name ?? namespace, | 460 | name: existing.name, |
| 476 | config: updatedConfig, | 461 | config: updatedConfig, |
| 462 | invocationConfig: newInvocationConfig, | ||
| 477 | }); | 463 | }); |
| 478 | }), | 464 | }), |
| 479 | }, | 465 | }, |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import { HeaderValue } from "./types"; | 3 | import { HeaderValue, InvocationConfig } from "./types"; |
| 4 | 4 | ||
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 6 | // Stored source — the shape persisted by the operation store and exposed | 6 | // Stored source — the shape persisted by the operation store and exposed |
| @@ -16,6 +16,10 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>("Graphq · 9 unmodified lines | |||
| 16 | namespace: Schema.optional(Schema.String), | 16 | namespace: Schema.optional(Schema.String), |
| 17 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValue })), | 17 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValue })), |
| 18 | }), | 18 | }), |
| 19 | // TODO(migration): make required once all rows have been migrated to | ||
| 20 | // carry invocationConfig. Left optional for decode compat with rows | ||
| 21 | // written before the source-level invocationConfig refactor. | ||
| 22 | invocationConfig: Schema.optional(InvocationConfig), | ||
| 19 | }) {} | 23 | }) {} |
| 20 | 24 | ||
| 21 | export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; | 25 | export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; |
| @@ -292,7 +292,17 @@ export const makeOpenApiInvoker = (opts: { · 291 unmodified lines | |||
| 292 | }); | 292 | }); |
| 293 | } | 293 | } |
| 294 | 294 | ||
| 295 | const { binding, config } = entry; | 295 | const source = yield* opts.operationStore.getSource(entry.namespace); |
| 296 | if (!source) { | ||
| 297 | return yield* new ToolInvocationError({ | ||
| 298 | toolId, | ||
| 299 | message: `No source found for namespace "${entry.namespace}"`, | ||
| 300 | cause: undefined, | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | const { binding } = entry; | ||
| 305 | const { invocationConfig: config } = source; | ||
| 296 | const baseUrl = config.baseUrl; | 306 | const baseUrl = config.baseUrl; |
| 297 | 307 | ||
| 298 | // Resolve secret-backed headers | 308 | // Resolve secret-backed headers |
| @@ -8,7 +8,7 @@ import { Effect, Schema } from "effect"; · 7 unmodified lines | |||
| 8 | import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; | 8 | import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; |
| 9 | 9 | ||
| 10 | import type { OpenApiOperationStore, StoredOperation, StoredSource } from "./operation-store"; | 10 | import type { OpenApiOperationStore, StoredOperation, StoredSource } from "./operation-store"; |
| 11 | import { OperationBinding, InvocationConfig } from "./types"; | 11 | import { InvocationConfig, OperationBinding } from "./types"; |
| 12 | import { StoredSourceSchema } from "./stored-source"; | 12 | import { StoredSourceSchema } from "./stored-source"; |
| 13 | 13 | ||
| 14 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| @@ -18,12 +18,23 @@ import { StoredSourceSchema } from "./stored-source"; · 3 unmodified lines | |||
| 18 | class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({ | 18 | class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({ |
| 19 | namespace: Schema.String, | 19 | namespace: Schema.String, |
| 20 | binding: OperationBinding, | 20 | binding: OperationBinding, |
| 21 | config: InvocationConfig, | ||
| 22 | }) {} | 21 | }) {} |
| 23 | 22 | ||
| 24 | const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); | 23 | const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); |
| 25 | const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry)); | 24 | const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry)); |
| 26 | 25 | ||
| 26 | // TODO(migration): remove LegacyStoredEntry + rehydrateInvocationConfig | ||
| 27 | // once all source rows have been migrated off the pre-refactor schema. | ||
| 28 | // Old binding rows inlined the resolved InvocationConfig, which is the | ||
| 29 | // only place the server-derived baseUrl was persisted. | ||
| 30 | class LegacyStoredEntry extends Schema.Class<LegacyStoredEntry>("LegacyStoredEntry")({ | ||
| 31 | namespace: Schema.String, | ||
| 32 | binding: OperationBinding, | ||
| 33 | config: Schema.optional(InvocationConfig), | ||
| 34 | }) {} | ||
| 35 | |||
| 36 | const decodeLegacyEntry = Schema.decodeUnknownSync(Schema.parseJson(LegacyStoredEntry)); | ||
| 37 | |||
| 27 | const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema)); | 38 | const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema)); |
| 28 | const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema)); | 39 | const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema)); |
| 29 | 40 | ||
| @@ -37,22 +48,53 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): OpenApiOperationStore · 7 unmodified lines | |||
| 37 | effect: Effect.Effect<A, E, never>, | 48 | effect: Effect.Effect<A, E, never>, |
| 38 | ): Effect.Effect<A, E, never> => kv.withTransaction?.(effect) ?? effect; | 49 | ): Effect.Effect<A, E, never> => kv.withTransaction?.(effect) ?? effect; |
| 39 | 50 | ||
| 51 | // TODO(migration): remove along with LegacyStoredEntry. | ||
| 52 | // Rebuild invocationConfig for a source row that predates the refactor. | ||
| 53 | // We try to recover the resolved baseUrl from any legacy binding row | ||
| 54 | // (old rows inlined the full InvocationConfig); if none is found, fall | ||
| 55 | // back to the user-provided baseUrl from the source config. | ||
| 56 | type DecodedSource = Omit<StoredSource, "invocationConfig"> & { | ||
| 57 | invocationConfig?: InvocationConfig; | ||
| 58 | }; | ||
| 59 | const rehydrateInvocationConfig = ( | ||
| 60 | source: DecodedSource, | ||
| 61 | ): Effect.Effect<StoredSource> => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | if (source.invocationConfig) return source as StoredSource; | ||
| 64 | let recovered: InvocationConfig | null = null; | ||
| 65 | const entries = yield* bindings.list(); | ||
| 66 | for (const e of entries) { | ||
| 67 | const legacy = decodeLegacyEntry(e.value); | ||
| 68 | if (legacy.namespace === source.namespace && legacy.config) { | ||
| 69 | recovered = legacy.config; | ||
| 70 | break; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | const invocationConfig = | ||
| 74 | recovered ?? | ||
| 75 | new InvocationConfig({ | ||
| 76 | baseUrl: source.config.baseUrl ?? "", | ||
| 77 | headers: source.config.headers ?? {}, | ||
| 78 | }); | ||
| 79 | return { ...source, invocationConfig }; | ||
| 80 | }); | ||
| 81 | |||
| 40 | return { | 82 | return { |
| 41 | get: (toolId) => | 83 | get: (toolId) => |
| 42 | Effect.gen(function* () { | 84 | Effect.gen(function* () { |
| 43 | const raw = yield* bindings.get(toolId); | 85 | const raw = yield* bindings.get(toolId); |
| 44 | if (!raw) return null; | 86 | if (!raw) return null; |
| 45 | const entry = decodeEntry(raw); | 87 | const entry = decodeEntry(raw); |
| 46 | return { binding: entry.binding, config: entry.config }; | 88 | return { binding: entry.binding, namespace: entry.namespace }; |
| 47 | }), | 89 | }), |
| 48 | 90 | ||
| 49 | put: (entries: readonly StoredOperation[]) => | 91 | put: (entries: readonly StoredOperation[]) => |
| 50 | withKvTransaction( | 92 | withKvTransaction( |
| 51 | bindings, | 93 | bindings, |
| 52 | bindings.set( | 94 | bindings.set( |
| 53 | entries.map(({ toolId, namespace, binding, config }) => ({ | 95 | entries.map(({ toolId, namespace, binding }) => ({ |
| 54 | key: toolId, | 96 | key: toolId, |
| 55 | value: encodeEntry(new StoredEntry({ namespace, binding, config })), | 97 | value: encodeEntry(new StoredEntry({ namespace, binding })), |
| 56 | })), | 98 | })), |
| 57 | ), | 99 | ), |
| 58 | ), | 100 | ), |
| @@ -89,14 +131,29 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): OpenApiOperationStore · 30 unmodified lines | |||
| 89 | listSources: () => | 131 | listSources: () => |
| 90 | Effect.gen(function* () { | 132 | Effect.gen(function* () { |
| 91 | const entries = yield* sources.list(); | 133 | const entries = yield* sources.list(); |
| 92 | return entries.map((e) => decodeSource(e.value) as StoredSource); | 134 | const out: StoredSource[] = []; |
| 135 | for (const e of entries) { | ||
| 136 | const raw = decodeSource(e.value) as DecodedSource; | ||
| 137 | // TODO(migration): rehydrate in memory only — avoid N writes per list. | ||
| 138 | out.push( | ||
| 139 | raw.invocationConfig | ||
| 140 | ? (raw as StoredSource) | ||
| 141 | : yield* rehydrateInvocationConfig(raw), | ||
| 142 | ); | ||
| 143 | } | ||
| 144 | return out; | ||
| 93 | }), | 145 | }), |
| 94 | 146 | ||
| 95 | getSource: (namespace) => | 147 | getSource: (namespace) => |
| 96 | Effect.gen(function* () { | 148 | Effect.gen(function* () { |
| 97 | const raw = yield* sources.get(namespace); | 149 | const raw = yield* sources.get(namespace); |
| 98 | if (!raw) return null; | 150 | if (!raw) return null; |
| 99 | return decodeSource(raw) as StoredSource; | 151 | const source = decodeSource(raw) as DecodedSource; |
| 152 | if (source.invocationConfig) return source as StoredSource; | ||
| 153 | // TODO(migration): self-heal — rehydrate and write back once. | ||
| 154 | const healed = yield* rehydrateInvocationConfig(source); | ||
| 155 | yield* sources.set([{ key: namespace, value: encodeSource(healed) }]); | ||
| 156 | return healed; | ||
| 100 | }), | 157 | }), |
| 101 | 158 | ||
| 102 | getSourceConfig: (namespace) => | 159 | getSourceConfig: (namespace) => |
| @@ -18,19 +18,20 @@ export interface StoredSource { · 17 unmodified lines | |||
| 18 | readonly namespace: string; | 18 | readonly namespace: string; |
| 19 | readonly name: string; | 19 | readonly name: string; |
| 20 | readonly config: SourceConfig; | 20 | readonly config: SourceConfig; |
| 21 | /** Pre-resolved runtime invocation config (baseUrl resolved from servers, headers). */ | ||
| 22 | readonly invocationConfig: InvocationConfig; | ||
| 21 | } | 23 | } |
| 22 | 24 | ||
| 23 | export interface StoredOperation { | 25 | export interface StoredOperation { |
| 24 | readonly toolId: ToolId; | 26 | readonly toolId: ToolId; |
| 25 | readonly namespace: string; | 27 | readonly namespace: string; |
| 26 | readonly binding: OperationBinding; | 28 | readonly binding: OperationBinding; |
| 27 | readonly config: InvocationConfig; | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | export interface OpenApiOperationStore { | 31 | export interface OpenApiOperationStore { |
| 31 | readonly get: ( | 32 | readonly get: ( |
| 32 | toolId: ToolId, | 33 | toolId: ToolId, |
| 33 | ) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>; | 34 | ) => Effect.Effect<{ binding: OperationBinding; namespace: string } | null>; |
| 34 | 35 | ||
| 35 | readonly put: (entries: readonly StoredOperation[]) => Effect.Effect<void>; | 36 | readonly put: (entries: readonly StoredOperation[]) => Effect.Effect<void>; |
| 36 | 37 | ||
| @@ -275,7 +275,6 @@ export const openApiPlugin = (options?: { · 274 unmodified lines | |||
| 275 | toolId: ToolId.make(`${namespace}.${def.toolPath}`), | 275 | toolId: ToolId.make(`${namespace}.${def.toolPath}`), |
| 276 | namespace, | 276 | namespace, |
| 277 | binding: toBinding(def), | 277 | binding: toBinding(def), |
| 278 | config: invocationConfig, | ||
| 279 | })), | 278 | })), |
| 280 | ); | 279 | ); |
| 281 | 280 | ||
| @@ -291,6 +290,7 @@ export const openApiPlugin = (options?: { · 9 unmodified lines | |||
| 291 | namespace: config.namespace, | 290 | namespace: config.namespace, |
| 292 | headers: config.headers, | 291 | headers: config.headers, |
| 293 | }, | 292 | }, |
| 293 | invocationConfig, | ||
| 294 | }); | 294 | }); |
| 295 | 295 | ||
| 296 | return { sourceId: namespace, toolCount: registrations.length }; | 296 | return { sourceId: namespace, toolCount: registrations.length }; |
| @@ -345,11 +345,11 @@ export const openApiPlugin = (options?: { · 48 unmodified lines | |||
| 345 | 345 | ||
| 346 | updateSource: (namespace: string, input: OpenApiUpdateSourceInput) => | 346 | updateSource: (namespace: string, input: OpenApiUpdateSourceInput) => |
| 347 | Effect.gen(function* () { | 347 | Effect.gen(function* () { |
| 348 | const existingSource = yield* operationStore.getSourceConfig(namespace); | 348 | const existing = yield* operationStore.getSource(namespace); |
| 349 | if (!existingSource) return; | 349 | if (!existing) return; |
| 350 | 350 | ||
| 351 | const updatedConfig = { | 351 | const updatedConfig = { |
| 352 | ...existingSource, | 352 | ...existing.config, |
| 353 | ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), | 353 | ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), |
| 354 | ...(input.headers !== undefined | 354 | ...(input.headers !== undefined |
| 355 | ? { headers: input.headers as Record<string, HeaderValueValue> } | 355 | ? { headers: input.headers as Record<string, HeaderValueValue> } |
| @@ -357,32 +357,15 @@ export const openApiPlugin = (options?: { · 1 unmodified line | |||
| 357 | }; | 357 | }; |
| 358 | 358 | ||
| 359 | const newInvocationConfig = new InvocationConfig({ | 359 | const newInvocationConfig = new InvocationConfig({ |
| 360 | baseUrl: updatedConfig.baseUrl ?? resolveBaseUrl([]), | 360 | baseUrl: updatedConfig.baseUrl ?? existing.invocationConfig.baseUrl, |
| 361 | headers: (updatedConfig.headers ?? {}) as Record<string, HeaderValueValue>, | 361 | headers: (updatedConfig.headers ?? {}) as Record<string, HeaderValueValue>, |
| 362 | }); | 362 | }); |
| 363 | 363 | ||
| 364 | const toolIds = yield* operationStore.listByNamespace(namespace); | ||
| 365 | for (const toolId of toolIds) { | ||
| 366 | const entry = yield* operationStore.get(toolId); | ||
| 367 | if (entry) { | ||
| 368 | yield* operationStore.put([ | ||
| 369 | { | ||
| 370 | toolId, | ||
| 371 | namespace, | ||
| 372 | binding: entry.binding, | ||
| 373 | config: newInvocationConfig, | ||
| 374 | }, | ||
| 375 | ]); | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | const sources = yield* operationStore.listSources(); | ||
| 380 | const existingMeta = sources.find((s) => s.namespace === namespace); | ||
| 381 | |||
| 382 | yield* operationStore.putSource({ | 364 | yield* operationStore.putSource({ |
| 383 | namespace, | 365 | namespace, |
| 384 | name: existingMeta?.name ?? namespace, | 366 | name: existing.name, |
| 385 | config: updatedConfig, | 367 | config: updatedConfig, |
| 368 | invocationConfig: newInvocationConfig, | ||
| 386 | }); | 369 | }); |
| 387 | }), | 370 | }), |
| 388 | }, | 371 | }, |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import { HeaderValue } from "./types"; | 3 | import { HeaderValue, InvocationConfig } from "./types"; |
| 4 | 4 | ||
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 6 | // Stored source — the shape persisted by the operation store and exposed | 6 | // Stored source — the shape persisted by the operation store and exposed |
| @@ -16,6 +16,10 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>("OpenAp · 9 unmodified lines | |||
| 16 | namespace: Schema.optional(Schema.String), | 16 | namespace: Schema.optional(Schema.String), |
| 17 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValue })), | 17 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValue })), |
| 18 | }), | 18 | }), |
| 19 | // TODO(migration): make required once all rows have been migrated to | ||
| 20 | // carry invocationConfig. Left optional for decode compat with rows | ||
| 21 | // written before the source-level invocationConfig refactor. | ||
| 22 | invocationConfig: Schema.optional(InvocationConfig), | ||
| 19 | }) {} | 23 | }) {} |
| 20 | 24 | ||
| 21 | export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; | 25 | export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; |