/** * What a handler returns becomes the action's `results`, and the platform * validates results against the action specification's results schema — for * the default create/update actions, the specification's own attributes. A * rejected transition used to be swallowed, leaving the action `in_progress` * forever with the work already done. So the SDK checks the shape here first, * names what is wrong, and never hides the platform's answer. */ import { ActionError } from "./handler"; type Schema = { type?: string | string[]; properties?: Record; required?: string[]; additionalProperties?: boolean | Schema; items?: Schema; enum?: unknown[]; }; const typeOf = (v: unknown): string => v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? Number.isInteger(v) ? "integer" : "number" : typeof v; const accepts = ( declared: string | string[] | undefined, actual: string ): boolean => { if (!declared) return true; const list = Array.isArray(declared) ? declared : [declared]; return list.some( (t) => t === actual || (t === "number" && actual === "integer") ); }; /** Problems with `value` against `schema`; empty when it fits. Structural, not exhaustive. */ export function schemaProblems( schema: Schema | undefined, value: unknown, path = "results" ): string[] { if (!schema) return []; const problems: string[] = []; const actual = typeOf(value); if (!accepts(schema.type, actual)) { problems.push( `${path}: expected ${ Array.isArray(schema.type) ? schema.type.join("|") : schema.type }, got ${actual}` ); return problems; } if (schema.enum && !schema.enum.some((e) => e === value)) { problems.push( `${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify( schema.enum )}` ); } if (actual === "object" && schema.properties) { const obj = value as Record; if (schema.additionalProperties === false) { for (const k of Object.keys(obj)) if (!(k in schema.properties)) problems.push(`${path}.${k}: not in the schema`); } for (const [k, sub] of Object.entries(schema.properties)) { if (k in obj && obj[k] !== undefined) problems.push(...schemaProblems(sub, obj[k], `${path}.${k}`)); } } if (actual === "array" && schema.items) { (value as unknown[]).forEach((item, i) => problems.push(...schemaProblems(schema.items, item, `${path}[${i}]`)) ); } return problems; } /** * Results for a default create/update must be attributes: every key one the * specification declares (the platform rejects anything else), each of its * declared type. Custom actions are checked against their results schema. */ export function validateResults( schema: Schema | undefined, results: unknown, strictKeys: boolean ): void { if (results === undefined || results === null) return; if (typeof results !== "object" || Array.isArray(results)) { throw new ActionError( "INVALID_RESULTS", `results must be an object, got ${typeOf(results)}` ); } if (!schema) return; const problems = schemaProblems( { ...schema, additionalProperties: strictKeys ? false : schema.additionalProperties, }, results ); if (problems.length) { throw new ActionError( "INVALID_RESULTS", `results do not fit the ${ strictKeys ? "attributes" : "results" } schema: ${problems.join("; ")}` ); } }