From 82833d35d0c5909a8bfdfc143101b939b738ee6d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 3 Oct 2026 14:30:00 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Export=20dynamic=20and=20validate?= =?UTF-8?q?=20resume=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 +++-- docs/insights.md | 9 +- lib/checkpoint.ts | 32 +++- lib/dynamic.ts | 48 +++-- lib/parse.ts | 17 +- lib/pipeline.ts | 2 +- lib/types.ts | 10 +- mod.ts | 1 + test/argument.test.ts | 16 +- test/checkpoint.test.ts | 63 +++++++ test/dynamic.test.ts | 404 +++++++++++++++++++++++++--------------- test/env.test.ts | 48 ++--- test/fold.test.ts | 36 ++-- test/multiple.test.ts | 4 +- test/transform.test.ts | 2 +- test/value.test.ts | 41 ++-- 16 files changed, 512 insertions(+), 270 deletions(-) create mode 100644 test/checkpoint.test.ts diff --git a/README.md b/README.md index 7036e18..94281a9 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,32 @@ Dynamic phases serve two common cases: route-extension mechanism. Both keep I/O in the application while preserving the exact type of what parsing can produce next. +`dynamic(schema, extension)` requires a Standard Schema for the value supplied +to `resume()`. The schema's input type determines what `resume()` accepts; its +validated output is passed to the extension, including any schema transforms. + +```ts +import { command, dynamic, name, parse, routes } from "@bomb.sh/router"; +import * as z from "zod"; + +const app = command( + name("plugins"), + dynamic( + z.array(z.string()), + (plugins) => routes(...plugins.map((plugin) => command(name(plugin)))), + ), +); + +const step = parse(app, { argv: ["serve"] }); +if (step.ok) { + const result = step.resume(["serve"]); // EXECUTE /serve +} +``` + +Invalid input produces `unprocessable-content` with the schema's issues and +paths, without applying the extension. Validation must be synchronous; async +schemas also produce `unprocessable-content`. + ### Help and version cross checkpoints `--help` and `--version` request methods; they do not settle an intent or bypass @@ -216,12 +242,12 @@ app --config app.json auth0 --help Help and version are not escape hatches around configuration loading. Do not inspect `argv` to skip a checkpoint. A phase may be required by `HELP`, -`VERSION`, or `EXECUTE`, so its driver work must be safe for all three: return -loading and validation failures as `Result` issues, avoid command side effects, -and defer execution until an `EXECUTE` intent. If discovery fails, report that -failure rather than printing incomplete help for an unresolved route graph. -Routes without dynamic phases still resolve directly; the rule is to stop only -at an intent or failure, never merely because the arguments look informational. +`VERSION`, or `EXECUTE`, so its driver work must be safe for all three: handle +loading failures in the application, avoid command side effects, and defer +execution until an `EXECUTE` intent. If discovery fails, report that failure +rather than printing incomplete help for an unresolved route graph. Routes +without dynamic phases still resolve directly; the rule is to stop only at an +intent or failure, never merely because the arguments look informational. ```ts import process from "node:process"; @@ -234,7 +260,6 @@ import { printErrors, printHelp, printVersion, - type Result, schema, type ValueSource, version, @@ -280,13 +305,15 @@ switch (result.method) { break; } -declare function load(path: string): Promise>; +declare function load(path: string): Promise; ``` The parser remains synchronous and performs no I/O. The caller loads the file -and resumes with a `Result`; loader failures enter the ordinary issue path. -Unconsumed CLI input survives the pause, so a later `--port 5000` can override -the value loaded from the file. +and resumes directly with a value-source array. `checkpoint()` validates the +source names and the presence of their values; later parameter schemas validate +the contents. The application handles I/O failures before resuming. Unconsumed +CLI input survives the pause, so a later `--port 5000` can override the value +loaded from the file. The same phase mechanism can add options or routes from runtime data. Parsing then continues against the expanded route graph, and the continuation type diff --git a/docs/insights.md b/docs/insights.md index bf43f5a..8ab7859 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -114,9 +114,12 @@ The detailed phase-binding design is recorded in [Binding](./binding.md). - A dynamic resolver returns an extension at its current pipeline position. - The extension's return type determines the continuation type. - `resume()` continues parsing; it never exposes the intermediate route. -- `resume()` accepts `Result`, allowing loader failures through the - ordinary issue path. -- A failed requirement never invokes the resolver. +- `dynamic(schema, extension)` requires a Standard Schema for its requirement. +- `resume()` accepts the schema's input directly; synchronous validation + supplies the schema's output to the extension, including transforms. +- An invalid requirement returns `unprocessable-content` with the schema's + issues and never invokes the extension. Async schemas are rejected through the + same issue path; the application handles I/O failures before resuming. - `RequirementsOf` preserves requirement order; `RequirementOf` is its head. - `ContinuationOf` settles the current unresolved phase and retains it as diff --git a/lib/checkpoint.ts b/lib/checkpoint.ts index a5175ec..b6b20a1 100644 --- a/lib/checkpoint.ts +++ b/lib/checkpoint.ts @@ -1,10 +1,40 @@ import { dynamic } from "./dynamic.ts"; import type { DynamicElement } from "./pipeline.ts"; +import type { Issue, Schema } from "./types.ts"; import { type ValueSource, withValues } from "./values.ts"; export function checkpoint(): DynamicElement< ValueSource[], ReturnType > { - return dynamic((values: ValueSource[]) => withValues(values)); + return dynamic(sources, (values) => withValues(values)); } + +const sources: Schema = { + "~standard": { + version: 1, + vendor: "@bomb.sh/router", + validate(value) { + if (!Array.isArray(value)) { + return { issues: [{ message: "expected an array of value sources" }] }; + } + + let issues: Issue[] = []; + for (let [index, source] of value.entries()) { + if (source === null || typeof source !== "object") { + issues.push({ message: "expected a value source", path: [index] }); + continue; + } + + if (typeof source.name !== "string") { + issues.push({ message: "expected a string", path: [index, "name"] }); + } + if (!("value" in source)) { + issues.push({ message: "expected a value", path: [index, "value"] }); + } + } + + return issues.length > 0 ? { issues } : { value: value as ValueSource[] }; + }, + }, +}; diff --git a/lib/dynamic.ts b/lib/dynamic.ts index 29578bf..c282e00 100644 --- a/lib/dynamic.ts +++ b/lib/dynamic.ts @@ -1,27 +1,53 @@ -import type { AnyRoute } from "./types.ts"; -import { type AnyElement, brand, type DynamicElement } from "./pipeline.ts"; +import type { AnyRoute, Schema } from "./types.ts"; +import { + type AnyElement, + brand, + type DynamicElement, + type Element, +} from "./pipeline.ts"; export type { ConjoinPhases, Seed } from "./pipeline.ts"; export type PhaseOf = R["phases"][0]; export type PhasesOf = R["phases"]; +// Constrain only the brand while inferring E. The full operation would +// contextually widen extend() tuples and erase their continuation types. export function dynamic< - Requirement, - E, + Input, + Output, + E extends Element, >( - extension: (requires: Requirement) => E, - ..._valid: E extends AnyElement ? [] : [never] -): DynamicElement> { - return brand>>( + schema: Schema, + extension: (requires: Output) => E, +): DynamicElement> { + return brand>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; phases.push({ ...phase, - resolver: extension as unknown as ( - requirement: never, - ) => (input: never) => AnyRoute, + resolver(requirement: never) { + let result = schema["~standard"].validate(requirement); + + if (result instanceof Promise) { + return { + ok: false, + issues: [{ message: "async schemas are not allowed" }], + }; + } + + if (result.issues) { + return { ok: false, issues: result.issues }; + } + + return { + ok: true, + value: extension(result.value) as unknown as ( + input: never, + ) => AnyRoute, + }; + }, }); phases.push({ model: { diff --git a/lib/parse.ts b/lib/parse.ts index 0b3a731..fe99124 100644 --- a/lib/parse.ts +++ b/lib/parse.ts @@ -168,20 +168,21 @@ function advance( route: segment.id, model: binding.model, - resume(result) { - if (!result.ok) { - return unprocessableContent(segment, result.issues); - } - + resume(value) { // AnyPhase erases these exact types with `never`; dynamic() already // proved them at its public boundary. let resolver = phase.resolver as unknown as ( requirement: unknown, - ) => (route: AnyRoute) => AnyRoute; + ) => Result<(route: AnyRoute) => AnyRoute>; + + let result = resolver(value); + if (!result.ok) { + return unprocessableContent(segment, result.issues); + } // The extension operates against the same aggregate route metadata, // but begins with one fresh, empty phase. - let continuation = resolver(result.value)( + let continuation = result.value( seed(segment.route), ); let phases = stitch( @@ -260,7 +261,7 @@ interface AnyIncrement { readonly ok: true; readonly route: RoutePath; readonly model: object; - resume(result: Result): Outcome; + resume(value: unknown): Outcome; } interface Segment { diff --git a/lib/pipeline.ts b/lib/pipeline.ts index 8d2ae77..a84a812 100644 --- a/lib/pipeline.ts +++ b/lib/pipeline.ts @@ -11,7 +11,7 @@ import type { Route, } from "./types.ts"; -export type Element = { +export type Element = { readonly [operation]: D; }; diff --git a/lib/types.ts b/lib/types.ts index c0b0dc2..53f77e1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -54,7 +54,7 @@ export type Next< readonly envs: readonly EnvSource[]; readonly resolver: ( requirement: T, - ) => (input: AnyRoute) => AnyRoute; + ) => Result<(input: AnyRoute) => AnyRoute>; }; export type Done< @@ -86,7 +86,7 @@ export interface ParseIncrement< readonly model: IncrementModelOf; resume( - result: Result>, + value: RequirementOf, ): Outcome, P, Models>>; } @@ -127,7 +127,9 @@ export interface AnyPhase { readonly routes: readonly AnyRoute[]; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; - readonly resolver?: (requirement: never) => (route: never) => AnyRoute; + readonly resolver?: ( + requirement: never, + ) => Result<(route: never) => AnyRoute>; } export type AnyPhases = readonly [AnyPhase, ...AnyPhase[]]; @@ -304,7 +306,7 @@ type NextModelIn

= P extends readonly [ type RequirementIn

= P extends { readonly resolver: ( requirement: infer Requirement, - ) => (route: AnyRoute) => AnyRoute; + ) => Result<(route: AnyRoute) => AnyRoute>; } ? Requirement : never; diff --git a/mod.ts b/mod.ts index 6db3300..a2884ea 100644 --- a/mod.ts +++ b/mod.ts @@ -5,6 +5,7 @@ export { command } from "./lib/command.ts"; export type { CommandZero } from "./lib/command.ts"; export { checkpoint } from "./lib/checkpoint.ts"; +export { dynamic } from "./lib/dynamic.ts"; export { description, name } from "./lib/definition.ts"; diff --git a/test/argument.test.ts b/test/argument.test.ts index 196e089..198c947 100644 --- a/test/argument.test.ts +++ b/test/argument.test.ts @@ -314,11 +314,10 @@ describe("argument()", () => { let app = command( name("app"), option(name("config"), schema(type("string"))), - dynamic(() => + dynamic(type("unknown"), () => extend( argument(name("input"), schema(type("string"))), - ) - ), + )), ); let increment = parse(app, { argv: ["--config", "app.json", "input.txt"], @@ -331,7 +330,7 @@ describe("argument()", () => { }); assertIncrement(increment); - let result = increment.resume({ ok: true, value: undefined }); + let result = increment.resume(undefined); expect(result).toMatchObject({ ok: true, method: "execute", @@ -344,11 +343,10 @@ describe("argument()", () => { let app = command( name("app"), argument(name("target"), schema(type("string"))), - dynamic(() => + dynamic(type("unknown"), () => extend( routes(command(name("auth0"))), - ) - ), + )), ); let increment = parse(app, { argv: ["auth0"] }); @@ -359,7 +357,7 @@ describe("argument()", () => { }); assertIncrement(increment); - let result = increment.resume({ ok: true, value: undefined }); + let result = increment.resume(undefined); expect(result).toMatchObject({ ok: true, method: "execute", @@ -379,7 +377,7 @@ function expectType(_value: T): void {} function assertIncrement( result: unknown, ): asserts result is { - resume(result: { readonly ok: true; readonly value: undefined }): unknown; + resume(value: undefined): unknown; } { expect(result).toMatchObject({ ok: true }); expect( diff --git a/test/checkpoint.test.ts b/test/checkpoint.test.ts new file mode 100644 index 0000000..03a0290 --- /dev/null +++ b/test/checkpoint.test.ts @@ -0,0 +1,63 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import * as z from "zod"; +import { checkpoint, command, name, option, parse, schema } from "../mod.ts"; + +describe("checkpoint()", () => { + it("validates sources and binds their values after resuming", () => { + let app = command( + name("server"), + checkpoint(), + option(name("port"), schema(z.number())), + ); + let first = parse(app, { argv: [] }); + if (!first.ok) throw new Error("expected increment"); + + expect(first.resume([{ name: "config", value: { port: 4100 } }])) + .toMatchObject({ + ok: true, + method: "execute", + model: { port: 4100 }, + }); + }); + + it("reports malformed sources through the ordinary issue path", () => { + let app = command(name("server"), checkpoint()); + let first = parse(app, { argv: [] }); + if (!first.ok) throw new Error("expected increment"); + + // @ts-expect-error JavaScript callers can supply invalid source arrays. + expect(first.resume(null)).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ message: "expected an array of value sources" }], + }); + + // @ts-expect-error JavaScript callers can supply invalid source entries. + expect(first.resume([null, { name: 123 }])).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [ + { message: "expected a value source", path: [0] }, + { message: "expected a string", path: [1, "name"] }, + { message: "expected a value", path: [1, "value"] }, + ], + }); + }); + + it("accepts an explicit undefined source value", () => { + let app = command( + name("server"), + checkpoint(), + option(name("port"), schema(z.number().default(4100))), + ); + let first = parse(app, { argv: [] }); + if (!first.ok) throw new Error("expected increment"); + + expect(first.resume([{ name: "config", value: undefined }])).toMatchObject({ + ok: true, + method: "execute", + model: { port: 4100 }, + }); + }); +}); diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index fcb2fd2..9ef04b2 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -1,15 +1,12 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { type } from "arktype"; +import * as z from "zod"; +import { dynamic } from "../mod.ts"; import { command } from "../lib/command.ts"; import { checkpoint } from "../lib/checkpoint.ts"; import { name } from "../lib/definition.ts"; -import { - type ConjoinPhases, - dynamic, - type PhasesOf, - type Seed, -} from "../lib/dynamic.ts"; +import type { ConjoinPhases, PhasesOf, Seed } from "../lib/dynamic.ts"; import { extend } from "../lib/extend.ts"; import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; @@ -33,9 +30,17 @@ import type { RequirementsOf, Route, RoutePath, + Schema, Version, } from "../lib/types.ts"; import { toggle } from "../lib/toggle.ts"; +import { withValues } from "../lib/values.ts"; + +const config: Schema = type({ services: "string[]" }); + +const plugins: Schema = type({ names: "string[]" }); + +const frontmatter: Schema = type({ properties: "string[]" }); describe("dynamic()", () => { it("starts its extension with a fresh phase", () => { @@ -86,11 +91,14 @@ describe("dynamic()", () => { expectType>(true); }); - it("infers its requirements from the resolver parameters", () => { + it("infers its requirements and resolver input from the schema", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => extend()), + dynamic(config, (value) => { + expectType>(true); + return extend(); + }), ); expectType, readonly [Config]>>(true); @@ -100,11 +108,10 @@ describe("dynamic()", () => { it("collects recursive requirements in resume order", () => { let app = command( name("simulacrum"), - dynamic((_config: Config) => + dynamic(config, (_config: Config) => extend( - dynamic((_plugins: Plugins) => extend()), - ) - ), + dynamic(plugins, (_plugins: Plugins) => extend()), + )), ); type Next = ContinuationOf; @@ -117,16 +124,58 @@ describe("dynamic()", () => { expectType, Plugins>>(true); }); + it("accepts schema input and passes transformed output to its extension", () => { + let app = command( + name("server"), + dynamic(z.string().transform(Number), (port) => { + expectType>(true); + return extend( + withValues([{ name: "settings", value: { port } }]), + option(name("port"), schema(z.number())), + ); + }), + ); + + expectType, string>>(true); + expectType, { port: number }>>(true); + + let first = parse(app, { argv: [] }); + assertIncrement(first, {}); + + check(() => { + // @ts-expect-error resume accepts the schema input, not its output. + first.resume(4100); + // @ts-expect-error resume accepts a value directly. + first.resume({ ok: true, value: "4100" }); + }); + + let result = first.resume("4100"); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { port: 4100 }, + }); + }); + + it("requires a schema and a resolver accepting its output", () => { + check(() => { + // @ts-expect-error every dynamic requirement must have a schema. + dynamic((_value: string) => extend()); + // @ts-expect-error the resolver receives the schema's output type. + dynamic(z.string().transform(Number), (_value: string) => extend()); + }); + }); + it("infers its continuation from the returned extension", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => + dynamic(config, (_config: Config) => extend( option(name("port"), schema(type("number"))), option(name("domain"), schema(type("string"))), - ) - ), + )), ); type Next = ContinuationOf; @@ -156,7 +205,10 @@ describe("dynamic()", () => { type Plugin = typeof plugin; let app = command( name("simulacrum"), - dynamic((plugin: Plugin) => extend(routes(plugin))), + dynamic( + z.custom((value) => value === plugin), + (plugin: Plugin) => extend(routes(plugin)), + ), ); type Next = ContinuationOf; @@ -169,7 +221,7 @@ describe("dynamic()", () => { }); assertIncrement(first, {}); - let result = first.resume({ ok: true, value: plugin }); + let result = first.resume(plugin); expect(result).toMatchObject({ ok: true, method: "execute", @@ -179,9 +231,16 @@ describe("dynamic()", () => { }); it("preserves the resume boundary for runtime-sized command extensions", () => { + let serve = command(name("serve")); + let commands: Schema = z.object({ + commands: z.array(z.custom((value) => value === serve)), + }); let app = command( name("simulacrum"), - dynamic((plugins: PluginSet) => extend(routes(...plugins.commands))), + dynamic( + commands, + (plugins: PluginSet) => extend(routes(...plugins.commands)), + ), ); expectType, PluginSet>>(true); @@ -190,10 +249,7 @@ describe("dynamic()", () => { let first = parse(app, { argv: ["serve"] }); assertIncrement(first, {}); - let result = first.resume({ - ok: true, - value: { commands: [command(name("serve"))] }, - }); + let result = first.resume({ commands: [serve] }); expect(result).toMatchObject({ ok: true, method: "execute", @@ -209,12 +265,14 @@ describe("dynamic()", () => { let app = command( name("xmd"), option(name("path"), schema(type("string"))), - dynamic((declared: readonly string[]) => - extend( - ...declared.map((property) => - option(name(property), schema(type("string"))) + dynamic( + z.array(z.string()).readonly(), + (declared: readonly string[]) => + extend( + ...declared.map((property) => + option(name(property), schema(type("string"))) + ), ), - ) ), option(name("raw"), schema(type("string | undefined"))), routes(serve), @@ -237,7 +295,7 @@ describe("dynamic()", () => { }); assertIncrement(first, { path: "doc.md" }); - let result = first.resume({ ok: true, value: ["author"] }); + let result = first.resume(["author"]); expect(result).toMatchObject({ ok: true, model: { path: "doc.md", author: "Ada", raw: "true" }, @@ -267,8 +325,10 @@ describe("dynamic()", () => { cli(["--file"]), schema(type("string")), ), - dynamic((frontmatter: Frontmatter) => - extend(...frontmatter.properties.map(generated)) + dynamic( + frontmatter, + (frontmatter: Frontmatter) => + extend(...frontmatter.properties.map(generated)), ), option( name("raw"), @@ -285,10 +345,7 @@ describe("dynamic()", () => { if (!step.ok || !("resume" in step)) throw new Error("expected increment"); expectType>(true); - let result = step.resume({ - ok: true, - value: { properties: ["props-name"] }, - }); + let result = step.resume({ properties: ["props-name"] }); if (!result.ok || result.method !== "execute") { throw new Error("expected execute result"); } @@ -307,7 +364,7 @@ describe("dynamic()", () => { let app = command( name("xmd"), option(name("path"), schema(type("string"))), - dynamic((input: unknown) => { + dynamic(type("unknown"), (input: unknown) => { let declared = Array.isArray(input) ? input.filter((value): value is string => typeof value === "string") : []; @@ -331,7 +388,7 @@ describe("dynamic()", () => { }); assertIncrement(first, { path: "doc.md" }); - let result = first.resume({ ok: true, value: ["author"] }); + let result = first.resume(["author"]); expect(result).toMatchObject({ ok: true, model: { path: "doc.md", author: "Ada" }, @@ -343,11 +400,10 @@ describe("dynamic()", () => { name("simulacrum"), version("1.2.0"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => + dynamic(config, (_config: Config) => extend( option(name("port"), schema(type("number"))), - ) - ), + )), ); type Next = ContinuationOf; @@ -363,7 +419,7 @@ describe("dynamic()", () => { it("continues composing from the conjoined route", () => { let app = command( name("simulacrum"), - dynamic((_config: Config) => extend()), + dynamic(config, (_config: Config) => extend()), option(name("port"), schema(type("number"))), ); @@ -385,7 +441,7 @@ describe("dynamic()", () => { version("1.2.0"), option(name("config"), schema(type("string"))), checkpoint(), - dynamic(() => routes(command(name("dyn")))), + dynamic(type("unknown"), () => routes(command(name("dyn")))), option(name("delay"), schema(type("number"))), routes( command( @@ -420,7 +476,7 @@ describe("dynamic()", () => { it("rejects a resolver that does not return an extension", () => { check(() => { // @ts-expect-error a command is a definition, not a route extension. - dynamic((_config: Config) => command(name("serve"))); + dynamic(config, (_config: Config) => command(name("serve"))); }); }); @@ -430,7 +486,7 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => extend()), + dynamic(config, (_config: Config) => extend()), ); let result = parse(app, { argv: ["--config", "simulacrum.json"], @@ -446,7 +502,7 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => extend()), + dynamic(config, (_config: Config) => extend()), ); let result = parse(app, { argv: [] }); @@ -461,8 +517,10 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => - extend(option(name("port"), schema(type("number")))) + dynamic( + config, + (_config: Config) => + extend(option(name("port"), schema(type("number")))), ), ); let first = parse(app, { @@ -470,10 +528,7 @@ describe("dynamic()", () => { }); assertIncrement(first, { config: "simulacrum.json" }); - let result = first.resume({ - ok: true, - value: { services: [] }, - }); + let result = first.resume({ services: [] }); expect(result).toMatchObject({ ok: true, @@ -486,28 +541,126 @@ describe("dynamic()", () => { }); }); - it("turns a failed requirement into unprocessable content", () => { + it("turns an invalid requirement into unprocessable content", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - dynamic((_config: Config) => extend()), + dynamic(config, (_config: Config) => extend()), ); let first = parse(app, { argv: ["--config", "broken.json"], }); assertIncrement(first, { config: "broken.json" }); - let result = first.resume({ - ok: false, - issues: [{ message: "could not load broken.json" }], - }); + // @ts-expect-error JavaScript callers can supply invalid requirements. + let result = first.resume({ services: "invalid" }); expect(result).toMatchObject({ ok: false, code: "unprocessable-content", route: "/", path: [], - issues: [{ message: "could not load broken.json" }], + }); + if (result.ok || result.code !== "unprocessable-content") { + throw new Error("expected validation issues"); + } + expect(result.issues).toHaveLength(1); + expect([...result.issues[0].path ?? []]).toEqual(["services"]); + + expect(first.resume({ services: [] })).toMatchObject({ + ok: true, + method: "execute", + model: { config: "broken.json" }, + }); + }); + + it("preserves schema issue paths on a child route", () => { + let child = command( + name("plugins"), + dynamic( + z.unknown().pipe(z.object({ + plugins: z.array(z.object({ name: z.string() })), + })), + (value) => + routes(command(name(value.plugins[0].name.toUpperCase()))), + ), + ); + let app = command(name("server"), routes(child)); + let first = parse(app, { argv: ["plugins", "--help"] }); + assertIncrementAt(first, "/plugins"); + + let result = first.resume({ plugins: [{ name: 123 }] }); + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/plugins", + path: ["plugins"], + issues: [{ path: ["plugins", 0, "name"] }], + }); + }); + + it("preserves schema validation messages", () => { + let app = command( + name("server"), + dynamic( + z.string().min(1, "plugin name is required"), + (value) => routes(command(name(value))), + ), + ); + let first = parse(app, { argv: [] }); + assertIncrement(first, {}); + + expect(first.resume("")).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ message: "plugin name is required", path: [] }], + }); + }); + + it("validates result-shaped objects as ordinary input", () => { + let app = command( + name("server"), + dynamic( + z.object({ ok: z.literal(false), value: z.string() }), + (input) => + extend( + withValues([{ + name: "settings", + value: { label: input.value }, + }]), + option(name("label"), schema(z.string())), + ), + ), + ); + let first = parse(app, { argv: [] }); + assertIncrement(first, {}); + + expect(first.resume({ ok: false, value: "ready" })).toMatchObject({ + ok: true, + method: "execute", + model: { label: "ready" }, + }); + }); + + it("reports asynchronous schemas as unprocessable content", () => { + let asynchronous: Schema = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => Promise.resolve({ value: String(value) }), + }, + }; + let app = command( + name("server"), + dynamic(asynchronous, (value) => routes(command(name(value)))), + ); + let first = parse(app, { argv: [] }); + assertIncrement(first, {}); + + expect(first.resume("serve")).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ message: "async schemas are not allowed" }], }); }); }); @@ -517,30 +670,22 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("a"), schema(type("string"))), - dynamic((_config: Config) => + dynamic(config, (_config: Config) => extend( option(name("b"), schema(type("string"))), - dynamic((_plugins: Plugins) => - extend(option(name("c"), schema(type("string")))) - ), - ) - ), + dynamic(plugins, (_plugins: Plugins) => + extend(option(name("c"), schema(type("string"))))), + )), ); let first = parse(app, { argv: ["--a", "one", "--b", "two", "--c", "three"], }); assertIncrement(first, { a: "one" }); - let second = first.resume({ - ok: true, - value: { services: [] }, - }); + let second = first.resume({ services: [] }); assertIncrement(second, { b: "two" }); - let result = second.resume({ - ok: true, - value: { names: [] }, - }); + let result = second.resume({ names: [] }); expect(result).toMatchObject({ ok: true, @@ -554,8 +699,10 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("a"), schema(type("string"))), - dynamic((_config: Config) => - extend(option(name("b"), schema(type("string")))) + dynamic( + config, + (_config: Config) => + extend(option(name("b"), schema(type("string")))), ), ); let first = parse(app, { @@ -563,10 +710,7 @@ describe("dynamic()", () => { }); assertIncrement(first, { a: "one" }); - let result = first.resume({ - ok: true, - value: { services: [] }, - }); + let result = first.resume({ services: [] }); expect(result).toMatchObject({ ok: false, @@ -585,7 +729,7 @@ describe("dynamic()", () => { let clean = command( name("clean"), toggle(name("truncate")), - dynamic(() => extend(routes(auth0))), + dynamic(type("unknown"), () => extend(routes(auth0))), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { @@ -610,10 +754,7 @@ describe("dynamic()", () => { expectType>(true); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ method: "execute", route: "/clean/auth0", @@ -630,7 +771,7 @@ describe("dynamic()", () => { let clean = command( name("clean"), toggle(name("verbose")), - dynamic((_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { @@ -643,10 +784,7 @@ describe("dynamic()", () => { }); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ method: "execute", @@ -663,12 +801,11 @@ describe("dynamic()", () => { let auth0 = command(name("auth0")); let clean = command( name("clean"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( toggle(name("audit")), routes(auth0), - ) - ), + )), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { @@ -681,10 +818,7 @@ describe("dynamic()", () => { }); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ method: "execute", @@ -701,12 +835,11 @@ describe("dynamic()", () => { let auth0 = command(name("auth0")); let clean = command( name("clean"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( option(name("target"), schema(type("string"))), routes(auth0), - ) - ), + )), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { @@ -714,10 +847,7 @@ describe("dynamic()", () => { }); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: false, @@ -731,12 +861,11 @@ describe("dynamic()", () => { let auth0 = command(name("auth0"), toggle(name("audit"))); let clean = command( name("clean"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( toggle(name("audit")), routes(auth0), - ) - ), + )), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { @@ -749,10 +878,7 @@ describe("dynamic()", () => { }); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ method: "execute", @@ -768,7 +894,7 @@ describe("dynamic()", () => { it("defers an unknown token until the route frame is final", () => { let clean = command( name("clean"), - dynamic((_plugins: Plugins) => extend()), + dynamic(plugins, (_plugins: Plugins) => extend()), ); let app = command(name("simulacrum"), routes(clean)); let increment = parse(app, { argv: ["clean", "auth0"] }); @@ -779,10 +905,7 @@ describe("dynamic()", () => { }); assertIncrementAt(increment, "/clean"); - let result = increment.resume({ - ok: true, - value: { names: [] }, - }); + let result = increment.resume({ names: [] }); expect(result).toMatchObject({ ok: false, @@ -798,13 +921,19 @@ describe("dynamic()", () => { let clean = command( name("clean"), toggle(name("truncate")), - dynamic((_child: { readonly child: true }) => extend()), + dynamic( + type({ child: "true" }), + (_child: { readonly child: true }) => extend(), + ), ); let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), routes(clean), - dynamic((_root: { readonly root: true }) => extend()), + dynamic( + type({ root: "true" }), + (_root: { readonly root: true }) => extend(), + ), ); let root = parse(app, { argv: ["--config", "app.json", "clean", "--truncate"], @@ -813,10 +942,7 @@ describe("dynamic()", () => { type Root = Extract; expectType>>(true); assertIncrement(root, { config: "app.json" }); - let child = root.resume({ - ok: true, - value: { root: true }, - }); + let child = root.resume({ root: true }); expect(child).toMatchObject({ route: "/clean", @@ -836,10 +962,7 @@ describe("dynamic()", () => { expectType>(true); assertIncrementAt(child, "/clean"); - let result = child.resume({ - ok: true, - value: { child: true }, - }); + let result = child.resume({ child: true }); expect(result).toMatchObject({ ok: true, @@ -858,15 +981,12 @@ describe("dynamic()", () => { let auth0 = command(name("auth0")); let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), ); let first = parse(app, { argv: ["auth0", "--help"] }); assertIncrement(first, {}); - let result = first.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = first.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -878,15 +998,12 @@ describe("dynamic()", () => { it("waits for all root phases before returning root help", () => { let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => extend()), + dynamic(plugins, (_plugins: Plugins) => extend()), ); let first = parse(app, { argv: ["--help"] }); assertIncrement(first, {}); - let result = first.resume({ - ok: true, - value: { names: [] }, - }); + let result = first.resume({ names: [] }); expect(result).toMatchObject({ ok: true, @@ -899,17 +1016,14 @@ describe("dynamic()", () => { let app = route( name("simulacrum"), option(name("config")), - dynamic((_plugins: Plugins) => option(name("dyno"))), + dynamic(plugins, (_plugins: Plugins) => option(name("dyno"))), option(name("delay")), routes(command(name("serve"))), ); let first = parse(app, { argv: ["--help"] }); assertIncrement(first, { config: undefined }); - let result = first.resume({ - ok: true, - value: { names: [] }, - }); + let result = first.resume({ names: [] }); expect(result).toMatchObject({ ok: true, @@ -939,15 +1053,12 @@ Options: let release = route(name("release"), version("2.0.0")); let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => extend(routes(release))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(release))), ); let first = parse(app, { argv: ["release", "--version"] }); assertIncrement(first, {}); - let result = first.resume({ - ok: true, - value: { names: ["release"] }, - }); + let result = first.resume({ names: ["release"] }); expect(result).toMatchObject({ ok: true, @@ -960,15 +1071,12 @@ Options: let serve = command(name("serve")); let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => extend(routes(serve))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(serve))), ); let first = parse(app, { argv: ["serve"] }); assertIncrement(first, {}); - let result = first.resume({ - ok: true, - value: { names: ["serve"] }, - }); + let result = first.resume({ names: ["serve"] }); expect(result).toMatchObject({ ok: true, diff --git a/test/env.test.ts b/test/env.test.ts index 4422eb6..5f2954d 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -11,7 +11,9 @@ import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; import { route, routes } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; -import type { ModelOf } from "../lib/types.ts"; +import type { ModelOf, Schema } from "../lib/types.ts"; + +const plugins: Schema = z.object({ names: z.array(z.string()) }); describe("environment binding", () => { describe("mapping", () => { @@ -570,11 +572,10 @@ describe("environment binding", () => { it("keeps input environment sources available to later parameters", () => { let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( option(name("domain"), schema(z.string())), - ) - ), + )), ); let increment = parse(app, { argv: [], @@ -585,10 +586,7 @@ describe("environment binding", () => { }); assertIncrement(increment); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -605,7 +603,7 @@ describe("environment binding", () => { ); let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), ); let increment = parse(app, { argv: ["auth0"], @@ -616,10 +614,7 @@ describe("environment binding", () => { }); assertIncrement(increment); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -632,33 +627,25 @@ describe("environment binding", () => { it("keeps dynamically introduced environment sources available downstream", () => { let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( withEnvs([{ name: "settings", value: { DOMAIN: "auth0.local" }, }]), - dynamic((_services: Plugins) => + dynamic(plugins, (_services: Plugins) => extend( option(name("domain"), schema(z.string())), - ) - ), - ) - ), + )), + )), ); let first = parse(app, { argv: [] }); assertIncrement(first); - let second = first.resume({ - ok: true, - value: { names: ["config"] }, - }); + let second = first.resume({ names: ["config"] }); assertIncrement(second); - let result = second.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = second.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -673,7 +660,7 @@ describe("environment binding", () => { let app = command( name("simulacrum"), option(name("config"), schema(z.string())), - dynamic((_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), ); let increment = parse(app, { argv: ["auth0", "--help"], @@ -684,10 +671,7 @@ describe("environment binding", () => { }); assertIncrement(increment, { config: "plugins.json" }); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, diff --git a/test/fold.test.ts b/test/fold.test.ts index 323d269..6bbd110 100644 --- a/test/fold.test.ts +++ b/test/fold.test.ts @@ -22,9 +22,17 @@ import type { ModelOf, RequirementOf, Route, + Schema, } from "../lib/types.ts"; import { type ValueSource, withValues } from "../lib/values.ts"; +const sources: Schema = type({ + name: "string", + value: "unknown", +}).array(); + +const plugins: Schema = type({ auth0: "boolean" }); + const staticApp = command( name("simulacrum"), description("run service simulators"), @@ -43,7 +51,10 @@ const staticApp = command( const configApp = command( name("server"), option(name("config"), schema(type("string"))), - dynamic((sources: readonly ValueSource[]) => extend(withValues(sources))), + dynamic( + sources, + (sources: readonly ValueSource[]) => extend(withValues(sources)), + ), option(name("port"), schema(type("number"))), ); @@ -301,8 +312,9 @@ describe("pipeline fold", () => { it("accepts an atomic dynamic continuation", () => { let app = command( name("atomic"), - dynamic((_plugins: Plugins) => - option(name("dyno"), schema(type("number"))) + dynamic( + plugins, + (_plugins: Plugins) => option(name("dyno"), schema(type("number"))), ), ); @@ -490,8 +502,9 @@ describe("pipeline fold", () => { let phased = extend( option(name("before"), schema(type("number"))), identity, - dynamic((_plugins: Plugins) => - option(name("dynamic"), schema(type("string"))) + dynamic( + plugins, + (_plugins: Plugins) => option(name("dynamic"), schema(type("string"))), ), option(name("after"), schema(type("boolean"))), ); @@ -601,8 +614,9 @@ describe("pipeline fold", () => { it("composes nested dynamic batches through an identity macro", () => { let identity = extend(); let nested = extend( - dynamic((_plugins: Plugins) => - option(name("dynamic"), schema(type("number"))) + dynamic( + plugins, + (_plugins: Plugins) => option(name("dynamic"), schema(type("number"))), ), withValues([]), ); @@ -701,8 +715,9 @@ describe("pipeline fold", () => { }); check(() => { - // @ts-expect-error a resolver must return a route extension. dynamic( + plugins, + // @ts-expect-error a resolver must return a route extension. (_plugins: Plugins) => command(name("auth0")), ); }); @@ -739,10 +754,7 @@ describe("pipeline fold", () => { throw new Error("expected a parse increment"); } - let result = first.resume({ - ok: true, - value: [{ name: "server.json", value: { port: 9001 } }], - }); + let result = first.resume([{ name: "server.json", value: { port: 9001 } }]); expect(result).toMatchObject({ ok: true, diff --git a/test/multiple.test.ts b/test/multiple.test.ts index bb88f75..4351d03 100644 --- a/test/multiple.test.ts +++ b/test/multiple.test.ts @@ -346,7 +346,7 @@ describe("multiple()", () => { let app = command( name("simulacrum"), option(name("config"), multiple(), schema(z.array(z.string()))), - dynamic(() => extend(routes(child))), + dynamic(type("unknown"), () => extend(routes(child))), ); let first = parse(app, { argv: [ @@ -366,7 +366,7 @@ describe("multiple()", () => { expect("resume" in first).toBe(true); if (!("resume" in first)) return; - let result = first.resume({ ok: true, value: undefined }); + let result = first.resume(undefined); expect(result).toMatchObject({ ok: true, diff --git a/test/transform.test.ts b/test/transform.test.ts index ac3a6e9..8faa3fc 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -118,7 +118,7 @@ describe("transform()", () => { expect("resume" in first).toBe(true); if (!("resume" in first)) return; - let result = first.resume({ ok: true, value: [] }); + let result = first.resume([]); expect(result).toMatchObject({ ok: true, method: "execute" }); if (!("model" in result)) return; diff --git a/test/value.test.ts b/test/value.test.ts index f289340..79ddc65 100644 --- a/test/value.test.ts +++ b/test/value.test.ts @@ -9,9 +9,11 @@ import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; import { routes } from "../lib/route.ts"; -import type { Parse } from "../lib/types.ts"; +import type { Parse, Schema } from "../lib/types.ts"; import { withValues } from "../lib/values.ts"; +const plugins: Schema = type({ names: "string[]" }); + describe("value sources", () => { it("binds top-level properties to root parameters", () => { let app = command( @@ -345,7 +347,7 @@ describe("value sources", () => { name("simulacrum"), option(name("port"), schema(type("number"))), option(name("target"), schema(type("string"))), - dynamic((_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), ); let increment = parse(app, { argv: ["--target", "local", "auth0", "--port", "9001"], @@ -363,10 +365,7 @@ describe("value sources", () => { }, }); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -386,11 +385,10 @@ describe("value sources", () => { it("keeps values available to parameters introduced by a later phase", () => { let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( option(name("domain"), schema(type("string"))), - ) - ), + )), ); let input = { argv: [], @@ -402,10 +400,7 @@ describe("value sources", () => { let increment = parse(app, input); assertIncrement(increment); - let result = increment.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = increment.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true, @@ -421,33 +416,25 @@ describe("value sources", () => { it("keeps dynamically introduced values available downstream", () => { let app = command( name("simulacrum"), - dynamic((_plugins: Plugins) => + dynamic(plugins, (_plugins: Plugins) => extend( withValues([{ name: "settings", value: { domain: "auth0.local" }, }]), - dynamic((_services: Plugins) => + dynamic(plugins, (_services: Plugins) => extend( option(name("domain"), schema(type("string"))), - ) - ), - ) - ), + )), + )), ); let first = parse(app, { argv: [] }); assertIncrement(first); - let second = first.resume({ - ok: true, - value: { names: ["config"] }, - }); + let second = first.resume({ names: ["config"] }); assertIncrement(second); - let result = second.resume({ - ok: true, - value: { names: ["auth0"] }, - }); + let result = second.resume({ names: ["auth0"] }); expect(result).toMatchObject({ ok: true,