117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
import { Either, Schema } from "effect";
|
|
import type * as AST from "effect/SchemaAST";
|
|
import {
|
|
workflowRuntimeContract,
|
|
type WorkflowOperationParams,
|
|
type WorkflowOperationResult,
|
|
} from "../generated/workflow-contract.js";
|
|
import { translateJsonSchema } from "./translator.js";
|
|
import { hasBoundedInputExpressionsAtSchema } from "./input-expression-limits.js";
|
|
|
|
type RuntimeOperationName = keyof typeof workflowRuntimeContract.operations;
|
|
const MAX_RUNTIME_VALUE_DEPTH = 64;
|
|
|
|
type RuntimeValueFrame =
|
|
| { readonly kind: "enter"; readonly depth: number; readonly value: unknown }
|
|
| { readonly kind: "leave"; readonly value: object };
|
|
|
|
export interface RuntimeOperationSchemas<Name extends RuntimeOperationName> {
|
|
readonly payload: Schema.Schema<WorkflowOperationParams<Name>, unknown, never>;
|
|
readonly success: Schema.Schema<WorkflowOperationResult<Name>, unknown, never>;
|
|
}
|
|
|
|
const hasBoundedRuntimeValueDepth = (input: unknown): boolean => {
|
|
const pending: RuntimeValueFrame[] = [{ kind: "enter", depth: 0, value: input }];
|
|
// Track only the current ancestor chain so shared acyclic values remain valid.
|
|
const active = new WeakSet<object>();
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
if (current === undefined) break;
|
|
if (current.kind === "leave") {
|
|
active.delete(current.value);
|
|
continue;
|
|
}
|
|
if (typeof current.value !== "object" || current.value === null) continue;
|
|
if (current.depth >= MAX_RUNTIME_VALUE_DEPTH) return false;
|
|
if (active.has(current.value)) return false;
|
|
active.add(current.value);
|
|
pending.push({ kind: "leave", value: current.value });
|
|
for (const value of Object.values(current.value)) {
|
|
pending.push({ kind: "enter", depth: current.depth + 1, value });
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const BoundedRuntimeValueSchema = Schema.Unknown.pipe(
|
|
Schema.filter(hasBoundedRuntimeValueDepth, {
|
|
message: () =>
|
|
`runtime value exceeds ${MAX_RUNTIME_VALUE_DEPTH} nested containers`,
|
|
}),
|
|
);
|
|
|
|
const translatedAst = (schema: unknown): AST.AST => {
|
|
const translated = translateJsonSchema(schema, {
|
|
components: workflowRuntimeContract.components,
|
|
});
|
|
if (Either.isLeft(translated)) {
|
|
throw new Error(
|
|
`checked runtime schema failed at ${translated.left.path}: ${translated.left.message}`,
|
|
);
|
|
}
|
|
|
|
return translated.right.ast;
|
|
};
|
|
|
|
const boundedRuntimeInputFor = (operationSchema: unknown) =>
|
|
BoundedRuntimeValueSchema.pipe(
|
|
Schema.filter(
|
|
(value) =>
|
|
hasBoundedInputExpressionsAtSchema(
|
|
value,
|
|
operationSchema,
|
|
workflowRuntimeContract.components,
|
|
),
|
|
{
|
|
message: () =>
|
|
"runtime value contains an input expression over the 1024-node budget",
|
|
},
|
|
),
|
|
);
|
|
|
|
const payloadSchemaFor = <Name extends RuntimeOperationName>(
|
|
name: Name,
|
|
): Schema.Schema<WorkflowOperationParams<Name>, unknown, never> => {
|
|
// The AST and payload type are generated from the same checked operation.
|
|
const schema = Schema.make<WorkflowOperationParams<Name>, unknown, never>(
|
|
translatedAst(workflowRuntimeContract.operations[name].payload),
|
|
);
|
|
return Schema.compose(
|
|
boundedRuntimeInputFor(workflowRuntimeContract.operations[name].payload),
|
|
schema,
|
|
);
|
|
};
|
|
|
|
const successSchemaFor = <Name extends RuntimeOperationName>(
|
|
name: Name,
|
|
): Schema.Schema<WorkflowOperationResult<Name>, unknown, never> => {
|
|
// The AST and result type are generated from the same checked operation.
|
|
const schema = Schema.make<WorkflowOperationResult<Name>, unknown, never>(
|
|
translatedAst(workflowRuntimeContract.operations[name].success),
|
|
);
|
|
return Schema.compose(
|
|
boundedRuntimeInputFor(workflowRuntimeContract.operations[name].success),
|
|
schema,
|
|
);
|
|
};
|
|
|
|
/** Returns fail-fast Effect schemas for one parity-verified authored RPC. */
|
|
export const runtimeSchemasFor = <Name extends RuntimeOperationName>(
|
|
name: Name,
|
|
): RuntimeOperationSchemas<Name> => {
|
|
return {
|
|
payload: payloadSchemaFor(name),
|
|
success: successSchemaFor(name),
|
|
};
|
|
};
|