@ontrails/core 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +179 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +2 -0
- package/dist/adapters.js.map +1 -0
- package/dist/blob-ref.d.ts +20 -0
- package/dist/blob-ref.d.ts.map +1 -0
- package/dist/blob-ref.js +22 -0
- package/dist/blob-ref.js.map +1 -0
- package/dist/branded.d.ts +36 -0
- package/dist/branded.d.ts.map +1 -0
- package/dist/branded.js +89 -0
- package/dist/branded.js.map +1 -0
- package/dist/collections.d.ts +31 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +60 -0
- package/dist/collections.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/context.js.map +1 -0
- package/dist/derive.d.ts +33 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +122 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +45 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +17 -0
- package/dist/event.js.map +1 -0
- package/dist/fetch.d.ts +15 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +102 -0
- package/dist/fetch.js.map +1 -0
- package/dist/guards.d.ts +17 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +25 -0
- package/dist/guards.js.map +1 -0
- package/dist/health.d.ts +18 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +5 -0
- package/dist/health.js.map +1 -0
- package/dist/hike.d.ts +36 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +20 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +24 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +17 -0
- package/dist/job.js.map +1 -0
- package/dist/layer.d.ts +17 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +21 -0
- package/dist/layer.js.map +1 -0
- package/dist/path-security.d.ts +28 -0
- package/dist/path-security.d.ts.map +1 -0
- package/dist/path-security.js +63 -0
- package/dist/path-security.js.map +1 -0
- package/dist/patterns/bulk.d.ts +15 -0
- package/dist/patterns/bulk.d.ts.map +1 -0
- package/dist/patterns/bulk.js +14 -0
- package/dist/patterns/bulk.js.map +1 -0
- package/dist/patterns/change.d.ts +10 -0
- package/dist/patterns/change.d.ts.map +1 -0
- package/dist/patterns/change.js +10 -0
- package/dist/patterns/change.js.map +1 -0
- package/dist/patterns/date-range.d.ts +10 -0
- package/dist/patterns/date-range.d.ts.map +1 -0
- package/dist/patterns/date-range.js +10 -0
- package/dist/patterns/date-range.js.map +1 -0
- package/dist/patterns/index.d.ts +9 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/pagination.d.ts +18 -0
- package/dist/patterns/pagination.d.ts.map +1 -0
- package/dist/patterns/pagination.js +18 -0
- package/dist/patterns/pagination.js.map +1 -0
- package/dist/patterns/progress.d.ts +11 -0
- package/dist/patterns/progress.d.ts.map +1 -0
- package/dist/patterns/progress.js +11 -0
- package/dist/patterns/progress.js.map +1 -0
- package/dist/patterns/sorting.d.ts +13 -0
- package/dist/patterns/sorting.d.ts.map +1 -0
- package/dist/patterns/sorting.js +10 -0
- package/dist/patterns/sorting.js.map +1 -0
- package/dist/patterns/status.d.ts +15 -0
- package/dist/patterns/status.d.ts.map +1 -0
- package/dist/patterns/status.js +9 -0
- package/dist/patterns/status.js.map +1 -0
- package/dist/patterns/timestamps.d.ts +10 -0
- package/dist/patterns/timestamps.d.ts.map +1 -0
- package/dist/patterns/timestamps.js +10 -0
- package/dist/patterns/timestamps.js.map +1 -0
- package/dist/redaction/index.d.ts +4 -0
- package/dist/redaction/index.d.ts.map +1 -0
- package/dist/redaction/index.js +3 -0
- package/dist/redaction/index.js.map +1 -0
- package/dist/redaction/patterns.d.ts +9 -0
- package/dist/redaction/patterns.d.ts.map +1 -0
- package/dist/redaction/patterns.js +39 -0
- package/dist/redaction/patterns.js.map +1 -0
- package/dist/redaction/redactor.d.ts +27 -0
- package/dist/redaction/redactor.d.ts.map +1 -0
- package/dist/redaction/redactor.js +89 -0
- package/dist/redaction/redactor.js.map +1 -0
- package/dist/resilience.d.ts +34 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +164 -0
- package/dist/resilience.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +145 -0
- package/dist/result.js.map +1 -0
- package/dist/serialization.d.ts +27 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +115 -0
- package/dist/serialization.js.map +1 -0
- package/dist/topo.d.ts +18 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +74 -0
- package/dist/topo.js.map +1 -0
- package/dist/trail.d.ts +83 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +16 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-topo.d.ts +24 -0
- package/dist/validate-topo.d.ts.map +1 -0
- package/dist/validate-topo.js +108 -0
- package/dist/validate-topo.js.map +1 -0
- package/dist/validation.d.ts +27 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +57 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/blob-ref.test.ts +103 -0
- package/src/__tests__/branded.test.ts +148 -0
- package/src/__tests__/collections.test.ts +126 -0
- package/src/__tests__/context.test.ts +66 -0
- package/src/__tests__/derive.test.ts +159 -0
- package/src/__tests__/errors.test.ts +309 -0
- package/src/__tests__/event.test.ts +82 -0
- package/src/__tests__/fetch.test.ts +217 -0
- package/src/__tests__/guards.test.ts +102 -0
- package/src/__tests__/hike.test.ts +117 -0
- package/src/__tests__/job.test.ts +98 -0
- package/src/__tests__/layer.test.ts +224 -0
- package/src/__tests__/path-security.test.ts +114 -0
- package/src/__tests__/patterns.test.ts +273 -0
- package/src/__tests__/redaction.test.ts +244 -0
- package/src/__tests__/resilience.test.ts +246 -0
- package/src/__tests__/result.test.ts +155 -0
- package/src/__tests__/serialization.test.ts +236 -0
- package/src/__tests__/topo.test.ts +184 -0
- package/src/__tests__/trail.test.ts +179 -0
- package/src/__tests__/validate-topo.test.ts +201 -0
- package/src/__tests__/validation.test.ts +283 -0
- package/src/__tests__/workspace.test.ts +183 -0
- package/src/adapters.ts +68 -0
- package/src/blob-ref.ts +39 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/context.ts +18 -0
- package/src/derive.ts +223 -0
- package/src/errors.ts +196 -0
- package/src/event.ts +77 -0
- package/src/fetch.ts +138 -0
- package/src/guards.ts +37 -0
- package/src/health.ts +23 -0
- package/src/hike.ts +77 -0
- package/src/index.ts +158 -0
- package/src/job.ts +20 -0
- package/src/layer.ts +44 -0
- package/src/path-security.ts +90 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +47 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/result.ts +180 -0
- package/src/serialization.ts +183 -0
- package/src/topo.ts +123 -0
- package/src/trail.ts +130 -0
- package/src/types.ts +58 -0
- package/src/validate-topo.ts +151 -0
- package/src/validation.ts +182 -0
- package/src/workspace.ts +77 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/topo.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application entry point — scans module exports to build a topology graph.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ValidationError } from './errors.js';
|
|
6
|
+
import type { AnyEvent } from './event.js';
|
|
7
|
+
import type { AnyHike } from './hike.js';
|
|
8
|
+
import type { AnyTrail } from './trail.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Public types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface Topo {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly trails: ReadonlyMap<string, AnyTrail>;
|
|
17
|
+
readonly hikes: ReadonlyMap<string, AnyHike>;
|
|
18
|
+
readonly events: ReadonlyMap<string, AnyEvent>;
|
|
19
|
+
get(id: string): AnyTrail | AnyHike | undefined;
|
|
20
|
+
has(id: string): boolean;
|
|
21
|
+
list(): (AnyTrail | AnyHike)[];
|
|
22
|
+
listEvents(): AnyEvent[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Kind discriminant check
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
type Registrable = AnyTrail | AnyHike | AnyEvent;
|
|
30
|
+
|
|
31
|
+
const isRegistrable = (value: unknown): value is Registrable => {
|
|
32
|
+
if (typeof value !== 'object' || value === null) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const { kind } = value as Record<string, unknown>;
|
|
36
|
+
return kind === 'trail' || kind === 'hike' || kind === 'event';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Topo implementation
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const createTopo = (
|
|
44
|
+
name: string,
|
|
45
|
+
trails: ReadonlyMap<string, AnyTrail>,
|
|
46
|
+
hikes: ReadonlyMap<string, AnyHike>,
|
|
47
|
+
events: ReadonlyMap<string, AnyEvent>
|
|
48
|
+
): Topo => ({
|
|
49
|
+
events,
|
|
50
|
+
get(id: string): AnyTrail | AnyHike | undefined {
|
|
51
|
+
return trails.get(id) ?? hikes.get(id);
|
|
52
|
+
},
|
|
53
|
+
has(id: string): boolean {
|
|
54
|
+
return trails.has(id) || hikes.has(id);
|
|
55
|
+
},
|
|
56
|
+
hikes,
|
|
57
|
+
|
|
58
|
+
list(): (AnyTrail | AnyHike)[] {
|
|
59
|
+
return [...trails.values(), ...hikes.values()];
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
listEvents(): AnyEvent[] {
|
|
63
|
+
return [...events.values()];
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
name,
|
|
67
|
+
|
|
68
|
+
trails,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// topo()
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Register a single registrable value into the appropriate map. */
|
|
76
|
+
const register = (
|
|
77
|
+
value: Registrable,
|
|
78
|
+
trails: Map<string, AnyTrail>,
|
|
79
|
+
hikes: Map<string, AnyHike>,
|
|
80
|
+
events: Map<string, AnyEvent>
|
|
81
|
+
): void => {
|
|
82
|
+
const { id } = value as { id: string };
|
|
83
|
+
const registrars: Record<string, () => void> = {
|
|
84
|
+
event: () => {
|
|
85
|
+
if (events.has(id)) {
|
|
86
|
+
throw new ValidationError(`Duplicate event ID: "${id}"`);
|
|
87
|
+
}
|
|
88
|
+
events.set(id, value as AnyEvent);
|
|
89
|
+
},
|
|
90
|
+
hike: () => {
|
|
91
|
+
if (hikes.has(id)) {
|
|
92
|
+
throw new ValidationError(`Duplicate hike ID: "${id}"`);
|
|
93
|
+
}
|
|
94
|
+
hikes.set(id, value as AnyHike);
|
|
95
|
+
},
|
|
96
|
+
trail: () => {
|
|
97
|
+
if (trails.has(id)) {
|
|
98
|
+
throw new ValidationError(`Duplicate trail ID: "${id}"`);
|
|
99
|
+
}
|
|
100
|
+
trails.set(id, value as AnyTrail);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
registrars[value.kind]?.();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const topo = (
|
|
107
|
+
name: string,
|
|
108
|
+
...modules: Record<string, unknown>[]
|
|
109
|
+
): Topo => {
|
|
110
|
+
const trails = new Map<string, AnyTrail>();
|
|
111
|
+
const hikes = new Map<string, AnyHike>();
|
|
112
|
+
const events = new Map<string, AnyEvent>();
|
|
113
|
+
|
|
114
|
+
for (const mod of modules) {
|
|
115
|
+
for (const value of Object.values(mod)) {
|
|
116
|
+
if (isRegistrable(value)) {
|
|
117
|
+
register(value, trails, hikes, events);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return createTopo(name, trails, hikes, events);
|
|
123
|
+
};
|
package/src/trail.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { FieldOverride } from './derive.js';
|
|
4
|
+
import type { Result } from './result.js';
|
|
5
|
+
import type { Implementation, TrailContext } from './types.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Trail example
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A named example for documentation and testing.
|
|
13
|
+
*
|
|
14
|
+
* The `input` field accepts `Partial<I>` so that fields with schema defaults
|
|
15
|
+
* (e.g. `z.number().default(20)`) can be omitted from examples. The schema
|
|
16
|
+
* fills in defaults at validation time.
|
|
17
|
+
*/
|
|
18
|
+
export interface TrailExample<I, O> {
|
|
19
|
+
/** Human-readable name */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Optional description of what this example demonstrates */
|
|
22
|
+
readonly description?: string | undefined;
|
|
23
|
+
/** The input value — fields with schema defaults may be omitted */
|
|
24
|
+
readonly input: Partial<I>;
|
|
25
|
+
/** Expected output for success-path examples */
|
|
26
|
+
readonly expected?: O | undefined;
|
|
27
|
+
/** Error class name for error-path examples */
|
|
28
|
+
readonly error?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Trail spec
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Everything needed to define a trail (minus the id) */
|
|
36
|
+
export interface TrailSpec<I, O> {
|
|
37
|
+
/** Zod schema for validating input */
|
|
38
|
+
readonly input: z.ZodType<I>;
|
|
39
|
+
/** Zod schema for validating output (optional — some trails are fire-and-forget) */
|
|
40
|
+
readonly output?: z.ZodType<O> | undefined;
|
|
41
|
+
/** The pure function that does the work (sync or async authoring) */
|
|
42
|
+
readonly implementation: Implementation<I, O>;
|
|
43
|
+
/** Human-readable description */
|
|
44
|
+
readonly description?: string | undefined;
|
|
45
|
+
/** Named examples for docs and testing */
|
|
46
|
+
readonly examples?: readonly TrailExample<I, O>[] | undefined;
|
|
47
|
+
/** Trail is read-only (no side effects) */
|
|
48
|
+
readonly readOnly?: boolean | undefined;
|
|
49
|
+
/** Trail is destructive (deletes or overwrites data) */
|
|
50
|
+
readonly destructive?: boolean | undefined;
|
|
51
|
+
/** Trail is idempotent (safe to retry) */
|
|
52
|
+
readonly idempotent?: boolean | undefined;
|
|
53
|
+
/** Arbitrary metadata for tooling and filtering */
|
|
54
|
+
readonly markers?: Readonly<Record<string, unknown>> | undefined;
|
|
55
|
+
/** Named sets of downstream trail IDs that may be invoked */
|
|
56
|
+
readonly detours?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
57
|
+
/** Per-field overrides for deriveFields() (labels, hints, options) */
|
|
58
|
+
readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Trail (the frozen runtime object)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** A fully-defined trail — the unit of work in the Trails system */
|
|
66
|
+
export interface Trail<I, O> extends Omit<TrailSpec<I, O>, 'implementation'> {
|
|
67
|
+
readonly kind: 'trail';
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly implementation: Implementation<I, O>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Factory
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a trail definition.
|
|
78
|
+
*
|
|
79
|
+
* Returns a frozen object with `kind: "trail"` and all spec fields.
|
|
80
|
+
* The trail is inert until handed to a runner.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* // ID as first argument (recommended for human authoring)
|
|
85
|
+
* const show = trail("entity.show", {
|
|
86
|
+
* input: z.object({ name: z.string() }),
|
|
87
|
+
* implementation: (input) => Result.ok(entity),
|
|
88
|
+
* });
|
|
89
|
+
*
|
|
90
|
+
* // Full spec object (for programmatic generation)
|
|
91
|
+
* const show = trail({
|
|
92
|
+
* id: "entity.show",
|
|
93
|
+
* input: z.object({ name: z.string() }),
|
|
94
|
+
* implementation: (input) => Result.ok(entity),
|
|
95
|
+
* });
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function trail<I, O>(id: string, spec: TrailSpec<I, O>): Trail<I, O>;
|
|
99
|
+
export function trail<I, O>(
|
|
100
|
+
spec: TrailSpec<I, O> & { readonly id: string }
|
|
101
|
+
): Trail<I, O>;
|
|
102
|
+
export function trail<I, O>(
|
|
103
|
+
idOrSpec: string | (TrailSpec<I, O> & { readonly id: string }),
|
|
104
|
+
maybeSpec?: TrailSpec<I, O>
|
|
105
|
+
): Trail<I, O> {
|
|
106
|
+
const resolved =
|
|
107
|
+
typeof idOrSpec === 'string'
|
|
108
|
+
? { id: idOrSpec, spec: maybeSpec }
|
|
109
|
+
: { id: idOrSpec.id, spec: idOrSpec };
|
|
110
|
+
|
|
111
|
+
if (!resolved.spec) {
|
|
112
|
+
throw new TypeError('trail() requires a spec when an id is provided');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { implementation, ...spec } = resolved.spec;
|
|
116
|
+
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
...spec,
|
|
119
|
+
id: resolved.id,
|
|
120
|
+
implementation: async (input: I, ctx: TrailContext) =>
|
|
121
|
+
await implementation(input, ctx),
|
|
122
|
+
kind: 'trail' as const,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Re-export types that callers of trail() will need
|
|
127
|
+
// oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous collections; `any` is correct here because Implementation is contravariant in I
|
|
128
|
+
export type AnyTrail = Trail<any, any>;
|
|
129
|
+
|
|
130
|
+
export type { Implementation, TrailContext, Result };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Result } from './result.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trail implementation — sync or async.
|
|
5
|
+
*
|
|
6
|
+
* Authors can return `Result` directly or wrap it in a `Promise`. The framework
|
|
7
|
+
* normalizes with `await` at every call site, so both forms work transparently.
|
|
8
|
+
*/
|
|
9
|
+
export type Implementation<I, O> = (
|
|
10
|
+
input: I,
|
|
11
|
+
ctx: TrailContext
|
|
12
|
+
) => Result<O, Error> | Promise<Result<O, Error>>;
|
|
13
|
+
|
|
14
|
+
/** Invoke another trail by id — used for trail composition */
|
|
15
|
+
export type FollowFn = <O>(
|
|
16
|
+
id: string,
|
|
17
|
+
input: unknown
|
|
18
|
+
) => Promise<Result<O, Error>>;
|
|
19
|
+
|
|
20
|
+
/** Callback for reporting progress from long-running trails */
|
|
21
|
+
export type ProgressCallback = (event: ProgressEvent) => void;
|
|
22
|
+
|
|
23
|
+
/** Structured progress event emitted during trail execution */
|
|
24
|
+
export interface ProgressEvent {
|
|
25
|
+
readonly type: 'start' | 'progress' | 'complete' | 'error';
|
|
26
|
+
readonly current?: number | undefined;
|
|
27
|
+
readonly total?: number | undefined;
|
|
28
|
+
readonly message?: string | undefined;
|
|
29
|
+
readonly ts: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal logger interface — implementations can bridge to any logging library */
|
|
33
|
+
export interface Logger {
|
|
34
|
+
readonly name?: string | undefined;
|
|
35
|
+
trace(message: string, data?: Record<string, unknown>): void;
|
|
36
|
+
debug(message: string, data?: Record<string, unknown>): void;
|
|
37
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
38
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
39
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
40
|
+
fatal(message: string, data?: Record<string, unknown>): void;
|
|
41
|
+
child(context: Record<string, unknown>): Logger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Runtime context threaded through every trail execution */
|
|
45
|
+
export interface TrailContext {
|
|
46
|
+
readonly requestId: string;
|
|
47
|
+
readonly signal: AbortSignal;
|
|
48
|
+
readonly follow?: FollowFn | undefined;
|
|
49
|
+
readonly permit?: unknown | undefined;
|
|
50
|
+
readonly workspaceRoot?: string | undefined;
|
|
51
|
+
readonly logger?: Logger | undefined;
|
|
52
|
+
readonly progress?: ProgressCallback | undefined;
|
|
53
|
+
readonly cwd?: string | undefined;
|
|
54
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
55
|
+
readonly [key: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Surface = 'cli' | 'mcp' | 'http' | 'ws';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural validation for a Topo graph.
|
|
3
|
+
*
|
|
4
|
+
* Checks hike follows references, example input validity, event origin
|
|
5
|
+
* references, and output schema completeness. Returns a Result with all
|
|
6
|
+
* issues collected into a single ValidationError.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ValidationError } from './errors.js';
|
|
10
|
+
import type { AnyEvent } from './event.js';
|
|
11
|
+
import type { AnyHike } from './hike.js';
|
|
12
|
+
import { Result } from './result.js';
|
|
13
|
+
import type { Topo } from './topo.js';
|
|
14
|
+
import type { AnyTrail } from './trail.js';
|
|
15
|
+
import { validateInput } from './validation.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Issue shape
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface TopoIssue {
|
|
22
|
+
readonly trailId: string;
|
|
23
|
+
readonly rule: string;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Validators
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const checkFollows = (
|
|
32
|
+
hikes: ReadonlyMap<string, AnyHike>,
|
|
33
|
+
topo: Topo
|
|
34
|
+
): TopoIssue[] => {
|
|
35
|
+
const issues: TopoIssue[] = [];
|
|
36
|
+
for (const [id, hike] of hikes) {
|
|
37
|
+
for (const followId of hike.follows) {
|
|
38
|
+
if (followId === id) {
|
|
39
|
+
issues.push({
|
|
40
|
+
message: `Hike follows itself`,
|
|
41
|
+
rule: 'no-self-follow',
|
|
42
|
+
trailId: id,
|
|
43
|
+
});
|
|
44
|
+
} else if (!topo.has(followId)) {
|
|
45
|
+
issues.push({
|
|
46
|
+
message: `Follows "${followId}" which is not in the topo`,
|
|
47
|
+
rule: 'follows-exist',
|
|
48
|
+
trailId: id,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return issues;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const checkOneExample = (
|
|
57
|
+
id: string,
|
|
58
|
+
example: {
|
|
59
|
+
name: string;
|
|
60
|
+
input: unknown;
|
|
61
|
+
expected?: unknown | undefined;
|
|
62
|
+
error?: string | undefined;
|
|
63
|
+
},
|
|
64
|
+
inputSchema: { safeParse: (data: unknown) => { success: boolean } },
|
|
65
|
+
hasOutput: boolean
|
|
66
|
+
): TopoIssue[] => {
|
|
67
|
+
const issues: TopoIssue[] = [];
|
|
68
|
+
const result = validateInput(inputSchema as AnyTrail['input'], example.input);
|
|
69
|
+
if (result.isErr() && example.error === undefined) {
|
|
70
|
+
issues.push({
|
|
71
|
+
message: `Example "${example.name}" input does not parse against schema`,
|
|
72
|
+
rule: 'example-input-valid',
|
|
73
|
+
trailId: id,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (example.expected !== undefined && !hasOutput) {
|
|
77
|
+
issues.push({
|
|
78
|
+
message: `Example "${example.name}" has expected output but trail has no output schema`,
|
|
79
|
+
rule: 'output-schema-present',
|
|
80
|
+
trailId: id,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return issues;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
|
|
87
|
+
const issues: TopoIssue[] = [];
|
|
88
|
+
for (const [id, trail] of trails) {
|
|
89
|
+
if (!trail.examples) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const example of trail.examples) {
|
|
93
|
+
issues.push(...checkOneExample(id, example, trail.input, !!trail.output));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return issues;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const checkEventOrigins = (
|
|
100
|
+
events: ReadonlyMap<string, AnyEvent>,
|
|
101
|
+
topo: Topo
|
|
102
|
+
): TopoIssue[] => {
|
|
103
|
+
const issues: TopoIssue[] = [];
|
|
104
|
+
for (const [id, evt] of events) {
|
|
105
|
+
if (!evt.from) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const originId of evt.from) {
|
|
109
|
+
if (!topo.has(originId)) {
|
|
110
|
+
issues.push({
|
|
111
|
+
message: `Event origin "${originId}" is not in the topo`,
|
|
112
|
+
rule: 'event-origin-exists',
|
|
113
|
+
trailId: id,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return issues;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Public API
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate the structural integrity of a Topo graph.
|
|
127
|
+
*
|
|
128
|
+
* Checks follows references, example inputs, event origins, and output
|
|
129
|
+
* schema presence. Returns `Result.ok()` when no issues are found, or
|
|
130
|
+
* `Result.err(ValidationError)` with all issues in the error context.
|
|
131
|
+
*/
|
|
132
|
+
export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
|
|
133
|
+
const issues = [
|
|
134
|
+
...checkFollows(topo.hikes, topo),
|
|
135
|
+
...checkExamples(topo.trails),
|
|
136
|
+
...checkEventOrigins(topo.events, topo),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
if (issues.length === 0) {
|
|
140
|
+
return Result.ok();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Result.err(
|
|
144
|
+
new ValidationError(
|
|
145
|
+
`Topo validation failed with ${issues.length} issue(s)`,
|
|
146
|
+
{
|
|
147
|
+
context: { issues },
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation utilities for @ontrails/core
|
|
3
|
+
*
|
|
4
|
+
* Wraps Zod parsing into Result types and provides JSON Schema conversion
|
|
5
|
+
* for trail input schemas.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import { ValidationError } from './errors.js';
|
|
11
|
+
import { Result } from './result.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Zod → JSON Schema (Zod v4)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Internal accessor for Zod v4's internals. */
|
|
18
|
+
interface ZodInternals {
|
|
19
|
+
readonly _zod: {
|
|
20
|
+
readonly def: Readonly<Record<string, unknown>>;
|
|
21
|
+
readonly traits: ReadonlySet<string>;
|
|
22
|
+
};
|
|
23
|
+
readonly description?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type JsonSchema = Record<string, unknown>;
|
|
27
|
+
type JsonSchemaConverter = (schema: z.ZodType) => JsonSchema;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Internal helpers (defined before usage)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const isOptionalLike = (s: ZodInternals): boolean => {
|
|
34
|
+
const defType = s._zod.def['type'] as string;
|
|
35
|
+
return defType === 'optional' || defType === 'default';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Issue formatting
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Format each ZodIssue as "path: message" (or just "message" for root). */
|
|
43
|
+
export const formatZodIssues = (issues: z.ZodIssue[]): string[] =>
|
|
44
|
+
issues.map((issue) => {
|
|
45
|
+
const path = issue.path.join('.');
|
|
46
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Input validation
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Parse unknown data against a Zod schema, returning a Result. */
|
|
54
|
+
export const validateInput = <T>(
|
|
55
|
+
schema: z.ZodType<T>,
|
|
56
|
+
data: unknown
|
|
57
|
+
): Result<T, ValidationError> => {
|
|
58
|
+
const parsed = schema.safeParse(data);
|
|
59
|
+
if (parsed.success) {
|
|
60
|
+
return Result.ok(parsed.data);
|
|
61
|
+
}
|
|
62
|
+
const messages = formatZodIssues(parsed.error.issues);
|
|
63
|
+
return Result.err(
|
|
64
|
+
new ValidationError(messages.join('; '), {
|
|
65
|
+
cause: parsed.error,
|
|
66
|
+
context: { issues: parsed.error.issues },
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Output validation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Parse unknown data against a Zod schema, returning a Result suitable for output validation. */
|
|
76
|
+
export const validateOutput = <T>(
|
|
77
|
+
schema: z.ZodType<T>,
|
|
78
|
+
data: unknown
|
|
79
|
+
): Result<T, ValidationError> => {
|
|
80
|
+
const parsed = schema.safeParse(data);
|
|
81
|
+
if (parsed.success) {
|
|
82
|
+
return Result.ok(parsed.data);
|
|
83
|
+
}
|
|
84
|
+
const messages = formatZodIssues(parsed.error.issues);
|
|
85
|
+
return Result.err(
|
|
86
|
+
new ValidationError(`Output validation failed: ${messages.join('; ')}`, {
|
|
87
|
+
cause: parsed.error,
|
|
88
|
+
context: { issues: parsed.error.issues },
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Zod → JSON Schema (public API)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert common Zod types to a JSON Schema object.
|
|
99
|
+
*
|
|
100
|
+
* Uses Zod v4's `_zod.def` and `_zod.traits` for introspection.
|
|
101
|
+
* Covers: string, number, boolean, object, array, enum, optional,
|
|
102
|
+
* default, union, literal, nullable, and describe.
|
|
103
|
+
*/
|
|
104
|
+
export const zodToJsonSchema: JsonSchemaConverter = (
|
|
105
|
+
schema: z.ZodType
|
|
106
|
+
): JsonSchema => {
|
|
107
|
+
const s = schema as unknown as ZodInternals;
|
|
108
|
+
|
|
109
|
+
const collectObjectFields = (shape: Record<string, ZodInternals>) => {
|
|
110
|
+
const properties: JsonSchema = {};
|
|
111
|
+
const required: string[] = [];
|
|
112
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
113
|
+
properties[key] = zodToJsonSchema(value as unknown as z.ZodType);
|
|
114
|
+
if (!isOptionalLike(value)) {
|
|
115
|
+
required.push(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { properties, required };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const convertObject = (value: ZodInternals): JsonSchema => {
|
|
122
|
+
const shape = value._zod.def['shape'] as
|
|
123
|
+
| Record<string, ZodInternals>
|
|
124
|
+
| undefined;
|
|
125
|
+
if (!shape) {
|
|
126
|
+
return { type: 'object' };
|
|
127
|
+
}
|
|
128
|
+
const { properties, required } = collectObjectFields(shape);
|
|
129
|
+
const result: JsonSchema = { properties, type: 'object' };
|
|
130
|
+
if (required.length > 0) {
|
|
131
|
+
result['required'] = required;
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const zodConverters: Record<string, (value: ZodInternals) => JsonSchema> = {
|
|
137
|
+
array: (value) => {
|
|
138
|
+
const element = value._zod.def['element'] as unknown as z.ZodType;
|
|
139
|
+
return { items: zodToJsonSchema(element), type: 'array' };
|
|
140
|
+
},
|
|
141
|
+
boolean: () => ({ type: 'boolean' }),
|
|
142
|
+
default: (value) => {
|
|
143
|
+
const inner = value._zod.def['innerType'] as unknown as z.ZodType;
|
|
144
|
+
const innerSchema = zodToJsonSchema(inner);
|
|
145
|
+
const rawDefault = value._zod.def['defaultValue'];
|
|
146
|
+
innerSchema['default'] =
|
|
147
|
+
typeof rawDefault === 'function' ? rawDefault() : rawDefault;
|
|
148
|
+
return innerSchema;
|
|
149
|
+
},
|
|
150
|
+
enum: (value) => {
|
|
151
|
+
const entries = value._zod.def['entries'] as Record<string, string>;
|
|
152
|
+
return { enum: Object.values(entries), type: 'string' };
|
|
153
|
+
},
|
|
154
|
+
literal: (value) => {
|
|
155
|
+
const values = value._zod.def['values'] as unknown[];
|
|
156
|
+
return { const: values[0] };
|
|
157
|
+
},
|
|
158
|
+
nullable: (value) => {
|
|
159
|
+
const inner = value._zod.def['innerType'] as unknown as z.ZodType;
|
|
160
|
+
return { anyOf: [zodToJsonSchema(inner), { type: 'null' }] };
|
|
161
|
+
},
|
|
162
|
+
number: () => ({ type: 'number' }),
|
|
163
|
+
object: convertObject,
|
|
164
|
+
optional: (value) => {
|
|
165
|
+
const inner = value._zod.def['innerType'] as unknown as z.ZodType;
|
|
166
|
+
return zodToJsonSchema(inner);
|
|
167
|
+
},
|
|
168
|
+
string: () => ({ type: 'string' }),
|
|
169
|
+
union: (value) => {
|
|
170
|
+
const options = value._zod.def['options'] as unknown as z.ZodType[];
|
|
171
|
+
return { anyOf: options.map((option) => zodToJsonSchema(option)) };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const converter = zodConverters[s._zod.def['type'] as string];
|
|
176
|
+
const base = converter ? converter(s) : {};
|
|
177
|
+
|
|
178
|
+
if (s.description) {
|
|
179
|
+
base['description'] = s.description;
|
|
180
|
+
}
|
|
181
|
+
return base;
|
|
182
|
+
};
|