@ontrails/core 1.0.0-beta.12 → 1.0.0-beta.13
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/CHANGELOG.md +59 -35
- package/README.md +19 -19
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +5 -4
- package/dist/context.js.map +1 -1
- package/dist/derive.d.ts +8 -3
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +7 -7
- package/dist/derive.js.map +1 -1
- package/dist/event.d.ts +4 -41
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +4 -14
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +10 -10
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +35 -28
- package/dist/execute.js.map +1 -1
- package/dist/gate.d.ts +17 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +21 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/layer.d.ts +5 -16
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +3 -20
- package/dist/layer.js.map +1 -1
- package/dist/provision-config.d.ts +22 -0
- package/dist/provision-config.d.ts.map +1 -0
- package/dist/provision-config.js +210 -0
- package/dist/provision-config.js.map +1 -0
- package/dist/provision.d.ts +71 -0
- package/dist/provision.d.ts.map +1 -0
- package/dist/provision.js +56 -0
- package/dist/provision.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +34 -0
- package/dist/run.js.map +1 -0
- package/dist/service-config.d.ts +7 -7
- package/dist/service-config.d.ts.map +1 -1
- package/dist/service-config.js +75 -73
- package/dist/service-config.js.map +1 -1
- package/dist/service.d.ts +32 -36
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +21 -21
- package/dist/service.js.map +1 -1
- package/dist/signal.d.ts +33 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +17 -0
- package/dist/signal.js.map +1 -0
- package/dist/topo.d.ts +10 -10
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +48 -35
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +16 -16
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +5 -4
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +10 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/validate-topo.d.ts +2 -2
- package/dist/validate-topo.js +31 -31
- package/dist/validate-topo.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/context.test.ts +8 -8
- package/src/__tests__/execute.test.ts +124 -122
- package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
- package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
- package/src/__tests__/service-config.test.ts +41 -37
- package/src/__tests__/service.test.ts +71 -71
- package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
- package/src/__tests__/topo.test.ts +54 -54
- package/src/__tests__/trail-permit.test.ts +4 -4
- package/src/__tests__/trail.test.ts +58 -58
- package/src/__tests__/type-utils.test.ts +3 -3
- package/src/__tests__/validate-topo.test.ts +38 -38
- package/src/context.ts +5 -4
- package/src/derive.ts +8 -8
- package/src/event.ts +11 -73
- package/src/execute.ts +56 -40
- package/src/{layer.ts → gate.ts} +13 -13
- package/src/index.ts +24 -22
- package/src/{service-config.ts → provision-config.ts} +124 -105
- package/src/provision.ts +148 -0
- package/src/{dispatch.ts → run.ts} +8 -8
- package/src/signal.ts +65 -0
- package/src/topo.ts +74 -52
- package/src/trail.ts +23 -22
- package/src/types.ts +11 -11
- package/src/validate-topo.ts +33 -33
- package/tsconfig.tsbuildinfo +1 -1
- package/src/service.ts +0 -145
package/src/provision.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { NotFoundError } from './errors.js';
|
|
2
|
+
import type { Result } from './result.js';
|
|
3
|
+
import type { ProvisionLookup, TrailContext } from './types.js';
|
|
4
|
+
import type { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stable process-scoped fields available when constructing a provision.
|
|
8
|
+
*
|
|
9
|
+
* Provisions are app-level singletons, so they intentionally do not receive
|
|
10
|
+
* the full per-request TrailContext. When a provision declares a `config` schema,
|
|
11
|
+
* the validated config is passed as `svc.config`.
|
|
12
|
+
*/
|
|
13
|
+
export type ProvisionContext<C = unknown> = Pick<
|
|
14
|
+
TrailContext,
|
|
15
|
+
'cwd' | 'env' | 'workspaceRoot'
|
|
16
|
+
> & {
|
|
17
|
+
readonly config: C;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Everything needed to describe a provision before a factory is introduced.
|
|
22
|
+
*
|
|
23
|
+
* When `config` is a Zod schema, the `create` callback receives
|
|
24
|
+
* `ProvisionContext<C>` with the validated config value.
|
|
25
|
+
*/
|
|
26
|
+
export interface ProvisionSpec<T, C = unknown> {
|
|
27
|
+
/** Create the provision instance from stable process-scoped context. */
|
|
28
|
+
readonly create: (
|
|
29
|
+
svc: ProvisionContext<C>
|
|
30
|
+
) => Result<T, Error> | Promise<Result<T, Error>>;
|
|
31
|
+
/** Config schema — when present, config is validated and passed to `create`. */
|
|
32
|
+
readonly config?: z.ZodType<C> | undefined;
|
|
33
|
+
/** Optional cleanup performed when the hosting trailhead shuts down. */
|
|
34
|
+
readonly dispose?: ((provision: T) => void | Promise<void>) | undefined;
|
|
35
|
+
/** Optional operational readiness probe for introspection tooling. */
|
|
36
|
+
readonly health?:
|
|
37
|
+
| ((
|
|
38
|
+
provision: T
|
|
39
|
+
) => Result<unknown, Error> | Promise<Result<unknown, Error>>)
|
|
40
|
+
| undefined;
|
|
41
|
+
/** Optional test factory used by higher-level helpers. */
|
|
42
|
+
readonly mock?: (() => T | Promise<T>) | undefined;
|
|
43
|
+
/** Human-readable description. */
|
|
44
|
+
readonly description?: string | undefined;
|
|
45
|
+
/** Arbitrary meta for tooling and filtering. */
|
|
46
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A typed provision definition. */
|
|
50
|
+
export interface Provision<T> extends ProvisionSpec<T> {
|
|
51
|
+
readonly kind: 'provision';
|
|
52
|
+
readonly id: string;
|
|
53
|
+
/** Read the resolved provision instance from a trail context. */
|
|
54
|
+
from(ctx: TrailContext): T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Existential type for heterogeneous provision collections.
|
|
59
|
+
*
|
|
60
|
+
* `Provision<T>` includes function parameters in `dispose`/`health`, so
|
|
61
|
+
* `unknown` is too narrow for mixed provision arrays. `any` is the correct
|
|
62
|
+
* existential here.
|
|
63
|
+
*/
|
|
64
|
+
// oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous provision collections
|
|
65
|
+
export type AnyProvision = Provision<any>;
|
|
66
|
+
|
|
67
|
+
/** Explicit runtime overrides keyed by provision ID. */
|
|
68
|
+
export type ProvisionOverrideMap = Readonly<Record<string, unknown>>;
|
|
69
|
+
|
|
70
|
+
const getProvisionId = <T>(
|
|
71
|
+
provisionOrId: string | Pick<Provision<T>, 'id'>
|
|
72
|
+
): string =>
|
|
73
|
+
typeof provisionOrId === 'string' ? provisionOrId : provisionOrId.id;
|
|
74
|
+
|
|
75
|
+
const getProvisionInstance = <T>(
|
|
76
|
+
ctx: Pick<TrailContext, 'extensions'>,
|
|
77
|
+
provisionOrId: string | Pick<Provision<T>, 'id'>
|
|
78
|
+
): T => {
|
|
79
|
+
const id = getProvisionId(provisionOrId);
|
|
80
|
+
return ctx.extensions?.[id] as T;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const hasProvisionInstance = (
|
|
84
|
+
ctx: Pick<TrailContext, 'extensions'>,
|
|
85
|
+
provisionOrId: string | Pick<AnyProvision, 'id'>
|
|
86
|
+
): boolean =>
|
|
87
|
+
Object.hasOwn(ctx.extensions ?? {}, getProvisionId(provisionOrId));
|
|
88
|
+
|
|
89
|
+
/** Create a `ctx.provision(...)` accessor bound to a concrete context snapshot. */
|
|
90
|
+
export const createProvisionLookup = (
|
|
91
|
+
getContext: () => Pick<TrailContext, 'extensions'>
|
|
92
|
+
): ProvisionLookup =>
|
|
93
|
+
((provisionOrId: string | Pick<AnyProvision, 'id'>) => {
|
|
94
|
+
const id = getProvisionId(provisionOrId);
|
|
95
|
+
const ctx = getContext();
|
|
96
|
+
if (!hasProvisionInstance(ctx, id)) {
|
|
97
|
+
throw new NotFoundError(`Provision "${id}" not found in trail context`);
|
|
98
|
+
}
|
|
99
|
+
return getProvisionInstance(ctx, id);
|
|
100
|
+
}) as ProvisionLookup;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a typed provision definition.
|
|
104
|
+
*
|
|
105
|
+
* The provision object is inert until a later execution branch resolves concrete
|
|
106
|
+
* instances into TrailContext extensions.
|
|
107
|
+
*/
|
|
108
|
+
export const provision = <T>(
|
|
109
|
+
id: string,
|
|
110
|
+
spec: ProvisionSpec<T>
|
|
111
|
+
): Provision<T> =>
|
|
112
|
+
Object.freeze({
|
|
113
|
+
...spec,
|
|
114
|
+
from(ctx: TrailContext): T {
|
|
115
|
+
const lookup = ctx.provision ?? createProvisionLookup(() => ctx);
|
|
116
|
+
return lookup(this);
|
|
117
|
+
},
|
|
118
|
+
id,
|
|
119
|
+
kind: 'provision' as const,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** Narrow unknown values to provision definitions during topo discovery. */
|
|
123
|
+
export const isProvision = (value: unknown): value is AnyProvision => {
|
|
124
|
+
if (typeof value !== 'object' || value === null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const v = value as { kind?: unknown; id?: unknown };
|
|
128
|
+
return v.kind === 'provision' && typeof v.id === 'string';
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Return the first duplicate provision ID in a collection, if any.
|
|
133
|
+
*
|
|
134
|
+
* This supports later topo registration without each caller duplicating the
|
|
135
|
+
* same scan logic.
|
|
136
|
+
*/
|
|
137
|
+
export const findDuplicateProvisionId = (
|
|
138
|
+
provisions: readonly Pick<AnyProvision, 'id'>[]
|
|
139
|
+
): string | undefined => {
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
for (const candidate of provisions) {
|
|
142
|
+
if (seen.has(candidate.id)) {
|
|
143
|
+
return candidate.id;
|
|
144
|
+
}
|
|
145
|
+
seen.add(candidate.id);
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Headless trail execution
|
|
2
|
+
* Headless trail execution without mounting a trailhead.
|
|
3
3
|
*
|
|
4
4
|
* Looks up a trail by ID in a topo, then delegates to `executeTrail`.
|
|
5
5
|
* Returns a `Result` and never throws.
|
|
@@ -15,15 +15,15 @@ import { Result } from './result.js';
|
|
|
15
15
|
// Options
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
/** Options forwarded to `executeTrail` from `
|
|
19
|
-
export type
|
|
18
|
+
/** Options forwarded to `executeTrail` from `run`. */
|
|
19
|
+
export type RunOptions = ExecuteTrailOptions;
|
|
20
20
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
|
-
//
|
|
22
|
+
// run()
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Execute a trail by ID from a topo without mounting a
|
|
26
|
+
* Execute a trail by ID from a topo without mounting a trailhead.
|
|
27
27
|
*
|
|
28
28
|
* Resolves the trail from the topo, then runs it through the standard
|
|
29
29
|
* `executeTrail` pipeline. Returns `Result.err(NotFoundError)` if the
|
|
@@ -32,15 +32,15 @@ export type DispatchOptions = ExecuteTrailOptions;
|
|
|
32
32
|
*
|
|
33
33
|
* @example
|
|
34
34
|
* ```typescript
|
|
35
|
-
* const result = await
|
|
35
|
+
* const result = await run(myTopo, 'greet', { name: 'Alice' });
|
|
36
36
|
* if (result.isOk()) console.log(result.value);
|
|
37
37
|
* ```
|
|
38
38
|
*/
|
|
39
|
-
export const
|
|
39
|
+
export const run = (
|
|
40
40
|
topo: Topo,
|
|
41
41
|
id: string,
|
|
42
42
|
input: unknown,
|
|
43
|
-
options?:
|
|
43
|
+
options?: RunOptions
|
|
44
44
|
): Promise<Result<unknown, Error>> => {
|
|
45
45
|
const trail = topo.get(id);
|
|
46
46
|
if (trail === undefined) {
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal — a named payload schema with optional provenance meta.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Spec (input to the factory)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export interface SignalSpec<T> {
|
|
12
|
+
readonly payload: z.ZodType<T>;
|
|
13
|
+
readonly description?: string | undefined;
|
|
14
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
15
|
+
/** Trail IDs that produce this signal (e.g. the trails it originates from). */
|
|
16
|
+
readonly from?: readonly string[] | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Shape (output of the factory)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface Signal<T> {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly kind: 'signal';
|
|
26
|
+
readonly payload: z.ZodType<T>;
|
|
27
|
+
readonly description?: string | undefined;
|
|
28
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
29
|
+
/** Trail IDs that produce this signal (e.g. the trails it originates from). */
|
|
30
|
+
readonly from?: readonly string[] | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Factory
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a signal definition.
|
|
39
|
+
*
|
|
40
|
+
* A signal is a named payload schema describing something that happened.
|
|
41
|
+
* Returns a frozen object with `kind: "signal"` and all spec fields.
|
|
42
|
+
*/
|
|
43
|
+
export function signal<T>(id: string, spec: SignalSpec<T>): Signal<T>;
|
|
44
|
+
export function signal<T>(
|
|
45
|
+
spec: SignalSpec<T> & { readonly id: string }
|
|
46
|
+
): Signal<T>;
|
|
47
|
+
export function signal<T>(
|
|
48
|
+
idOrSpec: string | (SignalSpec<T> & { readonly id: string }),
|
|
49
|
+
maybeSpec?: SignalSpec<T>
|
|
50
|
+
): Signal<T> {
|
|
51
|
+
const resolvedId = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
|
|
52
|
+
// oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
|
|
53
|
+
const resolvedSpec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
description: resolvedSpec.description,
|
|
56
|
+
from: resolvedSpec.from ? Object.freeze([...resolvedSpec.from]) : undefined,
|
|
57
|
+
id: resolvedId,
|
|
58
|
+
kind: 'signal' as const,
|
|
59
|
+
meta: resolvedSpec.meta,
|
|
60
|
+
payload: resolvedSpec.payload,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Existential type for heterogeneous signal collections */
|
|
65
|
+
export type AnySignal = Signal<unknown>;
|
package/src/topo.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { ValidationError } from './errors.js';
|
|
6
|
-
import type {
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
6
|
+
import type { AnySignal } from './event.js';
|
|
7
|
+
import type { AnyProvision } from './provision.js';
|
|
8
|
+
import { isProvision } from './provision.js';
|
|
9
9
|
import type { AnyTrail } from './trail.js';
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -15,33 +15,33 @@ import type { AnyTrail } from './trail.js';
|
|
|
15
15
|
export interface Topo {
|
|
16
16
|
readonly name: string;
|
|
17
17
|
readonly trails: ReadonlyMap<string, AnyTrail>;
|
|
18
|
-
readonly
|
|
19
|
-
readonly
|
|
18
|
+
readonly signals: ReadonlyMap<string, AnySignal>;
|
|
19
|
+
readonly provisions: ReadonlyMap<string, AnyProvision>;
|
|
20
20
|
readonly count: number;
|
|
21
|
-
readonly
|
|
21
|
+
readonly provisionCount: number;
|
|
22
22
|
get(id: string): AnyTrail | undefined;
|
|
23
|
-
|
|
23
|
+
getProvision(id: string): AnyProvision | undefined;
|
|
24
24
|
has(id: string): boolean;
|
|
25
|
-
|
|
25
|
+
hasProvision(id: string): boolean;
|
|
26
26
|
ids(): string[];
|
|
27
|
-
|
|
27
|
+
provisionIds(): string[];
|
|
28
28
|
list(): AnyTrail[];
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
listSignals(): AnySignal[];
|
|
30
|
+
listProvisions(): AnyProvision[];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// Kind discriminant check
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
|
-
type Registrable = AnyTrail |
|
|
37
|
+
type Registrable = AnyTrail | AnySignal | AnyProvision;
|
|
38
38
|
|
|
39
39
|
const isRegistrable = (value: unknown): value is Registrable => {
|
|
40
40
|
if (typeof value !== 'object' || value === null) {
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
43
|
const { kind } = value as Record<string, unknown>;
|
|
44
|
-
return kind === 'trail' || kind === '
|
|
44
|
+
return kind === 'trail' || kind === 'signal';
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
@@ -51,24 +51,22 @@ const isRegistrable = (value: unknown): value is Registrable => {
|
|
|
51
51
|
const createTopo = (
|
|
52
52
|
name: string,
|
|
53
53
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
signals: ReadonlyMap<string, AnySignal>,
|
|
55
|
+
provisions: ReadonlyMap<string, AnyProvision>
|
|
56
56
|
): Topo => ({
|
|
57
57
|
count: trails.size,
|
|
58
|
-
events,
|
|
59
58
|
get(id: string): AnyTrail | undefined {
|
|
60
59
|
return trails.get(id);
|
|
61
60
|
},
|
|
62
|
-
|
|
63
|
-
return
|
|
61
|
+
getProvision(id: string): AnyProvision | undefined {
|
|
62
|
+
return provisions.get(id);
|
|
64
63
|
},
|
|
65
64
|
has(id: string): boolean {
|
|
66
65
|
return trails.has(id);
|
|
67
66
|
},
|
|
68
|
-
|
|
69
|
-
return
|
|
67
|
+
hasProvision(id: string): boolean {
|
|
68
|
+
return provisions.has(id);
|
|
70
69
|
},
|
|
71
|
-
|
|
72
70
|
ids(): string[] {
|
|
73
71
|
return [...trails.keys()];
|
|
74
72
|
},
|
|
@@ -76,21 +74,22 @@ const createTopo = (
|
|
|
76
74
|
list(): AnyTrail[] {
|
|
77
75
|
return [...trails.values()];
|
|
78
76
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return [...events.values()];
|
|
77
|
+
listProvisions(): AnyProvision[] {
|
|
78
|
+
return [...provisions.values()];
|
|
82
79
|
},
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
|
|
81
|
+
listSignals(): AnySignal[] {
|
|
82
|
+
return [...signals.values()];
|
|
85
83
|
},
|
|
86
84
|
|
|
87
85
|
name,
|
|
88
|
-
|
|
89
|
-
serviceIds(): string[] {
|
|
90
|
-
return [...services.keys()];
|
|
91
|
-
},
|
|
86
|
+
provisionCount: provisions.size,
|
|
92
87
|
|
|
93
|
-
|
|
88
|
+
provisionIds(): string[] {
|
|
89
|
+
return [...provisions.keys()];
|
|
90
|
+
},
|
|
91
|
+
provisions,
|
|
92
|
+
signals,
|
|
94
93
|
trails,
|
|
95
94
|
});
|
|
96
95
|
|
|
@@ -102,22 +101,22 @@ const createTopo = (
|
|
|
102
101
|
const register = (
|
|
103
102
|
value: Registrable,
|
|
104
103
|
trails: Map<string, AnyTrail>,
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
signals: Map<string, AnySignal>,
|
|
105
|
+
provisions: Map<string, AnyProvision>
|
|
107
106
|
): void => {
|
|
108
107
|
const { id } = value as { id: string };
|
|
109
108
|
const registrars: Record<string, () => void> = {
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
throw new ValidationError(`Duplicate
|
|
109
|
+
provision: () => {
|
|
110
|
+
if (provisions.has(id)) {
|
|
111
|
+
throw new ValidationError(`Duplicate provision ID: "${id}"`);
|
|
113
112
|
}
|
|
114
|
-
|
|
113
|
+
provisions.set(id, value as AnyProvision);
|
|
115
114
|
},
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
throw new ValidationError(`Duplicate
|
|
115
|
+
signal: () => {
|
|
116
|
+
if (signals.has(id)) {
|
|
117
|
+
throw new ValidationError(`Duplicate signal ID: "${id}"`);
|
|
119
118
|
}
|
|
120
|
-
|
|
119
|
+
signals.set(id, value as AnySignal);
|
|
121
120
|
},
|
|
122
121
|
trail: () => {
|
|
123
122
|
if (trails.has(id)) {
|
|
@@ -129,20 +128,43 @@ const register = (
|
|
|
129
128
|
registrars[value.kind]?.();
|
|
130
129
|
};
|
|
131
130
|
|
|
131
|
+
const markUniqueObject = (
|
|
132
|
+
value: unknown,
|
|
133
|
+
seenValues: WeakSet<object>
|
|
134
|
+
): boolean => {
|
|
135
|
+
if (typeof value !== 'object' || value === null) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (seenValues.has(value)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
seenValues.add(value);
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const registerModuleValue = (
|
|
146
|
+
value: unknown,
|
|
147
|
+
trails: Map<string, AnyTrail>,
|
|
148
|
+
signals: Map<string, AnySignal>,
|
|
149
|
+
provisions: Map<string, AnyProvision>
|
|
150
|
+
): void => {
|
|
151
|
+
if (isProvision(value) || isRegistrable(value)) {
|
|
152
|
+
register(value, trails, signals, provisions);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
132
156
|
const registerModuleValues = (
|
|
133
157
|
mod: Record<string, unknown>,
|
|
134
158
|
trails: Map<string, AnyTrail>,
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
signals: Map<string, AnySignal>,
|
|
160
|
+
provisions: Map<string, AnyProvision>
|
|
137
161
|
): void => {
|
|
162
|
+
const seenValues = new WeakSet<object>();
|
|
138
163
|
for (const value of Object.values(mod)) {
|
|
139
|
-
if (
|
|
140
|
-
register(value, trails, events, services);
|
|
164
|
+
if (!markUniqueObject(value, seenValues)) {
|
|
141
165
|
continue;
|
|
142
166
|
}
|
|
143
|
-
|
|
144
|
-
register(value, trails, events, services);
|
|
145
|
-
}
|
|
167
|
+
registerModuleValue(value, trails, signals, provisions);
|
|
146
168
|
}
|
|
147
169
|
};
|
|
148
170
|
|
|
@@ -151,12 +173,12 @@ export const topo = (
|
|
|
151
173
|
...modules: Record<string, unknown>[]
|
|
152
174
|
): Topo => {
|
|
153
175
|
const trails = new Map<string, AnyTrail>();
|
|
154
|
-
const
|
|
155
|
-
const
|
|
176
|
+
const signals = new Map<string, AnySignal>();
|
|
177
|
+
const provisions = new Map<string, AnyProvision>();
|
|
156
178
|
|
|
157
179
|
for (const mod of modules) {
|
|
158
|
-
registerModuleValues(mod, trails,
|
|
180
|
+
registerModuleValues(mod, trails, signals, provisions);
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
return createTopo(name, trails,
|
|
183
|
+
return createTopo(name, trails, signals, provisions);
|
|
162
184
|
};
|
package/src/trail.ts
CHANGED
|
@@ -2,7 +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 {
|
|
5
|
+
import type { AnyProvision } from './provision.js';
|
|
6
6
|
import type {
|
|
7
7
|
Implementation,
|
|
8
8
|
PermitRequirement,
|
|
@@ -44,7 +44,7 @@ export interface TrailSpec<I, O> {
|
|
|
44
44
|
/** Zod schema for validating output (optional — some trails are fire-and-forget) */
|
|
45
45
|
readonly output?: z.ZodType<O> | undefined;
|
|
46
46
|
/** The pure function that does the work (sync or async authoring) */
|
|
47
|
-
readonly
|
|
47
|
+
readonly blaze: Implementation<I, O>;
|
|
48
48
|
/** Human-readable description */
|
|
49
49
|
readonly description?: string | undefined;
|
|
50
50
|
/** Named examples for docs and testing */
|
|
@@ -53,16 +53,16 @@ export interface TrailSpec<I, O> {
|
|
|
53
53
|
readonly intent?: 'read' | 'write' | 'destroy' | undefined;
|
|
54
54
|
/** Trail is idempotent (safe to retry) */
|
|
55
55
|
readonly idempotent?: boolean | undefined;
|
|
56
|
-
/** Arbitrary
|
|
57
|
-
readonly
|
|
56
|
+
/** Arbitrary meta for tooling and filtering */
|
|
57
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
58
58
|
/** Named sets of downstream trail IDs that may be invoked */
|
|
59
59
|
readonly detours?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
60
60
|
/** Per-field overrides for deriveFields() (labels, hints, options) */
|
|
61
61
|
readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
|
|
62
|
-
/** IDs of downstream trails this trail may invoke via ctx.
|
|
63
|
-
readonly
|
|
64
|
-
/**
|
|
65
|
-
readonly
|
|
62
|
+
/** IDs of downstream trails this trail may invoke via ctx.cross() */
|
|
63
|
+
readonly crosses?: readonly string[] | undefined;
|
|
64
|
+
/** Provisions this trail may access via provision.from(ctx) */
|
|
65
|
+
readonly provisions?: readonly AnyProvision[] | undefined;
|
|
66
66
|
/** Auth requirement: scopes object, 'public', or omitted (undeclared) */
|
|
67
67
|
readonly permit?: PermitRequirement | undefined;
|
|
68
68
|
}
|
|
@@ -77,15 +77,15 @@ export type Intent = 'read' | 'write' | 'destroy';
|
|
|
77
77
|
/** A fully-defined trail — the unit of work in the Trails system */
|
|
78
78
|
export interface Trail<I, O> extends Omit<
|
|
79
79
|
TrailSpec<I, O>,
|
|
80
|
-
'
|
|
80
|
+
'blaze' | 'crosses' | 'intent' | 'provisions'
|
|
81
81
|
> {
|
|
82
82
|
readonly kind: 'trail';
|
|
83
83
|
readonly id: string;
|
|
84
|
-
readonly
|
|
85
|
-
/** IDs of downstream trails this trail may invoke via ctx.
|
|
86
|
-
readonly
|
|
87
|
-
/**
|
|
88
|
-
readonly
|
|
84
|
+
readonly blaze: Implementation<I, O>;
|
|
85
|
+
/** IDs of downstream trails this trail may invoke via ctx.cross() (always present, default []) */
|
|
86
|
+
readonly crosses: readonly string[];
|
|
87
|
+
/** Provisions this trail may access via provision.from(ctx) (always present, default []) */
|
|
88
|
+
readonly provisions: readonly AnyProvision[];
|
|
89
89
|
/** What this trail does to the world (always present, default 'write') */
|
|
90
90
|
readonly intent: Intent;
|
|
91
91
|
}
|
|
@@ -105,14 +105,14 @@ export interface Trail<I, O> extends Omit<
|
|
|
105
105
|
* // ID as first argument (recommended for human authoring)
|
|
106
106
|
* const show = trail("entity.show", {
|
|
107
107
|
* input: z.object({ name: z.string() }),
|
|
108
|
-
*
|
|
108
|
+
* blaze: (input) => Result.ok(entity),
|
|
109
109
|
* });
|
|
110
110
|
*
|
|
111
111
|
* // Full spec object (for programmatic generation)
|
|
112
112
|
* const show = trail({
|
|
113
113
|
* id: "entity.show",
|
|
114
114
|
* input: z.object({ name: z.string() }),
|
|
115
|
-
*
|
|
115
|
+
* blaze: (input) => Result.ok(entity),
|
|
116
116
|
* });
|
|
117
117
|
* ```
|
|
118
118
|
*/
|
|
@@ -134,21 +134,22 @@ export function trail<I, O>(
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
const {
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
blaze,
|
|
138
|
+
crosses: rawCrosses,
|
|
139
139
|
intent: rawIntent,
|
|
140
|
-
|
|
140
|
+
provisions: rawProvisions,
|
|
141
141
|
...spec
|
|
142
142
|
} = resolved.spec;
|
|
143
|
+
const provisions = Object.freeze([...(rawProvisions ?? [])]);
|
|
143
144
|
|
|
144
145
|
return Object.freeze({
|
|
145
146
|
...spec,
|
|
146
|
-
|
|
147
|
+
blaze: async (input: I, ctx: TrailContext) => await blaze(input, ctx),
|
|
148
|
+
crosses: Object.freeze([...(rawCrosses ?? [])]),
|
|
147
149
|
id: resolved.id,
|
|
148
150
|
intent: rawIntent ?? 'write',
|
|
149
151
|
kind: 'trail' as const,
|
|
150
|
-
|
|
151
|
-
services: Object.freeze([...(rawServices ?? [])]),
|
|
152
|
+
provisions,
|
|
152
153
|
});
|
|
153
154
|
}
|
|
154
155
|
|
package/src/types.ts
CHANGED
|
@@ -12,14 +12,14 @@ export type Implementation<I, O> = (
|
|
|
12
12
|
) => Result<O, Error> | Promise<Result<O, Error>>;
|
|
13
13
|
|
|
14
14
|
/** Invoke another trail by id — used for trail composition */
|
|
15
|
-
export type
|
|
15
|
+
export type CrossFn = <O>(
|
|
16
16
|
id: string,
|
|
17
17
|
input: unknown
|
|
18
18
|
) => Promise<Result<O, Error>>;
|
|
19
19
|
|
|
20
|
-
/** Resolve a
|
|
21
|
-
export type
|
|
22
|
-
|
|
20
|
+
/** Resolve a provision instance from the current trail context. */
|
|
21
|
+
export type ProvisionLookup = <T = unknown>(
|
|
22
|
+
provisionOrId: { readonly id: string } | string
|
|
23
23
|
) => T;
|
|
24
24
|
|
|
25
25
|
/** Callback for reporting progress from long-running trails */
|
|
@@ -46,8 +46,8 @@ export interface Logger {
|
|
|
46
46
|
child(context: Record<string, unknown>): Logger;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Context extension key for the invoking
|
|
50
|
-
export const
|
|
49
|
+
/** Context extension key for the invoking trailhead name. */
|
|
50
|
+
export const TRAILHEAD_KEY = '__trails_trailhead' as const;
|
|
51
51
|
|
|
52
52
|
/** Minimal permit shape available on TrailContext. Permits extends this. */
|
|
53
53
|
export interface BasePermit {
|
|
@@ -58,8 +58,8 @@ export interface BasePermit {
|
|
|
58
58
|
/** Runtime context threaded through every trail execution */
|
|
59
59
|
export interface TrailContext {
|
|
60
60
|
readonly requestId: string;
|
|
61
|
-
readonly
|
|
62
|
-
readonly
|
|
61
|
+
readonly abortSignal: AbortSignal;
|
|
62
|
+
readonly cross?: CrossFn | undefined;
|
|
63
63
|
readonly permit?: BasePermit;
|
|
64
64
|
readonly workspaceRoot?: string | undefined;
|
|
65
65
|
readonly logger?: Logger | undefined;
|
|
@@ -67,7 +67,7 @@ export interface TrailContext {
|
|
|
67
67
|
readonly cwd?: string | undefined;
|
|
68
68
|
readonly env?: Record<string, string | undefined> | undefined;
|
|
69
69
|
readonly extensions?: Readonly<Record<string, unknown>> | undefined;
|
|
70
|
-
readonly
|
|
70
|
+
readonly provision?: ProvisionLookup | undefined;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -82,6 +82,6 @@ export type PermitRequirement =
|
|
|
82
82
|
| 'public';
|
|
83
83
|
|
|
84
84
|
/** Input shape used to seed a runtime TrailContext before resolution. */
|
|
85
|
-
export type TrailContextInit = Omit<TrailContext, '
|
|
86
|
-
readonly
|
|
85
|
+
export type TrailContextInit = Omit<TrailContext, 'provision'> & {
|
|
86
|
+
readonly provision?: ProvisionLookup | undefined;
|
|
87
87
|
};
|