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
12 changes: 11 additions & 1 deletion packages/plugins/graphql/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,17 @@ export const makeGraphqlInvoker = (opts: {
});
}

const { binding, config } = entry;
const source = yield* opts.operationStore.getSource(entry.namespace);
if (!source) {
return yield* new ToolInvocationError({
toolId,
message: `No source found for namespace "${entry.namespace}"`,
cause: undefined,
});
}

const { binding } = entry;
const { invocationConfig: config } = source;

// Resolve secret-backed headers
const resolvedHeaders = yield* resolveHeaders(config.headers, opts.secrets, opts.scopeId);
Expand Down
37 changes: 30 additions & 7 deletions packages/plugins/graphql/src/sdk/kv-operation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Effect, Schema } from "effect";
import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";

import type { GraphqlOperationStore, StoredSource } from "./operation-store";
import { OperationBinding, InvocationConfig } from "./types";
import { InvocationConfig, OperationBinding } from "./types";
import { StoredSourceSchema } from "./stored-source";

// ---------------------------------------------------------------------------
Expand All @@ -16,7 +16,6 @@ import { StoredSourceSchema } from "./stored-source";
class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
namespace: Schema.String,
binding: OperationBinding,
config: InvocationConfig,
}) {}

const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
Expand All @@ -29,18 +28,36 @@ const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchem
// Implementation
// ---------------------------------------------------------------------------

// TODO(migration): remove DecodedSource + rehydrate once all source rows
// have been migrated to carry invocationConfig. For GraphQL the endpoint
// is always user-provided in SourceConfig, so rehydration is lossless.
type DecodedSource = Omit<StoredSource, "invocationConfig"> & {
invocationConfig?: InvocationConfig;
};

const rehydrate = (source: DecodedSource): StoredSource =>
source.invocationConfig
? (source as StoredSource)
: {
...source,
invocationConfig: new InvocationConfig({
endpoint: source.config.endpoint,
headers: source.config.headers ?? {},
}),
};

const makeStore = (bindings: ScopedKv, sources: ScopedKv): GraphqlOperationStore => ({
get: (toolId) =>
Effect.gen(function* () {
const raw = yield* bindings.get(toolId);
if (!raw) return null;
const entry = decodeEntry(raw);
return { binding: entry.binding, config: entry.config };
return { binding: entry.binding, namespace: entry.namespace };
}),

put: (toolId, namespace, binding, config) =>
put: (toolId, namespace, binding) =>
bindings.set([
{ key: toolId, value: encodeEntry(new StoredEntry({ namespace, binding, config })) },
{ key: toolId, value: encodeEntry(new StoredEntry({ namespace, binding })) },
]),

remove: (toolId) => bindings.delete([toolId]).pipe(Effect.asVoid),
Expand Down Expand Up @@ -75,14 +92,20 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): GraphqlOperationStore
listSources: () =>
Effect.gen(function* () {
const entries = yield* sources.list();
return entries.map((e) => decodeSource(e.value) as StoredSource);
// TODO(migration): rehydrate in memory only — avoid N writes per list.
return entries.map((e) => rehydrate(decodeSource(e.value) as DecodedSource));
}),

getSource: (namespace) =>
Effect.gen(function* () {
const raw = yield* sources.get(namespace);
if (!raw) return null;
return decodeSource(raw) as StoredSource;
const source = decodeSource(raw) as DecodedSource;
if (source.invocationConfig) return source as StoredSource;
// TODO(migration): self-heal — rehydrate and write back once.
const healed = rehydrate(source);
yield* sources.set([{ key: namespace, value: encodeSource(healed) }]);
return healed;
}),

getSourceConfig: (namespace) =>
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/graphql/src/sdk/operation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@ export interface StoredSource {
readonly namespace: string;
readonly name: string;
readonly config: SourceConfig;
/** Pre-resolved runtime invocation config (endpoint, headers). */
readonly invocationConfig: InvocationConfig;
}

export interface GraphqlOperationStore {
readonly get: (
toolId: ToolId,
) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>;
) => Effect.Effect<{ binding: OperationBinding; namespace: string } | null>;

readonly put: (
toolId: ToolId,
namespace: string,
binding: OperationBinding,
config: InvocationConfig,
) => Effect.Effect<void>;

readonly remove: (toolId: ToolId) => Effect.Effect<void>;
Expand Down
28 changes: 7 additions & 21 deletions packages/plugins/graphql/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export const graphqlPlugin = (options?: {
variableNames: extractedField.arguments.map((a) => a.name),
});

return operationStore.put(reg.id, namespace, binding, invocationConfig);
return operationStore.put(reg.id, namespace, binding);
},
{ discard: true },
);
Expand All @@ -386,6 +386,7 @@ export const graphqlPlugin = (options?: {
namespace: config.namespace,
headers: config.headers,
},
invocationConfig,
});

return { sourceId: namespace, toolCount: registrations.length };
Expand Down Expand Up @@ -438,11 +439,11 @@ export const graphqlPlugin = (options?: {

updateSource: (namespace: string, input: GraphqlUpdateSourceInput) =>
Effect.gen(function* () {
const existingConfig = yield* operationStore.getSourceConfig(namespace);
if (!existingConfig) return;
const existing = yield* operationStore.getSource(namespace);
if (!existing) return;

const updatedConfig = {
...existingConfig,
...existing.config,
...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}),
...(input.headers !== undefined
? { headers: input.headers as Record<string, HeaderValueValue> }
Expand All @@ -454,26 +455,11 @@ export const graphqlPlugin = (options?: {
headers: (updatedConfig.headers ?? {}) as Record<string, HeaderValueValue>,
});

const toolIds = yield* operationStore.listByNamespace(namespace);
for (const toolId of toolIds) {
const entry = yield* operationStore.get(toolId);
if (entry) {
yield* operationStore.put(
toolId,
namespace,
entry.binding,
newInvocationConfig,
);
}
}

const sources = yield* operationStore.listSources();
const existingMeta = sources.find((s) => s.namespace === namespace);

yield* operationStore.putSource({
namespace,
name: existingMeta?.name ?? namespace,
name: existing.name,
config: updatedConfig,
invocationConfig: newInvocationConfig,
});
}),
},
Expand Down
6 changes: 5 additions & 1 deletion packages/plugins/graphql/src/sdk/stored-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Schema } from "effect";

import { HeaderValue } from "./types";
import { HeaderValue, InvocationConfig } from "./types";

// ---------------------------------------------------------------------------
// Stored source — the shape persisted by the operation store and exposed
Expand All @@ -16,6 +16,10 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>("Graphq
namespace: Schema.optional(Schema.String),
headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValue })),
}),
// TODO(migration): make required once all rows have been migrated to
// carry invocationConfig. Left optional for decode compat with rows
// written before the source-level invocationConfig refactor.
invocationConfig: Schema.optional(InvocationConfig),
}) {}

export type StoredSourceSchemaType = typeof StoredSourceSchema.Type;
12 changes: 11 additions & 1 deletion packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,17 @@ export const makeOpenApiInvoker = (opts: {
});
}

const { binding, config } = entry;
const source = yield* opts.operationStore.getSource(entry.namespace);
if (!source) {
return yield* new ToolInvocationError({
toolId,
message: `No source found for namespace "${entry.namespace}"`,
cause: undefined,
});
}

const { binding } = entry;
const { invocationConfig: config } = source;
const baseUrl = config.baseUrl;

// Resolve secret-backed headers
Expand Down
71 changes: 64 additions & 7 deletions packages/plugins/openapi/src/sdk/kv-operation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Effect, Schema } from "effect";
import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";

import type { OpenApiOperationStore, StoredOperation, StoredSource } from "./operation-store";
import { OperationBinding, InvocationConfig } from "./types";
import { InvocationConfig, OperationBinding } from "./types";
import { StoredSourceSchema } from "./stored-source";

// ---------------------------------------------------------------------------
Expand All @@ -18,12 +18,23 @@ import { StoredSourceSchema } from "./stored-source";
class StoredEntry extends Schema.Class<StoredEntry>("StoredEntry")({
namespace: Schema.String,
binding: OperationBinding,
config: InvocationConfig,
}) {}

const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));

// TODO(migration): remove LegacyStoredEntry + rehydrateInvocationConfig
// once all source rows have been migrated off the pre-refactor schema.
// Old binding rows inlined the resolved InvocationConfig, which is the
// only place the server-derived baseUrl was persisted.
class LegacyStoredEntry extends Schema.Class<LegacyStoredEntry>("LegacyStoredEntry")({
namespace: Schema.String,
binding: OperationBinding,
config: Schema.optional(InvocationConfig),
}) {}

const decodeLegacyEntry = Schema.decodeUnknownSync(Schema.parseJson(LegacyStoredEntry));

const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema));
const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema));

Expand All @@ -37,22 +48,53 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): OpenApiOperationStore
effect: Effect.Effect<A, E, never>,
): Effect.Effect<A, E, never> => kv.withTransaction?.(effect) ?? effect;

// TODO(migration): remove along with LegacyStoredEntry.
// Rebuild invocationConfig for a source row that predates the refactor.
// We try to recover the resolved baseUrl from any legacy binding row
// (old rows inlined the full InvocationConfig); if none is found, fall
// back to the user-provided baseUrl from the source config.
type DecodedSource = Omit<StoredSource, "invocationConfig"> & {
invocationConfig?: InvocationConfig;
};
const rehydrateInvocationConfig = (
source: DecodedSource,
): Effect.Effect<StoredSource> =>
Effect.gen(function* () {
if (source.invocationConfig) return source as StoredSource;
let recovered: InvocationConfig | null = null;
const entries = yield* bindings.list();
for (const e of entries) {
const legacy = decodeLegacyEntry(e.value);
if (legacy.namespace === source.namespace && legacy.config) {
recovered = legacy.config;
break;
}
}
const invocationConfig =
recovered ??
new InvocationConfig({
baseUrl: source.config.baseUrl ?? "",
headers: source.config.headers ?? {},
});
return { ...source, invocationConfig };
});

return {
get: (toolId) =>
Effect.gen(function* () {
const raw = yield* bindings.get(toolId);
if (!raw) return null;
const entry = decodeEntry(raw);
return { binding: entry.binding, config: entry.config };
return { binding: entry.binding, namespace: entry.namespace };
}),

put: (entries: readonly StoredOperation[]) =>
withKvTransaction(
bindings,
bindings.set(
entries.map(({ toolId, namespace, binding, config }) => ({
entries.map(({ toolId, namespace, binding }) => ({
key: toolId,
value: encodeEntry(new StoredEntry({ namespace, binding, config })),
value: encodeEntry(new StoredEntry({ namespace, binding })),
})),
),
),
Expand Down Expand Up @@ -89,14 +131,29 @@ const makeStore = (bindings: ScopedKv, sources: ScopedKv): OpenApiOperationStore
listSources: () =>
Effect.gen(function* () {
const entries = yield* sources.list();
return entries.map((e) => decodeSource(e.value) as StoredSource);
const out: StoredSource[] = [];
for (const e of entries) {
const raw = decodeSource(e.value) as DecodedSource;
// TODO(migration): rehydrate in memory only — avoid N writes per list.
out.push(
raw.invocationConfig
? (raw as StoredSource)
: yield* rehydrateInvocationConfig(raw),
);
}
return out;
}),

getSource: (namespace) =>
Effect.gen(function* () {
const raw = yield* sources.get(namespace);
if (!raw) return null;
return decodeSource(raw) as StoredSource;
const source = decodeSource(raw) as DecodedSource;
if (source.invocationConfig) return source as StoredSource;
// TODO(migration): self-heal — rehydrate and write back once.
const healed = yield* rehydrateInvocationConfig(source);
yield* sources.set([{ key: namespace, value: encodeSource(healed) }]);
return healed;
}),

getSourceConfig: (namespace) =>
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/openapi/src/sdk/operation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,20 @@ export interface StoredSource {
readonly namespace: string;
readonly name: string;
readonly config: SourceConfig;
/** Pre-resolved runtime invocation config (baseUrl resolved from servers, headers). */
readonly invocationConfig: InvocationConfig;
}

export interface StoredOperation {
readonly toolId: ToolId;
readonly namespace: string;
readonly binding: OperationBinding;
readonly config: InvocationConfig;
}

export interface OpenApiOperationStore {
readonly get: (
toolId: ToolId,
) => Effect.Effect<{ binding: OperationBinding; config: InvocationConfig } | null>;
) => Effect.Effect<{ binding: OperationBinding; namespace: string } | null>;

readonly put: (entries: readonly StoredOperation[]) => Effect.Effect<void>;

Expand Down
Loading
Loading