@ontrails/core 1.0.0-beta.1 → 1.0.0-beta.11
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-lint.log +1 -1
- package/CHANGELOG.md +121 -0
- package/README.md +54 -11
- package/dist/context.d.ts +2 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +12 -7
- package/dist/context.js.map +1 -1
- package/dist/derive.d.ts +1 -1
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +4 -1
- package/dist/derive.js.map +1 -1
- package/dist/dispatch.d.ts +27 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +34 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/event.d.ts +2 -2
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +1 -1
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +33 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +207 -0
- package/dist/execute.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/patterns/status.d.ts +1 -1
- package/dist/result.d.ts.map +1 -1
- package/dist/result.js +15 -4
- package/dist/result.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +45 -7
- package/dist/serialization.js.map +1 -1
- package/dist/service.d.ts +69 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +56 -0
- package/dist/service.js.map +1 -0
- package/dist/topo.d.ts +11 -4
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +43 -18
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +21 -10
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +5 -2
- package/dist/trail.js.map +1 -1
- package/dist/type-utils.d.ts +24 -0
- package/dist/type-utils.d.ts.map +1 -0
- package/dist/type-utils.js +12 -0
- package/dist/type-utils.js.map +1 -0
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-topo.d.ts +2 -2
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +75 -9
- package/dist/validate-topo.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +34 -3
- package/dist/validation.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/context.test.ts +16 -5
- package/src/__tests__/derive.test.ts +44 -0
- package/src/__tests__/dispatch.test.ts +181 -0
- package/src/__tests__/event.test.ts +5 -5
- package/src/__tests__/execute.test.ts +523 -0
- package/src/__tests__/layer.test.ts +14 -113
- package/src/__tests__/serialization.test.ts +166 -1
- package/src/__tests__/service.test.ts +197 -0
- package/src/__tests__/topo.test.ts +171 -78
- package/src/__tests__/trail.test.ts +119 -37
- package/src/__tests__/type-utils.test.ts +90 -0
- package/src/__tests__/validate-topo.test.ts +140 -19
- package/src/__tests__/validation.test.ts +53 -0
- package/src/context.ts +18 -9
- package/src/derive.ts +12 -2
- package/src/dispatch.ts +54 -0
- package/src/event.ts +3 -3
- package/src/execute.ts +345 -0
- package/src/index.ts +39 -18
- package/src/result.ts +18 -4
- package/src/serialization.ts +56 -11
- package/src/service.ts +139 -0
- package/src/topo.ts +66 -27
- package/src/trail.ts +36 -13
- package/src/type-utils.ts +45 -0
- package/src/types.ts +11 -2
- package/src/validate-topo.ts +92 -10
- package/src/validation.ts +35 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -36
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -20
- package/dist/hike.js.map +0 -1
- package/src/__tests__/hike.test.ts +0 -117
- package/src/__tests__/job.test.ts +0 -98
- package/src/adapters.ts +0 -68
- package/src/health.ts +0 -23
- package/src/hike.ts +0 -77
- package/src/job.ts +0 -20
package/src/service.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { NotFoundError } from './errors.js';
|
|
2
|
+
import type { Result } from './result.js';
|
|
3
|
+
import type { ServiceLookup, TrailContext } from './types.js';
|
|
4
|
+
import type { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stable process-scoped fields available when constructing a service.
|
|
8
|
+
*
|
|
9
|
+
* Services are app-level singletons, so they intentionally do not receive the
|
|
10
|
+
* full per-request TrailContext.
|
|
11
|
+
*/
|
|
12
|
+
export type ServiceContext = Pick<
|
|
13
|
+
TrailContext,
|
|
14
|
+
'cwd' | 'env' | 'workspaceRoot'
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything needed to describe a service before a factory is introduced.
|
|
19
|
+
*/
|
|
20
|
+
export interface ServiceSpec<T> {
|
|
21
|
+
/** Create the service instance from stable process-scoped context. */
|
|
22
|
+
readonly create: (
|
|
23
|
+
svc: ServiceContext
|
|
24
|
+
) => Result<T, Error> | Promise<Result<T, Error>>;
|
|
25
|
+
/** Reserved config schema for follow-up config composition work. */
|
|
26
|
+
readonly config?: z.ZodType | undefined;
|
|
27
|
+
/** Optional cleanup performed when the hosting surface shuts down. */
|
|
28
|
+
readonly dispose?: ((service: T) => void | Promise<void>) | undefined;
|
|
29
|
+
/** Optional operational readiness probe for introspection tooling. */
|
|
30
|
+
readonly health?:
|
|
31
|
+
| ((service: T) => Result<unknown, Error> | Promise<Result<unknown, Error>>)
|
|
32
|
+
| undefined;
|
|
33
|
+
/** Optional test factory used by higher-level helpers. */
|
|
34
|
+
readonly mock?: (() => T | Promise<T>) | undefined;
|
|
35
|
+
/** Human-readable description. */
|
|
36
|
+
readonly description?: string | undefined;
|
|
37
|
+
/** Arbitrary metadata for tooling and filtering. */
|
|
38
|
+
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A typed service definition.
|
|
43
|
+
*
|
|
44
|
+
* TRL-73 introduces the structural contract only. The `service()` factory and
|
|
45
|
+
* runtime helpers land in follow-up branches.
|
|
46
|
+
*/
|
|
47
|
+
export interface Service<T> extends ServiceSpec<T> {
|
|
48
|
+
readonly kind: 'service';
|
|
49
|
+
readonly id: string;
|
|
50
|
+
/** Read the resolved service instance from a trail context. */
|
|
51
|
+
from(ctx: TrailContext): T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Existential type for heterogeneous service collections.
|
|
56
|
+
*
|
|
57
|
+
* `Service<T>` includes function parameters in `dispose`/`health`, so `unknown`
|
|
58
|
+
* is too narrow for mixed service arrays. `any` is the correct existential here.
|
|
59
|
+
*/
|
|
60
|
+
// oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous service collections
|
|
61
|
+
export type AnyService = Service<any>;
|
|
62
|
+
|
|
63
|
+
/** Explicit runtime overrides keyed by service ID. */
|
|
64
|
+
export type ServiceOverrideMap = Readonly<Record<string, unknown>>;
|
|
65
|
+
|
|
66
|
+
const getServiceId = <T>(
|
|
67
|
+
serviceOrId: string | Pick<Service<T>, 'id'>
|
|
68
|
+
): string => (typeof serviceOrId === 'string' ? serviceOrId : serviceOrId.id);
|
|
69
|
+
|
|
70
|
+
const getServiceInstance = <T>(
|
|
71
|
+
ctx: Pick<TrailContext, 'extensions'>,
|
|
72
|
+
serviceOrId: string | Pick<Service<T>, 'id'>
|
|
73
|
+
): T => {
|
|
74
|
+
const id = getServiceId(serviceOrId);
|
|
75
|
+
return ctx.extensions?.[id] as T;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const hasServiceInstance = (
|
|
79
|
+
ctx: Pick<TrailContext, 'extensions'>,
|
|
80
|
+
serviceOrId: string | Pick<AnyService, 'id'>
|
|
81
|
+
): boolean => Object.hasOwn(ctx.extensions ?? {}, getServiceId(serviceOrId));
|
|
82
|
+
|
|
83
|
+
/** Create a `ctx.service(...)` accessor bound to a concrete context snapshot. */
|
|
84
|
+
export const createServiceLookup = (
|
|
85
|
+
getContext: () => Pick<TrailContext, 'extensions'>
|
|
86
|
+
): ServiceLookup =>
|
|
87
|
+
((serviceOrId: string | Pick<AnyService, 'id'>) => {
|
|
88
|
+
const id = getServiceId(serviceOrId);
|
|
89
|
+
const ctx = getContext();
|
|
90
|
+
if (!hasServiceInstance(ctx, id)) {
|
|
91
|
+
throw new NotFoundError(`Service "${id}" not found in trail context`);
|
|
92
|
+
}
|
|
93
|
+
return getServiceInstance(ctx, id);
|
|
94
|
+
}) as ServiceLookup;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a typed service definition.
|
|
98
|
+
*
|
|
99
|
+
* The service object is inert until a later execution branch resolves concrete
|
|
100
|
+
* instances into TrailContext extensions.
|
|
101
|
+
*/
|
|
102
|
+
export const service = <T>(id: string, spec: ServiceSpec<T>): Service<T> =>
|
|
103
|
+
Object.freeze({
|
|
104
|
+
...spec,
|
|
105
|
+
from(ctx: TrailContext): T {
|
|
106
|
+
const lookup = ctx.service ?? createServiceLookup(() => ctx);
|
|
107
|
+
return lookup(this);
|
|
108
|
+
},
|
|
109
|
+
id,
|
|
110
|
+
kind: 'service' as const,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Narrow unknown values to service definitions during topo discovery. */
|
|
114
|
+
export const isService = (value: unknown): value is AnyService => {
|
|
115
|
+
if (typeof value !== 'object' || value === null) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const v = value as { kind?: unknown; id?: unknown };
|
|
119
|
+
return v.kind === 'service' && typeof v.id === 'string';
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Return the first duplicate service ID in a collection, if any.
|
|
124
|
+
*
|
|
125
|
+
* This supports later topo registration without each caller duplicating the
|
|
126
|
+
* same scan logic.
|
|
127
|
+
*/
|
|
128
|
+
export const findDuplicateServiceId = (
|
|
129
|
+
services: readonly Pick<AnyService, 'id'>[]
|
|
130
|
+
): string | undefined => {
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
for (const candidate of services) {
|
|
133
|
+
if (seen.has(candidate.id)) {
|
|
134
|
+
return candidate.id;
|
|
135
|
+
}
|
|
136
|
+
seen.add(candidate.id);
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
};
|
package/src/topo.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { ValidationError } from './errors.js';
|
|
6
6
|
import type { AnyEvent } from './event.js';
|
|
7
|
-
import type {
|
|
7
|
+
import type { AnyService } from './service.js';
|
|
8
|
+
import { isService } from './service.js';
|
|
8
9
|
import type { AnyTrail } from './trail.js';
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
@@ -14,26 +15,33 @@ import type { AnyTrail } from './trail.js';
|
|
|
14
15
|
export interface Topo {
|
|
15
16
|
readonly name: string;
|
|
16
17
|
readonly trails: ReadonlyMap<string, AnyTrail>;
|
|
17
|
-
readonly hikes: ReadonlyMap<string, AnyHike>;
|
|
18
18
|
readonly events: ReadonlyMap<string, AnyEvent>;
|
|
19
|
-
|
|
19
|
+
readonly services: ReadonlyMap<string, AnyService>;
|
|
20
|
+
readonly count: number;
|
|
21
|
+
readonly serviceCount: number;
|
|
22
|
+
get(id: string): AnyTrail | undefined;
|
|
23
|
+
getService(id: string): AnyService | undefined;
|
|
20
24
|
has(id: string): boolean;
|
|
21
|
-
|
|
25
|
+
hasService(id: string): boolean;
|
|
26
|
+
ids(): string[];
|
|
27
|
+
serviceIds(): string[];
|
|
28
|
+
list(): AnyTrail[];
|
|
22
29
|
listEvents(): AnyEvent[];
|
|
30
|
+
listServices(): AnyService[];
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
// ---------------------------------------------------------------------------
|
|
26
34
|
// Kind discriminant check
|
|
27
35
|
// ---------------------------------------------------------------------------
|
|
28
36
|
|
|
29
|
-
type Registrable = AnyTrail |
|
|
37
|
+
type Registrable = AnyTrail | AnyEvent | AnyService;
|
|
30
38
|
|
|
31
39
|
const isRegistrable = (value: unknown): value is Registrable => {
|
|
32
40
|
if (typeof value !== 'object' || value === null) {
|
|
33
41
|
return false;
|
|
34
42
|
}
|
|
35
43
|
const { kind } = value as Record<string, unknown>;
|
|
36
|
-
return kind === 'trail' || kind === '
|
|
44
|
+
return kind === 'trail' || kind === 'event';
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
// ---------------------------------------------------------------------------
|
|
@@ -43,28 +51,46 @@ const isRegistrable = (value: unknown): value is Registrable => {
|
|
|
43
51
|
const createTopo = (
|
|
44
52
|
name: string,
|
|
45
53
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
events: ReadonlyMap<string, AnyEvent>,
|
|
55
|
+
services: ReadonlyMap<string, AnyService>
|
|
48
56
|
): Topo => ({
|
|
57
|
+
count: trails.size,
|
|
49
58
|
events,
|
|
50
|
-
get(id: string): AnyTrail |
|
|
51
|
-
return trails.get(id)
|
|
59
|
+
get(id: string): AnyTrail | undefined {
|
|
60
|
+
return trails.get(id);
|
|
61
|
+
},
|
|
62
|
+
getService(id: string): AnyService | undefined {
|
|
63
|
+
return services.get(id);
|
|
52
64
|
},
|
|
53
65
|
has(id: string): boolean {
|
|
54
|
-
return trails.has(id)
|
|
66
|
+
return trails.has(id);
|
|
67
|
+
},
|
|
68
|
+
hasService(id: string): boolean {
|
|
69
|
+
return services.has(id);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
ids(): string[] {
|
|
73
|
+
return [...trails.keys()];
|
|
55
74
|
},
|
|
56
|
-
hikes,
|
|
57
75
|
|
|
58
|
-
list():
|
|
59
|
-
return [...trails.values()
|
|
76
|
+
list(): AnyTrail[] {
|
|
77
|
+
return [...trails.values()];
|
|
60
78
|
},
|
|
61
79
|
|
|
62
80
|
listEvents(): AnyEvent[] {
|
|
63
81
|
return [...events.values()];
|
|
64
82
|
},
|
|
83
|
+
listServices(): AnyService[] {
|
|
84
|
+
return [...services.values()];
|
|
85
|
+
},
|
|
65
86
|
|
|
66
87
|
name,
|
|
88
|
+
serviceCount: services.size,
|
|
89
|
+
serviceIds(): string[] {
|
|
90
|
+
return [...services.keys()];
|
|
91
|
+
},
|
|
67
92
|
|
|
93
|
+
services,
|
|
68
94
|
trails,
|
|
69
95
|
});
|
|
70
96
|
|
|
@@ -76,8 +102,8 @@ const createTopo = (
|
|
|
76
102
|
const register = (
|
|
77
103
|
value: Registrable,
|
|
78
104
|
trails: Map<string, AnyTrail>,
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
events: Map<string, AnyEvent>,
|
|
106
|
+
services: Map<string, AnyService>
|
|
81
107
|
): void => {
|
|
82
108
|
const { id } = value as { id: string };
|
|
83
109
|
const registrars: Record<string, () => void> = {
|
|
@@ -87,11 +113,11 @@ const register = (
|
|
|
87
113
|
}
|
|
88
114
|
events.set(id, value as AnyEvent);
|
|
89
115
|
},
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
throw new ValidationError(`Duplicate
|
|
116
|
+
service: () => {
|
|
117
|
+
if (services.has(id)) {
|
|
118
|
+
throw new ValidationError(`Duplicate service ID: "${id}"`);
|
|
93
119
|
}
|
|
94
|
-
|
|
120
|
+
services.set(id, value as AnyService);
|
|
95
121
|
},
|
|
96
122
|
trail: () => {
|
|
97
123
|
if (trails.has(id)) {
|
|
@@ -103,21 +129,34 @@ const register = (
|
|
|
103
129
|
registrars[value.kind]?.();
|
|
104
130
|
};
|
|
105
131
|
|
|
132
|
+
const registerModuleValues = (
|
|
133
|
+
mod: Record<string, unknown>,
|
|
134
|
+
trails: Map<string, AnyTrail>,
|
|
135
|
+
events: Map<string, AnyEvent>,
|
|
136
|
+
services: Map<string, AnyService>
|
|
137
|
+
): void => {
|
|
138
|
+
for (const value of Object.values(mod)) {
|
|
139
|
+
if (isService(value)) {
|
|
140
|
+
register(value, trails, events, services);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (isRegistrable(value)) {
|
|
144
|
+
register(value, trails, events, services);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
106
149
|
export const topo = (
|
|
107
150
|
name: string,
|
|
108
151
|
...modules: Record<string, unknown>[]
|
|
109
152
|
): Topo => {
|
|
110
153
|
const trails = new Map<string, AnyTrail>();
|
|
111
|
-
const hikes = new Map<string, AnyHike>();
|
|
112
154
|
const events = new Map<string, AnyEvent>();
|
|
155
|
+
const services = new Map<string, AnyService>();
|
|
113
156
|
|
|
114
157
|
for (const mod of modules) {
|
|
115
|
-
|
|
116
|
-
if (isRegistrable(value)) {
|
|
117
|
-
register(value, trails, hikes, events);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
158
|
+
registerModuleValues(mod, trails, events, services);
|
|
120
159
|
}
|
|
121
160
|
|
|
122
|
-
return createTopo(name, trails,
|
|
161
|
+
return createTopo(name, trails, events, services);
|
|
123
162
|
};
|
package/src/trail.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
import type { FieldOverride } from './derive.js';
|
|
4
4
|
import type { Result } from './result.js';
|
|
5
|
+
import type { AnyService } from './service.js';
|
|
5
6
|
import type { Implementation, TrailContext } from './types.js';
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -39,34 +40,48 @@ export interface TrailSpec<I, O> {
|
|
|
39
40
|
/** Zod schema for validating output (optional — some trails are fire-and-forget) */
|
|
40
41
|
readonly output?: z.ZodType<O> | undefined;
|
|
41
42
|
/** The pure function that does the work (sync or async authoring) */
|
|
42
|
-
readonly
|
|
43
|
+
readonly run: Implementation<I, O>;
|
|
43
44
|
/** Human-readable description */
|
|
44
45
|
readonly description?: string | undefined;
|
|
45
46
|
/** Named examples for docs and testing */
|
|
46
47
|
readonly examples?: readonly TrailExample<I, O>[] | undefined;
|
|
47
|
-
/**
|
|
48
|
-
readonly
|
|
49
|
-
/** Trail is destructive (deletes or overwrites data) */
|
|
50
|
-
readonly destructive?: boolean | undefined;
|
|
48
|
+
/** What this trail does to the world: read, write (default), or destroy */
|
|
49
|
+
readonly intent?: 'read' | 'write' | 'destroy' | undefined;
|
|
51
50
|
/** Trail is idempotent (safe to retry) */
|
|
52
51
|
readonly idempotent?: boolean | undefined;
|
|
53
52
|
/** Arbitrary metadata for tooling and filtering */
|
|
54
|
-
readonly
|
|
53
|
+
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
|
55
54
|
/** Named sets of downstream trail IDs that may be invoked */
|
|
56
55
|
readonly detours?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
57
56
|
/** Per-field overrides for deriveFields() (labels, hints, options) */
|
|
58
57
|
readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
|
|
58
|
+
/** IDs of downstream trails this trail may invoke via ctx.follow() */
|
|
59
|
+
readonly follow?: readonly string[] | undefined;
|
|
60
|
+
/** Services this trail may access via service.from(ctx) */
|
|
61
|
+
readonly services?: readonly AnyService[] | undefined;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// ---------------------------------------------------------------------------
|
|
62
65
|
// Trail (the frozen runtime object)
|
|
63
66
|
// ---------------------------------------------------------------------------
|
|
64
67
|
|
|
68
|
+
/** Intent describes what a trail does to the world */
|
|
69
|
+
export type Intent = 'read' | 'write' | 'destroy';
|
|
70
|
+
|
|
65
71
|
/** A fully-defined trail — the unit of work in the Trails system */
|
|
66
|
-
export interface Trail<I, O> extends Omit<
|
|
72
|
+
export interface Trail<I, O> extends Omit<
|
|
73
|
+
TrailSpec<I, O>,
|
|
74
|
+
'run' | 'follow' | 'intent' | 'services'
|
|
75
|
+
> {
|
|
67
76
|
readonly kind: 'trail';
|
|
68
77
|
readonly id: string;
|
|
69
|
-
readonly
|
|
78
|
+
readonly run: Implementation<I, O>;
|
|
79
|
+
/** IDs of downstream trails this trail may invoke via ctx.follow() (always present, default []) */
|
|
80
|
+
readonly follow: readonly string[];
|
|
81
|
+
/** Services this trail may access via service.from(ctx) (always present, default []) */
|
|
82
|
+
readonly services: readonly AnyService[];
|
|
83
|
+
/** What this trail does to the world (always present, default 'write') */
|
|
84
|
+
readonly intent: Intent;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
// ---------------------------------------------------------------------------
|
|
@@ -84,14 +99,14 @@ export interface Trail<I, O> extends Omit<TrailSpec<I, O>, 'implementation'> {
|
|
|
84
99
|
* // ID as first argument (recommended for human authoring)
|
|
85
100
|
* const show = trail("entity.show", {
|
|
86
101
|
* input: z.object({ name: z.string() }),
|
|
87
|
-
*
|
|
102
|
+
* run: (input) => Result.ok(entity),
|
|
88
103
|
* });
|
|
89
104
|
*
|
|
90
105
|
* // Full spec object (for programmatic generation)
|
|
91
106
|
* const show = trail({
|
|
92
107
|
* id: "entity.show",
|
|
93
108
|
* input: z.object({ name: z.string() }),
|
|
94
|
-
*
|
|
109
|
+
* run: (input) => Result.ok(entity),
|
|
95
110
|
* });
|
|
96
111
|
* ```
|
|
97
112
|
*/
|
|
@@ -112,14 +127,22 @@ export function trail<I, O>(
|
|
|
112
127
|
throw new TypeError('trail() requires a spec when an id is provided');
|
|
113
128
|
}
|
|
114
129
|
|
|
115
|
-
const {
|
|
130
|
+
const {
|
|
131
|
+
run,
|
|
132
|
+
follow: rawFollow,
|
|
133
|
+
intent: rawIntent,
|
|
134
|
+
services: rawServices,
|
|
135
|
+
...spec
|
|
136
|
+
} = resolved.spec;
|
|
116
137
|
|
|
117
138
|
return Object.freeze({
|
|
118
139
|
...spec,
|
|
140
|
+
follow: Object.freeze([...(rawFollow ?? [])]),
|
|
119
141
|
id: resolved.id,
|
|
120
|
-
|
|
121
|
-
await implementation(input, ctx),
|
|
142
|
+
intent: rawIntent ?? 'write',
|
|
122
143
|
kind: 'trail' as const,
|
|
144
|
+
run: async (input: I, ctx: TrailContext) => await run(input, ctx),
|
|
145
|
+
services: Object.freeze([...(rawServices ?? [])]),
|
|
123
146
|
});
|
|
124
147
|
}
|
|
125
148
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type utilities for extracting input/output types from trails.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Result } from './result.js';
|
|
6
|
+
import type { AnyTrail, Trail } from './trail.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Utility types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/* oxlint-disable no-explicit-any -- `any` required for conditional type inference; `unknown` breaks inference */
|
|
13
|
+
|
|
14
|
+
/** Extract the input type from a Trail. */
|
|
15
|
+
export type TrailInput<T extends AnyTrail> =
|
|
16
|
+
T extends Trail<infer I, any> ? I : never;
|
|
17
|
+
|
|
18
|
+
/** Extract the output type from a Trail. */
|
|
19
|
+
export type TrailOutput<T extends AnyTrail> =
|
|
20
|
+
T extends Trail<any, infer O> ? O : never;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extracts the full `Result<Output, Error>` type from a trail definition.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* type SearchResult = TrailResult<typeof searchTrail>;
|
|
28
|
+
* // Result<{ results: Item[]; count: number }, Error>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export type TrailResult<T extends AnyTrail> = Result<TrailOutput<T>, Error>;
|
|
32
|
+
|
|
33
|
+
/* oxlint-enable no-explicit-any */
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Runtime schema accessors
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Get the input Zod schema from a trail, preserving the specific schema type. */
|
|
40
|
+
export const inputOf = <T extends AnyTrail>(trail: T): T['input'] =>
|
|
41
|
+
trail.input;
|
|
42
|
+
|
|
43
|
+
/** Get the output Zod schema from a trail, if defined, preserving the specific schema type. */
|
|
44
|
+
export const outputOf = <T extends AnyTrail>(trail: T): T['output'] =>
|
|
45
|
+
trail.output;
|
package/src/types.ts
CHANGED
|
@@ -17,6 +17,11 @@ export type FollowFn = <O>(
|
|
|
17
17
|
input: unknown
|
|
18
18
|
) => Promise<Result<O, Error>>;
|
|
19
19
|
|
|
20
|
+
/** Resolve a service instance from the current trail context. */
|
|
21
|
+
export type ServiceLookup = <T = unknown>(
|
|
22
|
+
serviceOrId: { readonly id: string } | string
|
|
23
|
+
) => T;
|
|
24
|
+
|
|
20
25
|
/** Callback for reporting progress from long-running trails */
|
|
21
26
|
export type ProgressCallback = (event: ProgressEvent) => void;
|
|
22
27
|
|
|
@@ -52,7 +57,11 @@ export interface TrailContext {
|
|
|
52
57
|
readonly progress?: ProgressCallback | undefined;
|
|
53
58
|
readonly cwd?: string | undefined;
|
|
54
59
|
readonly env?: Record<string, string | undefined> | undefined;
|
|
55
|
-
readonly
|
|
60
|
+
readonly extensions?: Readonly<Record<string, unknown>> | undefined;
|
|
61
|
+
readonly service?: ServiceLookup | undefined;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
/** Input shape used to seed a runtime TrailContext before resolution. */
|
|
65
|
+
export type TrailContextInit = Omit<TrailContext, 'service'> & {
|
|
66
|
+
readonly service?: ServiceLookup | undefined;
|
|
67
|
+
};
|
package/src/validate-topo.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structural validation for a Topo graph.
|
|
3
3
|
*
|
|
4
|
-
* Checks
|
|
4
|
+
* Checks trail follow references, example input validity, event origin
|
|
5
5
|
* references, and output schema completeness. Returns a Result with all
|
|
6
6
|
* issues collected into a single ValidationError.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ValidationError } from './errors.js';
|
|
10
10
|
import type { AnyEvent } from './event.js';
|
|
11
|
-
import type { AnyHike } from './hike.js';
|
|
12
11
|
import { Result } from './result.js';
|
|
13
12
|
import type { Topo } from './topo.js';
|
|
14
13
|
import type { AnyTrail } from './trail.js';
|
|
@@ -28,28 +27,110 @@ export interface TopoIssue {
|
|
|
28
27
|
// Validators
|
|
29
28
|
// ---------------------------------------------------------------------------
|
|
30
29
|
|
|
30
|
+
const WHITE = 0;
|
|
31
|
+
const GRAY = 1;
|
|
32
|
+
const BLACK = 2;
|
|
33
|
+
|
|
34
|
+
/** Build an adjacency list and initial color map from trails with follow. */
|
|
35
|
+
const buildFollowGraph = (
|
|
36
|
+
trails: ReadonlyMap<string, AnyTrail>
|
|
37
|
+
): {
|
|
38
|
+
graph: Map<string, readonly string[]>;
|
|
39
|
+
color: Map<string, number>;
|
|
40
|
+
} => {
|
|
41
|
+
const graph = new Map<string, readonly string[]>();
|
|
42
|
+
for (const [id, t] of trails) {
|
|
43
|
+
if (t.follow.length > 0) {
|
|
44
|
+
graph.set(id, t.follow);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const color = new Map<string, number>();
|
|
48
|
+
for (const id of graph.keys()) {
|
|
49
|
+
color.set(id, WHITE);
|
|
50
|
+
}
|
|
51
|
+
return { color, graph };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Detect multi-node cycles in the trail follow graph via DFS. */
|
|
55
|
+
const detectFollowCycles = (
|
|
56
|
+
trails: ReadonlyMap<string, AnyTrail>
|
|
57
|
+
): TopoIssue[] => {
|
|
58
|
+
const issues: TopoIssue[] = [];
|
|
59
|
+
const { color, graph } = buildFollowGraph(trails);
|
|
60
|
+
|
|
61
|
+
const dfs = (node: string, path: string[]): void => {
|
|
62
|
+
color.set(node, GRAY);
|
|
63
|
+
for (const next of graph.get(node) ?? []) {
|
|
64
|
+
if (!graph.has(next)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const c = color.get(next) ?? WHITE;
|
|
68
|
+
if (c === GRAY) {
|
|
69
|
+
const cycle = [...path.slice(path.indexOf(next)), next];
|
|
70
|
+
issues.push({
|
|
71
|
+
message: `Cycle detected: ${cycle.join(' → ')}`,
|
|
72
|
+
rule: 'follow-cycle',
|
|
73
|
+
trailId: next,
|
|
74
|
+
});
|
|
75
|
+
} else if (c === WHITE) {
|
|
76
|
+
dfs(next, [...path, next]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
color.set(node, BLACK);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const id of graph.keys()) {
|
|
83
|
+
if (color.get(id) === WHITE) {
|
|
84
|
+
dfs(id, [id]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return issues;
|
|
88
|
+
};
|
|
89
|
+
|
|
31
90
|
const checkFollows = (
|
|
32
|
-
|
|
91
|
+
trails: ReadonlyMap<string, AnyTrail>,
|
|
33
92
|
topo: Topo
|
|
34
93
|
): TopoIssue[] => {
|
|
35
94
|
const issues: TopoIssue[] = [];
|
|
36
|
-
for (const [id,
|
|
37
|
-
for (const followId of
|
|
95
|
+
for (const [id, trail] of trails) {
|
|
96
|
+
for (const followId of trail.follow) {
|
|
38
97
|
if (followId === id) {
|
|
39
98
|
issues.push({
|
|
40
|
-
message: `
|
|
99
|
+
message: `Trail follows itself`,
|
|
41
100
|
rule: 'no-self-follow',
|
|
42
101
|
trailId: id,
|
|
43
102
|
});
|
|
44
103
|
} else if (!topo.has(followId)) {
|
|
45
104
|
issues.push({
|
|
46
105
|
message: `Follows "${followId}" which is not in the topo`,
|
|
47
|
-
rule: '
|
|
106
|
+
rule: 'follow-exists',
|
|
107
|
+
trailId: id,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
issues.push(...detectFollowCycles(trails));
|
|
113
|
+
return issues;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const checkServices = (
|
|
117
|
+
trails: ReadonlyMap<string, AnyTrail>,
|
|
118
|
+
topo: Topo
|
|
119
|
+
): TopoIssue[] => {
|
|
120
|
+
const issues: TopoIssue[] = [];
|
|
121
|
+
|
|
122
|
+
for (const [id, trail] of trails) {
|
|
123
|
+
for (const declaredService of trail.services) {
|
|
124
|
+
if (!topo.hasService(declaredService.id)) {
|
|
125
|
+
issues.push({
|
|
126
|
+
message: `Service "${declaredService.id}" is not in the topo`,
|
|
127
|
+
rule: 'service-exists',
|
|
48
128
|
trailId: id,
|
|
49
129
|
});
|
|
50
130
|
}
|
|
51
131
|
}
|
|
52
132
|
}
|
|
133
|
+
|
|
53
134
|
return issues;
|
|
54
135
|
};
|
|
55
136
|
|
|
@@ -66,7 +147,7 @@ const checkOneExample = (
|
|
|
66
147
|
): TopoIssue[] => {
|
|
67
148
|
const issues: TopoIssue[] = [];
|
|
68
149
|
const result = validateInput(inputSchema as AnyTrail['input'], example.input);
|
|
69
|
-
if (result.isErr() && example.error
|
|
150
|
+
if (result.isErr() && example.error !== 'ValidationError') {
|
|
70
151
|
issues.push({
|
|
71
152
|
message: `Example "${example.name}" input does not parse against schema`,
|
|
72
153
|
rule: 'example-input-valid',
|
|
@@ -125,13 +206,14 @@ const checkEventOrigins = (
|
|
|
125
206
|
/**
|
|
126
207
|
* Validate the structural integrity of a Topo graph.
|
|
127
208
|
*
|
|
128
|
-
* Checks
|
|
209
|
+
* Checks follow references, example inputs, event origins, and output
|
|
129
210
|
* schema presence. Returns `Result.ok()` when no issues are found, or
|
|
130
211
|
* `Result.err(ValidationError)` with all issues in the error context.
|
|
131
212
|
*/
|
|
132
213
|
export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
|
|
133
214
|
const issues = [
|
|
134
|
-
...checkFollows(topo.
|
|
215
|
+
...checkFollows(topo.trails, topo),
|
|
216
|
+
...checkServices(topo.trails, topo),
|
|
135
217
|
...checkExamples(topo.trails),
|
|
136
218
|
...checkEventOrigins(topo.events, topo),
|
|
137
219
|
];
|