Add schema-backed parameter spec accessors

This commit is contained in:
elpresidank 2026-06-02 03:10:43 -05:00
parent b51dc33786
commit abb6f3aed0
6 changed files with 187 additions and 38 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Duration, Effect, Fiber } from "effect";
import * as S from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import {
makeConsumerSpec,
@ -266,12 +267,14 @@ describe("Effect-native flow specifications", () => {
"returns typed errors for missing flow resources",
Effect.fnUntraced(function* () {
const backend = new RuntimeBackend(new ScriptedConsumer<unknown>());
const presentParameter = makeParameterSpec("present", S.Number);
const invalidParameter = makeParameterSpec("present", S.String);
const flow = new Flow(
"default",
"processor",
backend,
{ parameters: { present: 42 } },
[makeParameterSpec("present")],
[presentParameter],
);
const errors = yield* Effect.scoped(
@ -280,19 +283,27 @@ describe("Effect-native flow specifications", () => {
Effect.gen(function* () {
yield* flow.startEffect();
const producerError = yield* flow.producerEffect<string>("missing-producer").pipe(Effect.flip);
const parameter = yield* flow.parameterEffect<number>("present");
const parameterError = yield* flow.parameterEffect<number>("missing-parameter").pipe(Effect.flip);
return { producerError, parameter, parameterError };
const parameter = yield* flow.parameterEffect(presentParameter);
const legacyParameter = yield* flow.parameterEffect("present");
const parameterError = yield* flow.parameterEffect("missing-parameter").pipe(Effect.flip);
const invalidParameterError = yield* flow.parameterEffect(invalidParameter).pipe(Effect.flip);
return { producerError, parameter, legacyParameter, parameterError, invalidParameterError };
}),
),
);
expect(errors.parameter).toBe(42);
expect(errors.legacyParameter).toBe(42);
expect(errors.producerError._tag).toBe("FlowResourceNotFoundError");
expect(errors.producerError.resourceType).toBe("producer");
expect(errors.producerError.resourceName).toBe("missing-producer");
expect(errors.parameterError._tag).toBe("FlowResourceNotFoundError");
expect(errors.parameterError.resourceType).toBe("parameter");
expect(errors.invalidParameterError._tag).toBe("FlowParameterDecodeError");
expect(errors.invalidParameterError.parameterName).toBe("present");
expect(flow.parameter(presentParameter)).toBe(42);
expect(flow.parameter("present")).toBe(42);
expect(() => flow.parameter(invalidParameter)).toThrow("failed schema decoding");
expect(() => flow.producer("missing-producer")).toThrow("not found");
}),
);

View file

@ -140,6 +140,15 @@ export class FlowResourceNotFoundError extends S.TaggedErrorClass<FlowResourceNo
},
) {}
export class FlowParameterDecodeError extends S.TaggedErrorClass<FlowParameterDecodeError>()(
"FlowParameterDecodeError",
{
message: S.String,
flowName: S.String,
parameterName: S.String,
},
) {}
export type TrustGraphError =
| TooManyRequestsError
| LlmError
@ -155,6 +164,7 @@ export type TrustGraphError =
| MessagingTimeoutError
| MessagingHandlerError
| FlowRuntimeError
| FlowParameterDecodeError
| FlowResourceNotFoundError;
export type MessagingRuntimeError =
@ -165,6 +175,7 @@ export type MessagingRuntimeError =
| MessagingTimeoutError
| MessagingHandlerError
| FlowRuntimeError
| FlowParameterDecodeError
| FlowResourceNotFoundError;
export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError {
@ -291,6 +302,18 @@ export function flowResourceNotFoundError(
});
}
export function flowParameterDecodeError(
flowName: string,
parameterName: string,
error: unknown,
): FlowParameterDecodeError {
return FlowParameterDecodeError.make({
flowName,
parameterName,
message: `parameter "${parameterName}" in flow "${flowName}" failed schema decoding: ${errorMessage(error)}`,
});
}
export function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message;

View file

@ -5,10 +5,14 @@
*/
import { Context, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import type { PubSubBackend } from "../backend/types.js";
import { makePubSubService } from "../backend/pubsub.js";
import {
flowParameterDecodeError,
flowResourceNotFoundError,
type FlowParameterDecodeError,
type FlowResourceNotFoundError,
type PubSubError,
} from "../errors.js";
@ -25,6 +29,7 @@ import {
makeRequestResponseFactoryService,
} from "../messaging/runtime.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import type { ParameterSpec } from "../spec/parameter-spec.js";
import type { Spec, SpecRuntimeRequirements } from "../spec/types.js";
export interface FlowDefinition {
@ -57,6 +62,8 @@ export interface FlowRequestor<TReq, TRes> {
readonly stop: () => Promise<void>;
}
type FlowParameterError = FlowResourceNotFoundError | FlowParameterDecodeError;
export function makeFlow<Requirements = never>(
name: string,
processorId: string,
@ -97,6 +104,60 @@ export function makeFlow<Requirements = never>(
};
};
const getParameterEffect = (parameterName: string): Effect.Effect<unknown, FlowResourceNotFoundError> => {
const value = parameters.get(parameterName);
return value === undefined
? Effect.fail(flowResourceNotFoundError(name, "parameter", parameterName))
: Effect.succeed(value);
};
const getParameter = (parameterName: string): unknown => {
const value = parameters.get(parameterName);
if (value === undefined) throw flowResourceNotFoundError(name, "parameter", parameterName);
return value;
};
const decodeParameterEffect = <T>(
spec: ParameterSpec<T>,
value: unknown,
): Effect.Effect<T, FlowParameterDecodeError> =>
S.decodeUnknownEffect(spec.schema)(value).pipe(
Effect.mapError((error) => flowParameterDecodeError(name, spec.name, error)),
);
const decodeParameter = <T>(spec: ParameterSpec<T>, value: unknown): T => {
const decoded = S.decodeUnknownOption(spec.schema)(value);
if (O.isSome(decoded)) return decoded.value;
throw flowParameterDecodeError(name, spec.name, "Parameter value does not match schema");
};
function parameterEffect<T>(
parameterSpec: ParameterSpec<T>,
): Effect.Effect<T, FlowParameterError>;
function parameterEffect(
parameterName: string,
): Effect.Effect<unknown, FlowResourceNotFoundError>;
function parameterEffect<T>(
parameter: string | ParameterSpec<T>,
): Effect.Effect<unknown, FlowParameterError> {
if (typeof parameter === "string") {
return getParameterEffect(parameter);
}
return getParameterEffect(parameter.name).pipe(
Effect.flatMap((value) => decodeParameterEffect(parameter, value)),
);
}
function parameter<T>(parameterSpec: ParameterSpec<T>): T;
function parameter(parameterName: string): unknown;
function parameter<T>(parameter: string | ParameterSpec<T>): unknown {
const value = getParameter(typeof parameter === "string" ? parameter : parameter.name);
if (typeof parameter === "string") {
return value;
}
return decodeParameter(parameter, value);
}
const flow = {
name,
processorId,
@ -198,12 +259,7 @@ export function makeFlow<Requirements = never>(
? Effect.fail(flowResourceNotFoundError(name, "requestor", requestorName))
: Effect.succeed(rr as EffectRequestResponse<TReq, TRes>);
},
parameterEffect<T>(parameterName: string): Effect.Effect<T, FlowResourceNotFoundError> {
const v = parameters.get(parameterName);
return v === undefined
? Effect.fail(flowResourceNotFoundError(name, "parameter", parameterName))
: Effect.succeed(v as T);
},
parameterEffect,
producer<T>(producerName: string): FlowProducer<T> {
const p = producers.get(producerName);
if (p === undefined) throw flowResourceNotFoundError(name, "producer", producerName);
@ -234,11 +290,7 @@ export function makeFlow<Requirements = never>(
stop: () => compatibilityRuntime.runPromise(rr.stop),
};
},
parameter<T>(parameterName: string): T {
const v = parameters.get(parameterName);
if (v === undefined) throw flowResourceNotFoundError(name, "parameter", parameterName);
return v as T;
},
parameter,
};
return flow;

View file

@ -4,13 +4,31 @@
* Python reference: trustgraph-base/trustgraph/base/parameter_spec.py
*/
import { Effect } from "effect";
import { Effect, type Context } from "effect";
import * as S from "effect/Schema";
import type { PubSubBackend } from "../backend/types.js";
import type { Spec } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
export interface ParameterSpec extends Spec {}
declare const ParameterSpecType: unique symbol;
export function makeParameterSpec(name: string): ParameterSpec {
const UnknownParameterSchema: S.Codec<unknown, unknown> = S.Unknown;
export interface ParameterSpec<T = unknown> extends Spec {
readonly [ParameterSpecType]?: (_: T) => T;
readonly schema: S.Codec<T, unknown>;
}
export function makeParameterSpec(name: string): ParameterSpec<unknown>;
export function makeParameterSpec<T>(
name: string,
schema: S.Codec<T, unknown>,
): ParameterSpec<T>;
export function makeParameterSpec<T>(
name: string,
schema?: S.Codec<T, unknown>,
) {
const parameterSchema = schema ?? UnknownParameterSchema;
const addEffect = (flow: Flow, definition: FlowDefinition) =>
Effect.sync(() => {
const value = definition.parameters?.[name];
@ -19,8 +37,14 @@ export function makeParameterSpec(name: string): ParameterSpec {
return {
name,
schema: parameterSchema,
addEffect,
add: (flow, pubsub, definition, context) =>
add: (
flow: Flow,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<never>,
) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}