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
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand All @@ -234,7 +260,6 @@ import {
printErrors,
printHelp,
printVersion,
type Result,
schema,
type ValueSource,
version,
Expand Down Expand Up @@ -280,13 +305,15 @@ switch (result.method) {
break;
}

declare function load(path: string): Promise<Result<ValueSource[]>>;
declare function load(path: string): Promise<ValueSource[]>;
```

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
Expand Down
9 changes: 6 additions & 3 deletions docs/insights.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Requirement>`, 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<R>` preserves requirement order; `RequirementOf<R>` is its
head.
- `ContinuationOf<R>` settles the current unresolved phase and retains it as
Expand Down
32 changes: 31 additions & 1 deletion lib/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -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<typeof withValues>
> {
return dynamic((values: ValueSource[]) => withValues(values));
return dynamic(sources, (values) => withValues(values));
}

const sources: Schema<ValueSource[]> = {
"~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[] };
},
},
};
48 changes: 37 additions & 11 deletions lib/dynamic.ts
Original file line number Diff line number Diff line change
@@ -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 extends AnyRoute> = R["phases"][0];
export type PhasesOf<R extends AnyRoute> = 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<Requirement, Extract<E, AnyElement>> {
return brand<DynamicElement<Requirement, Extract<E, AnyElement>>>(
schema: Schema<Input, Output>,
extension: (requires: Output) => E,
): DynamicElement<Input, Extract<E, AnyElement>> {
return brand<DynamicElement<Input, Extract<E, AnyElement>>>(
(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: {
Expand Down
17 changes: 9 additions & 8 deletions lib/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -260,7 +261,7 @@ interface AnyIncrement {
readonly ok: true;
readonly route: RoutePath;
readonly model: object;
resume(result: Result<unknown>): Outcome<AnyIntent | AnyIncrement>;
resume(value: unknown): Outcome<AnyIntent | AnyIncrement>;
}

interface Segment {
Expand Down
2 changes: 1 addition & 1 deletion lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
Route,
} from "./types.ts";

export type Element<D extends Delta> = {
export type Element<D = unknown> = {
readonly [operation]: D;
};

Expand Down
10 changes: 6 additions & 4 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -86,7 +86,7 @@ export interface ParseIncrement<
readonly model: IncrementModelOf<R>;

resume(
result: Result<RequirementOf<R>>,
value: RequirementOf<R>,
): Outcome<ParseAt<ContinuationOf<R>, P, Models>>;
}

Expand Down Expand Up @@ -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[]];
Expand Down Expand Up @@ -304,7 +306,7 @@ type NextModelIn<P extends readonly AnyPhase[]> = P extends readonly [
type RequirementIn<P extends AnyPhase> = P extends {
readonly resolver: (
requirement: infer Requirement,
) => (route: AnyRoute) => AnyRoute;
) => Result<(route: AnyRoute) => AnyRoute>;
} ? Requirement
: never;

Expand Down
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
16 changes: 7 additions & 9 deletions test/argument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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",
Expand All @@ -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"] });

Expand All @@ -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",
Expand All @@ -379,7 +377,7 @@ function expectType<T extends true>(_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(
Expand Down
Loading
Loading