@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.15
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 +17 -0
- package/README.md +19 -14
- package/dist/branded.d.ts +1 -1
- package/dist/branded.d.ts.map +1 -1
- package/dist/branded.js +1 -1
- package/dist/branded.js.map +1 -1
- package/dist/context.d.ts +13 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -3
- package/dist/context.js.map +1 -1
- package/dist/contour.d.ts +81 -0
- package/dist/contour.d.ts.map +1 -0
- package/dist/contour.js +176 -0
- package/dist/contour.js.map +1 -0
- package/dist/cross-schema.d.ts +18 -0
- package/dist/cross-schema.d.ts.map +1 -0
- package/dist/cross-schema.js +29 -0
- package/dist/cross-schema.js.map +1 -0
- package/dist/derive.d.ts +6 -0
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +29 -5
- package/dist/derive.js.map +1 -1
- package/dist/draft.d.ts +28 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +157 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +62 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +26 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +441 -20
- package/dist/execute.js.map +1 -1
- package/dist/fire.d.ts +43 -0
- package/dist/fire.d.ts.map +1 -0
- package/dist/fire.js +185 -0
- package/dist/fire.js.map +1 -0
- package/dist/index.d.ts +33 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cross-batch.d.ts +44 -0
- package/dist/internal/cross-batch.d.ts.map +1 -0
- package/dist/internal/cross-batch.js +58 -0
- package/dist/internal/cross-batch.js.map +1 -0
- package/dist/internal/fork-ctx.d.ts +41 -0
- package/dist/internal/fork-ctx.d.ts.map +1 -0
- package/dist/internal/fork-ctx.js +47 -0
- package/dist/internal/fork-ctx.js.map +1 -0
- package/dist/internal/signal-ref.d.ts +15 -0
- package/dist/internal/signal-ref.d.ts.map +1 -0
- package/dist/internal/signal-ref.js +41 -0
- package/dist/internal/signal-ref.js.map +1 -0
- package/dist/internal/topo-saves.d.ts +53 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +449 -0
- package/dist/internal/topo-saves.js.map +1 -0
- package/dist/internal/topo-snapshots.d.ts +57 -0
- package/dist/internal/topo-snapshots.d.ts.map +1 -0
- package/dist/internal/topo-snapshots.js +372 -0
- package/dist/internal/topo-snapshots.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +70 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +221 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +26 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +621 -0
- package/dist/internal/topo-store.js.map +1 -0
- package/dist/internal/tracing.d.ts +98 -0
- package/dist/internal/tracing.d.ts.map +1 -0
- package/dist/internal/tracing.js +102 -0
- package/dist/internal/tracing.js.map +1 -0
- package/dist/internal/trails-db.d.ts +16 -0
- package/dist/internal/trails-db.d.ts.map +1 -0
- package/dist/internal/trails-db.js +118 -0
- package/dist/internal/trails-db.js.map +1 -0
- package/dist/internal/zod-wrappers.d.ts +40 -0
- package/dist/internal/zod-wrappers.d.ts.map +1 -0
- package/dist/internal/zod-wrappers.js +59 -0
- package/dist/internal/zod-wrappers.js.map +1 -0
- package/dist/layer.d.ts +22 -5
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +20 -3
- package/dist/layer.js.map +1 -1
- package/dist/path-security.d.ts +1 -1
- package/dist/path-security.d.ts.map +1 -1
- package/dist/path-security.js +1 -1
- package/dist/path-security.js.map +1 -1
- package/dist/resilience.d.ts +1 -1
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +3 -3
- package/dist/resilience.js.map +1 -1
- package/dist/resource-config.d.ts +22 -0
- package/dist/resource-config.d.ts.map +1 -0
- package/dist/resource-config.js +210 -0
- package/dist/resource-config.js.map +1 -0
- package/dist/resource.d.ts +74 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +61 -0
- package/dist/resource.js.map +1 -0
- package/dist/run.d.ts +4 -3
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -4
- package/dist/run.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +2 -1
- package/dist/serialization.js.map +1 -1
- package/dist/store/accessor-protocol.d.ts +51 -0
- package/dist/store/accessor-protocol.d.ts.map +1 -0
- package/dist/store/accessor-protocol.js +2 -0
- package/dist/store/accessor-protocol.js.map +1 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/surface-filter.d.ts +13 -0
- package/dist/surface-filter.d.ts.map +1 -0
- package/dist/surface-filter.js +76 -0
- package/dist/surface-filter.js.map +1 -0
- package/dist/topo-store.d.ts +62 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +319 -0
- package/dist/topo-store.js.map +1 -0
- package/dist/topo.d.ts +23 -9
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +175 -46
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +86 -19
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +59 -4
- package/dist/trail.js.map +1 -1
- package/dist/trails/derive-trail.d.ts +66 -0
- package/dist/trails/derive-trail.d.ts.map +1 -0
- package/dist/trails/derive-trail.js +378 -0
- package/dist/trails/derive-trail.js.map +1 -0
- package/dist/trails/index.d.ts +5 -0
- package/dist/trails/index.d.ts.map +1 -0
- package/dist/trails/index.js +3 -0
- package/dist/trails/index.js.map +1 -0
- package/dist/trails/ingest.d.ts +24 -0
- package/dist/trails/ingest.d.ts.map +1 -0
- package/dist/trails/ingest.js +63 -0
- package/dist/trails/ingest.js.map +1 -0
- package/dist/transport-error-map.d.ts +106 -0
- package/dist/transport-error-map.d.ts.map +1 -0
- package/dist/transport-error-map.js +24 -0
- package/dist/transport-error-map.js.map +1 -0
- package/dist/type-checks.test-d.d.ts +71 -0
- package/dist/type-checks.test-d.d.ts.map +1 -0
- package/dist/type-checks.test-d.js +12 -0
- package/dist/type-checks.test-d.js.map +1 -0
- package/dist/type-utils.d.ts +11 -2
- package/dist/type-utils.d.ts.map +1 -1
- package/dist/type-utils.js.map +1 -1
- package/dist/types.d.ts +113 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-established-topo.d.ts +76 -0
- package/dist/validate-established-topo.d.ts.map +1 -0
- package/dist/validate-established-topo.js +43 -0
- package/dist/validate-established-topo.js.map +1 -0
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +27 -8
- package/dist/validate-topo.js.map +1 -1
- package/dist/workspace.d.ts +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +1 -1
- package/dist/workspace.js.map +1 -1
- package/package.json +10 -1
- package/src/__tests__/branded.test.ts +5 -5
- package/src/__tests__/context.test.ts +3 -3
- package/src/__tests__/contour.test.ts +263 -0
- package/src/__tests__/derive-trail.test.ts +668 -0
- package/src/__tests__/derive.test.ts +58 -1
- package/src/__tests__/errors.test.ts +118 -0
- package/src/__tests__/execute.test.ts +1179 -141
- package/src/__tests__/fire.test.ts +1056 -0
- package/src/__tests__/fork-ctx.test.ts +83 -0
- package/src/__tests__/ingest.test.ts +191 -0
- package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
- package/src/__tests__/path-security.test.ts +7 -7
- package/src/__tests__/resilience.test.ts +8 -8
- package/src/__tests__/resource.test.ts +204 -0
- package/src/__tests__/run.test.ts +13 -13
- package/src/__tests__/service-config.test.ts +28 -32
- package/src/__tests__/surface-filter.test.ts +196 -0
- package/src/__tests__/topo-store-read.test.ts +256 -0
- package/src/__tests__/topo-store.test.ts +869 -0
- package/src/__tests__/topo.test.ts +350 -54
- package/src/__tests__/trail.test.ts +189 -24
- package/src/__tests__/trails-db.test.ts +198 -0
- package/src/__tests__/transport-error-map.test.ts +82 -0
- package/src/__tests__/type-utils.test.ts +29 -1
- package/src/__tests__/validate-topo.test.ts +305 -16
- package/src/__tests__/workspace.test.ts +6 -6
- package/src/__tests__/zod-wrappers.test.ts +62 -0
- package/src/branded.ts +1 -1
- package/src/context.ts +24 -4
- package/src/contour.ts +344 -0
- package/src/cross-schema.ts +36 -0
- package/src/derive.ts +39 -8
- package/src/draft.ts +338 -0
- package/src/errors.ts +62 -18
- package/src/execute.ts +840 -29
- package/src/fire.ts +295 -0
- package/src/index.ts +142 -26
- package/src/internal/cross-batch.ts +77 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/internal/signal-ref.ts +87 -0
- package/src/internal/topo-snapshots.ts +517 -0
- package/src/internal/topo-store-read.ts +481 -0
- package/src/internal/topo-store.ts +1192 -0
- package/src/internal/tracing.ts +187 -0
- package/src/internal/trails-db.ts +187 -0
- package/src/internal/zod-wrappers.ts +78 -0
- package/src/layer.ts +50 -0
- package/src/path-security.ts +1 -1
- package/src/resilience.ts +3 -3
- package/src/{provision-config.ts → resource-config.ts} +109 -123
- package/src/resource.ts +155 -0
- package/src/run.ts +5 -4
- package/src/serialization.ts +2 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/surface-filter.ts +182 -0
- package/src/topo-store.ts +510 -0
- package/src/topo.ts +328 -57
- package/src/trail.ts +199 -31
- package/src/trails/derive-trail.ts +842 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +146 -0
- package/src/transport-error-map.ts +56 -0
- package/src/type-checks.test-d.ts +106 -0
- package/src/type-utils.ts +17 -2
- package/src/types.ts +148 -11
- package/src/validate-established-topo.ts +63 -0
- package/src/validate-topo.ts +37 -9
- package/src/workspace.ts +1 -1
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/adapters.d.ts +0 -39
- package/dist/adapters.d.ts.map +0 -1
- package/dist/adapters.js +0 -2
- package/dist/adapters.js.map +0 -1
- package/dist/dispatch.d.ts +0 -27
- package/dist/dispatch.d.ts.map +0 -1
- package/dist/dispatch.js +0 -34
- package/dist/dispatch.js.map +0 -1
- package/dist/event.d.ts +0 -8
- package/dist/event.d.ts.map +0 -1
- package/dist/event.js +0 -7
- package/dist/event.js.map +0 -1
- package/dist/gate.d.ts +0 -17
- package/dist/gate.d.ts.map +0 -1
- package/dist/gate.js +0 -21
- package/dist/gate.js.map +0 -1
- package/dist/health.d.ts +0 -18
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js +0 -5
- package/dist/health.js.map +0 -1
- package/dist/job.d.ts +0 -24
- package/dist/job.d.ts.map +0 -1
- package/dist/job.js +0 -17
- package/dist/job.js.map +0 -1
- package/dist/provision-config.d.ts +0 -22
- package/dist/provision-config.d.ts.map +0 -1
- package/dist/provision-config.js +0 -210
- package/dist/provision-config.js.map +0 -1
- package/dist/provision.d.ts +0 -71
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -56
- package/dist/provision.js.map +0 -1
- package/dist/service-config.d.ts +0 -22
- package/dist/service-config.d.ts.map +0 -1
- package/dist/service-config.js +0 -210
- package/dist/service-config.js.map +0 -1
- package/dist/service.d.ts +0 -71
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -56
- package/dist/service.js.map +0 -1
- package/src/__tests__/service.test.ts +0 -197
- package/src/event.ts +0 -15
- package/src/gate.ts +0 -44
- package/src/provision.ts +0 -148
|
@@ -3,12 +3,23 @@ import { describe, test, expect } from 'bun:test';
|
|
|
3
3
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
CancelledError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
InternalError,
|
|
10
|
+
NetworkError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RetryExhaustedError,
|
|
13
|
+
TrailsError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
} from '../errors';
|
|
7
16
|
import { executeTrail } from '../execute';
|
|
17
|
+
import { TRACE_CONTEXT_KEY, clearTraceSink } from '../internal/tracing';
|
|
8
18
|
import { createTrailContext } from '../context';
|
|
9
|
-
import type {
|
|
19
|
+
import type { Layer } from '../layer';
|
|
10
20
|
import { Result } from '../result';
|
|
11
|
-
import {
|
|
21
|
+
import { resource } from '../resource';
|
|
22
|
+
import { topo } from '../topo';
|
|
12
23
|
import { trail } from '../trail';
|
|
13
24
|
import type { TrailContext, TrailContextInit } from '../types';
|
|
14
25
|
|
|
@@ -34,67 +45,67 @@ const throwingTrail = trail('throws', {
|
|
|
34
45
|
input: z.object({}),
|
|
35
46
|
});
|
|
36
47
|
|
|
37
|
-
const
|
|
38
|
-
`test.
|
|
48
|
+
const nextResourceId = (name: string): string =>
|
|
49
|
+
`test.resource.${name}.${Bun.randomUUIDv7()}`;
|
|
39
50
|
|
|
40
|
-
const
|
|
51
|
+
const createResolvedValueResource = (
|
|
41
52
|
id: string,
|
|
42
53
|
onCreate: () => void,
|
|
43
54
|
value: number
|
|
44
55
|
) =>
|
|
45
|
-
|
|
56
|
+
resource(id, {
|
|
46
57
|
create: () => {
|
|
47
58
|
onCreate();
|
|
48
59
|
return Result.ok({ value });
|
|
49
60
|
},
|
|
50
61
|
});
|
|
51
62
|
|
|
52
|
-
const
|
|
63
|
+
const createEagerResourceTrail = (
|
|
53
64
|
id: string,
|
|
54
|
-
counter: ReturnType<typeof
|
|
65
|
+
counter: ReturnType<typeof createResolvedValueResource>,
|
|
55
66
|
onRun: (value: number) => void
|
|
56
67
|
) =>
|
|
57
|
-
trail('
|
|
68
|
+
trail('resource.eager', {
|
|
58
69
|
blaze: (_input, ctx) => {
|
|
59
|
-
const fromAccessor = ctx.
|
|
70
|
+
const fromAccessor = ctx.resource<{ value: number }>(id);
|
|
60
71
|
const fromDefinition = counter.from(ctx);
|
|
61
72
|
onRun(fromDefinition.value);
|
|
62
73
|
return Result.ok({ total: fromAccessor.value + 1 });
|
|
63
74
|
},
|
|
64
75
|
input: z.object({}),
|
|
65
76
|
output: z.object({ total: z.number() }),
|
|
66
|
-
|
|
77
|
+
resources: [counter],
|
|
67
78
|
});
|
|
68
79
|
|
|
69
|
-
const
|
|
70
|
-
counter: ReturnType<typeof
|
|
80
|
+
const createResourceProbeGate = (
|
|
81
|
+
counter: ReturnType<typeof createResolvedValueResource>,
|
|
71
82
|
onResolve: (value: number) => void
|
|
72
|
-
):
|
|
73
|
-
name: 'uses-
|
|
83
|
+
): Layer => ({
|
|
84
|
+
name: 'uses-resource',
|
|
74
85
|
wrap(_trail, impl) {
|
|
75
86
|
return async (input, ctx) => {
|
|
76
|
-
onResolve(ctx.
|
|
87
|
+
onResolve(ctx.resource<{ value: number }>(counter).value);
|
|
77
88
|
return await impl(input, ctx);
|
|
78
89
|
};
|
|
79
90
|
},
|
|
80
91
|
});
|
|
81
92
|
|
|
82
|
-
const
|
|
83
|
-
|
|
93
|
+
const createSingletonResource = (id: string, onCreate: () => number) =>
|
|
94
|
+
resource(id, {
|
|
84
95
|
create: () => Result.ok({ createdAtCall: onCreate() }),
|
|
85
96
|
});
|
|
86
97
|
|
|
87
98
|
const createSingletonTrail = (
|
|
88
|
-
singleton: ReturnType<typeof
|
|
99
|
+
singleton: ReturnType<typeof createSingletonResource>
|
|
89
100
|
) =>
|
|
90
|
-
trail('
|
|
101
|
+
trail('resource.singleton', {
|
|
91
102
|
blaze: (_input, ctx) =>
|
|
92
103
|
Result.ok({
|
|
93
104
|
createdAtCall: singleton.from(ctx).createdAtCall,
|
|
94
105
|
}),
|
|
95
106
|
input: z.object({}),
|
|
96
107
|
output: z.object({ createdAtCall: z.number() }),
|
|
97
|
-
|
|
108
|
+
resources: [singleton],
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
const unwrapExecution = async (
|
|
@@ -104,123 +115,865 @@ const unwrapExecution = async (
|
|
|
104
115
|
return result.unwrap();
|
|
105
116
|
};
|
|
106
117
|
|
|
118
|
+
const requireCross = (
|
|
119
|
+
ctx: TrailContext
|
|
120
|
+
): NonNullable<TrailContext['cross']> => {
|
|
121
|
+
expect(ctx.cross).toBeDefined();
|
|
122
|
+
return ctx.cross as NonNullable<TrailContext['cross']>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const waitForAbort = async (signal: AbortSignal): Promise<void> => {
|
|
126
|
+
if (signal.aborted) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { promise, resolve } = Promise.withResolvers<undefined>();
|
|
131
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
132
|
+
await promise;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const createConcurrentBranchResourceScopeScenario = () => {
|
|
136
|
+
const id = nextResourceId('branch-scope');
|
|
137
|
+
const captures = { createCalls: 0 };
|
|
138
|
+
const scopedResource = resource(id, {
|
|
139
|
+
create: () => {
|
|
140
|
+
captures.createCalls += 1;
|
|
141
|
+
return Result.ok({ source: `branch-scope-${captures.createCalls}` });
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const createReader = (trailId: string) =>
|
|
145
|
+
trail(trailId, {
|
|
146
|
+
blaze: (_input, ctx) =>
|
|
147
|
+
Result.ok({ source: scopedResource.from(ctx).source }),
|
|
148
|
+
input: z.object({}),
|
|
149
|
+
output: z.object({ source: z.string() }),
|
|
150
|
+
resources: [scopedResource],
|
|
151
|
+
visibility: 'internal',
|
|
152
|
+
});
|
|
153
|
+
const left = createReader('entity.branch.left');
|
|
154
|
+
const right = createReader('entity.branch.right');
|
|
155
|
+
const entry = trail('entity.branch.resource-scope', {
|
|
156
|
+
blaze: async (_input, ctx) => {
|
|
157
|
+
const crossed = await requireCross(ctx)([
|
|
158
|
+
[left, {}],
|
|
159
|
+
[right, {}],
|
|
160
|
+
] as const);
|
|
161
|
+
return Result.ok({
|
|
162
|
+
sources: crossed.map((result) =>
|
|
163
|
+
result.match({
|
|
164
|
+
err: () => 'err',
|
|
165
|
+
ok: (value) => value.source,
|
|
166
|
+
})
|
|
167
|
+
),
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
crosses: [left, right],
|
|
171
|
+
input: z.object({}),
|
|
172
|
+
output: z.object({ sources: z.array(z.string()) }),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
app: topo('cross-branch-resource-scope-topo', {
|
|
177
|
+
entry,
|
|
178
|
+
left,
|
|
179
|
+
right,
|
|
180
|
+
scopedResource,
|
|
181
|
+
}),
|
|
182
|
+
captures,
|
|
183
|
+
entry,
|
|
184
|
+
id,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const createAbortSignalScenario = (seenSignals: AbortSignal[]) => {
|
|
189
|
+
const createCancellable = (id: string) =>
|
|
190
|
+
trail(id, {
|
|
191
|
+
blaze: async (_input, ctx) => {
|
|
192
|
+
seenSignals.push(ctx.abortSignal);
|
|
193
|
+
await waitForAbort(ctx.abortSignal);
|
|
194
|
+
return Result.err(
|
|
195
|
+
new CancelledError(`${id} cancelled by shared abort signal`)
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
input: z.object({}),
|
|
199
|
+
output: z.object({ ok: z.boolean() }),
|
|
200
|
+
visibility: 'internal',
|
|
201
|
+
});
|
|
202
|
+
const left = createCancellable('entity.cancel.left');
|
|
203
|
+
const right = createCancellable('entity.cancel.right');
|
|
204
|
+
const entry = trail('entity.branch.abort-signal', {
|
|
205
|
+
blaze: async (_input, ctx) => {
|
|
206
|
+
const crossed = await requireCross(ctx)([
|
|
207
|
+
[left, {}],
|
|
208
|
+
[right, {}],
|
|
209
|
+
] as const);
|
|
210
|
+
return Result.ok({
|
|
211
|
+
statuses: crossed.map((result) =>
|
|
212
|
+
result.match({
|
|
213
|
+
err: (error) =>
|
|
214
|
+
error instanceof CancelledError ? error.category : 'err',
|
|
215
|
+
ok: () => 'ok',
|
|
216
|
+
})
|
|
217
|
+
),
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
crosses: [left, right],
|
|
221
|
+
input: z.object({}),
|
|
222
|
+
output: z.object({ statuses: z.array(z.string()) }),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
app: topo('cross-branch-abort-signal-topo', {
|
|
227
|
+
entry,
|
|
228
|
+
left,
|
|
229
|
+
right,
|
|
230
|
+
}),
|
|
231
|
+
entry,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
interface PermitCapture {
|
|
236
|
+
readonly permitId: string;
|
|
237
|
+
readonly sameReference: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const createPermitScenario = (
|
|
241
|
+
permit: { readonly id: string; readonly scopes: readonly string[] },
|
|
242
|
+
captures: PermitCapture[]
|
|
243
|
+
) => {
|
|
244
|
+
const createChild = (id: string) =>
|
|
245
|
+
trail(id, {
|
|
246
|
+
blaze: (_input, ctx) => {
|
|
247
|
+
const branchPermit = ctx.permit as typeof permit;
|
|
248
|
+
const permitCapture = {
|
|
249
|
+
permitId: branchPermit.id,
|
|
250
|
+
sameReference: branchPermit === permit,
|
|
251
|
+
};
|
|
252
|
+
captures.push(permitCapture);
|
|
253
|
+
return Result.ok(permitCapture);
|
|
254
|
+
},
|
|
255
|
+
input: z.object({}),
|
|
256
|
+
output: z.object({
|
|
257
|
+
permitId: z.string(),
|
|
258
|
+
sameReference: z.boolean(),
|
|
259
|
+
}),
|
|
260
|
+
visibility: 'internal',
|
|
261
|
+
});
|
|
262
|
+
const left = createChild('entity.permit.left');
|
|
263
|
+
const right = createChild('entity.permit.right');
|
|
264
|
+
const entry = trail('entity.branch.permit', {
|
|
265
|
+
blaze: async (_input, ctx) => {
|
|
266
|
+
const crossed = await requireCross(ctx)([
|
|
267
|
+
[left, {}],
|
|
268
|
+
[right, {}],
|
|
269
|
+
] as const);
|
|
270
|
+
return Result.ok({
|
|
271
|
+
permits: crossed.map((result) =>
|
|
272
|
+
result.match({
|
|
273
|
+
err: () => ({ permitId: 'err', sameReference: false }),
|
|
274
|
+
ok: (value) => value,
|
|
275
|
+
})
|
|
276
|
+
),
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
crosses: [left, right],
|
|
280
|
+
input: z.object({}),
|
|
281
|
+
output: z.object({
|
|
282
|
+
permits: z.array(
|
|
283
|
+
z.object({
|
|
284
|
+
permitId: z.string(),
|
|
285
|
+
sameReference: z.boolean(),
|
|
286
|
+
})
|
|
287
|
+
),
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
app: topo('cross-branch-permit-topo', {
|
|
293
|
+
entry,
|
|
294
|
+
left,
|
|
295
|
+
right,
|
|
296
|
+
}),
|
|
297
|
+
entry,
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const createSiblingFailureScopeScenario = () => {
|
|
302
|
+
const failingId = nextResourceId('branch-fail');
|
|
303
|
+
const succeedingId = nextResourceId('branch-success');
|
|
304
|
+
const failingResource = resource(failingId, {
|
|
305
|
+
create: () =>
|
|
306
|
+
Result.err(new ValidationError('failing branch scope exploded')),
|
|
307
|
+
});
|
|
308
|
+
const succeedingResource = resource(succeedingId, {
|
|
309
|
+
create: () => Result.ok({ source: 'sibling-branch-scope' }),
|
|
310
|
+
});
|
|
311
|
+
const failing = trail('entity.scope.fail', {
|
|
312
|
+
blaze: () => Result.ok({ ok: true }),
|
|
313
|
+
input: z.object({}),
|
|
314
|
+
output: z.object({ ok: z.boolean() }),
|
|
315
|
+
resources: [failingResource],
|
|
316
|
+
visibility: 'internal',
|
|
317
|
+
});
|
|
318
|
+
const succeeding = trail('entity.scope.ok', {
|
|
319
|
+
blaze: (_input, ctx) =>
|
|
320
|
+
Result.ok({ source: succeedingResource.from(ctx).source }),
|
|
321
|
+
input: z.object({}),
|
|
322
|
+
output: z.object({ source: z.string() }),
|
|
323
|
+
resources: [succeedingResource],
|
|
324
|
+
visibility: 'internal',
|
|
325
|
+
});
|
|
326
|
+
const entry = trail('entity.branch.failure-scope', {
|
|
327
|
+
blaze: async (_input, ctx) => {
|
|
328
|
+
const [failed, succeeded] = await requireCross(ctx)([
|
|
329
|
+
[failing, {}],
|
|
330
|
+
[succeeding, {}],
|
|
331
|
+
] as const);
|
|
332
|
+
return Result.ok({
|
|
333
|
+
failed: failed.match({
|
|
334
|
+
err: (error) => error.message,
|
|
335
|
+
ok: () => 'ok',
|
|
336
|
+
}),
|
|
337
|
+
succeeded: succeeded.match({
|
|
338
|
+
err: () => 'err',
|
|
339
|
+
ok: (value) => value.source,
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
crosses: [failing, succeeding],
|
|
344
|
+
input: z.object({}),
|
|
345
|
+
output: z.object({
|
|
346
|
+
failed: z.string(),
|
|
347
|
+
succeeded: z.string(),
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
app: topo('cross-branch-failure-scope-topo', {
|
|
353
|
+
entry,
|
|
354
|
+
failing,
|
|
355
|
+
failingResource,
|
|
356
|
+
succeeding,
|
|
357
|
+
succeedingResource,
|
|
358
|
+
}),
|
|
359
|
+
entry,
|
|
360
|
+
failingId,
|
|
361
|
+
succeedingId,
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const createConcurrencyWorkerScenario = () => {
|
|
366
|
+
const captures = {
|
|
367
|
+
active: 0,
|
|
368
|
+
completionOrder: [] as string[],
|
|
369
|
+
maxActive: 0,
|
|
370
|
+
};
|
|
371
|
+
const worker = trail('entity.concurrent.worker', {
|
|
372
|
+
blaze: async (input: { delayMs: number; label: string }) => {
|
|
373
|
+
captures.active += 1;
|
|
374
|
+
captures.maxActive = Math.max(captures.maxActive, captures.active);
|
|
375
|
+
await Bun.sleep(input.delayMs);
|
|
376
|
+
captures.completionOrder.push(input.label);
|
|
377
|
+
captures.active -= 1;
|
|
378
|
+
return Result.ok({ label: input.label });
|
|
379
|
+
},
|
|
380
|
+
input: z.object({
|
|
381
|
+
delayMs: z.number(),
|
|
382
|
+
label: z.string(),
|
|
383
|
+
}),
|
|
384
|
+
output: z.object({ label: z.string() }),
|
|
385
|
+
visibility: 'internal',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { captures, worker };
|
|
389
|
+
};
|
|
390
|
+
|
|
107
391
|
// ---------------------------------------------------------------------------
|
|
108
392
|
// Tests
|
|
109
393
|
// ---------------------------------------------------------------------------
|
|
110
394
|
|
|
111
395
|
describe('executeTrail', () => {
|
|
112
396
|
describe('happy path', () => {
|
|
113
|
-
|
|
114
|
-
|
|
397
|
+
describe('execution and resources', () => {
|
|
398
|
+
test('validates input and executes trail', async () => {
|
|
399
|
+
const result = await executeTrail(echoTrail, { value: 'hello' });
|
|
115
400
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
401
|
+
expect(result.isOk()).toBe(true);
|
|
402
|
+
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
403
|
+
});
|
|
119
404
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
405
|
+
test('eagerly resolves declared resources before layers and implementation run', async () => {
|
|
406
|
+
const id = nextResourceId('eager');
|
|
407
|
+
const captures = {
|
|
408
|
+
createCalls: 0,
|
|
409
|
+
gateResolved: undefined as number | undefined,
|
|
410
|
+
runResolved: undefined as number | undefined,
|
|
411
|
+
};
|
|
412
|
+
const counter = createResolvedValueResource(
|
|
413
|
+
id,
|
|
414
|
+
() => {
|
|
415
|
+
captures.createCalls += 1;
|
|
416
|
+
},
|
|
417
|
+
41
|
|
418
|
+
);
|
|
419
|
+
const layeredTrail = createEagerResourceTrail(id, counter, (value) => {
|
|
420
|
+
captures.runResolved = value;
|
|
421
|
+
});
|
|
422
|
+
const layer = createResourceProbeGate(counter, (value) => {
|
|
423
|
+
captures.gateResolved = value;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await executeTrail(
|
|
427
|
+
layeredTrail,
|
|
428
|
+
{},
|
|
429
|
+
{ layers: [layer] }
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(result.unwrap()).toEqual({ total: 42 });
|
|
433
|
+
expect(captures.createCalls).toBe(1);
|
|
434
|
+
expect(captures.gateResolved).toBe(41);
|
|
435
|
+
expect(captures.runResolved).toBe(41);
|
|
136
436
|
});
|
|
137
|
-
|
|
138
|
-
|
|
437
|
+
|
|
438
|
+
test('awaits async resource factories before running the trail', async () => {
|
|
439
|
+
const db = resource(nextResourceId('async-factory'), {
|
|
440
|
+
create: async () => {
|
|
441
|
+
await Bun.sleep(0);
|
|
442
|
+
return Result.ok({ source: 'async-factory' });
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const resourceTrail = trail('resource.async-factory', {
|
|
446
|
+
blaze: (_input, ctx) =>
|
|
447
|
+
Result.ok({ source: db.from(ctx).source as string }),
|
|
448
|
+
input: z.object({}),
|
|
449
|
+
output: z.object({ source: z.string() }),
|
|
450
|
+
resources: [db],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const result = await executeTrail(resourceTrail, {});
|
|
454
|
+
|
|
455
|
+
expect(result.isOk()).toBe(true);
|
|
456
|
+
expect(result.unwrap()).toEqual({ source: 'async-factory' });
|
|
139
457
|
});
|
|
140
458
|
|
|
141
|
-
|
|
459
|
+
test('reuses cached singleton resources across executions', async () => {
|
|
460
|
+
const id = nextResourceId('singleton');
|
|
461
|
+
const captures = { createCalls: 0 };
|
|
462
|
+
const singleton = createSingletonResource(id, () => {
|
|
463
|
+
captures.createCalls += 1;
|
|
464
|
+
return captures.createCalls;
|
|
465
|
+
});
|
|
466
|
+
const singletonTrail = createSingletonTrail(singleton);
|
|
467
|
+
|
|
468
|
+
const outputs = [
|
|
469
|
+
await unwrapExecution(singletonTrail),
|
|
470
|
+
await unwrapExecution(singletonTrail),
|
|
471
|
+
];
|
|
142
472
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
473
|
+
expect(outputs).toEqual([{ createdAtCall: 1 }, { createdAtCall: 1 }]);
|
|
474
|
+
expect(captures.createCalls).toBe(1);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('scopes cached singleton resources to compatible resource contexts', async () => {
|
|
478
|
+
const id = nextResourceId('singleton-context');
|
|
479
|
+
const envAwareResource = resource(id, {
|
|
480
|
+
create: (ctx) => Result.ok({ value: String(ctx.env?.VAL) }),
|
|
481
|
+
});
|
|
482
|
+
const envAwareTrail = trail('resource.singleton-context', {
|
|
483
|
+
blaze: (_input, ctx) =>
|
|
484
|
+
Result.ok({ value: envAwareResource.from(ctx).value }),
|
|
485
|
+
input: z.object({}),
|
|
486
|
+
output: z.object({ value: z.string() }),
|
|
487
|
+
resources: [envAwareResource],
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const first = await executeTrail(
|
|
491
|
+
envAwareTrail,
|
|
492
|
+
{},
|
|
493
|
+
{
|
|
494
|
+
createContext: () =>
|
|
495
|
+
createTrailContext({
|
|
496
|
+
env: { VAL: 'first' },
|
|
497
|
+
}),
|
|
498
|
+
}
|
|
499
|
+
);
|
|
500
|
+
const second = await executeTrail(
|
|
501
|
+
envAwareTrail,
|
|
502
|
+
{},
|
|
503
|
+
{
|
|
504
|
+
createContext: () =>
|
|
505
|
+
createTrailContext({
|
|
506
|
+
env: { VAL: 'second' },
|
|
507
|
+
}),
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(first.unwrap()).toEqual({ value: 'first' });
|
|
512
|
+
expect(second.unwrap()).toEqual({ value: 'second' });
|
|
513
|
+
});
|
|
147
514
|
});
|
|
148
515
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
516
|
+
describe('crossing execution', () => {
|
|
517
|
+
test('binds ctx.cross when topo access is available', async () => {
|
|
518
|
+
const helper = trail('entity.secret.rotate', {
|
|
519
|
+
blaze: (input: { id: string }) => Result.ok({ rotated: input.id }),
|
|
520
|
+
input: z.object({ id: z.string() }),
|
|
521
|
+
output: z.object({ rotated: z.string() }),
|
|
522
|
+
visibility: 'internal',
|
|
523
|
+
});
|
|
524
|
+
const entry = trail('entity.rotate', {
|
|
525
|
+
blaze: async (input: { id: string }, ctx) => {
|
|
526
|
+
const crossed = await requireCross(ctx)(
|
|
527
|
+
'entity.secret.rotate',
|
|
528
|
+
input
|
|
529
|
+
);
|
|
530
|
+
return crossed.match({
|
|
531
|
+
err: (error) => Result.err(error),
|
|
532
|
+
ok: (value) => Result.ok(value),
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
crosses: ['entity.secret.rotate'],
|
|
536
|
+
input: z.object({ id: z.string() }),
|
|
537
|
+
output: z.object({ rotated: z.string() }),
|
|
538
|
+
});
|
|
539
|
+
const app = topo('cross-topo', { entry, helper });
|
|
540
|
+
|
|
541
|
+
const result = await executeTrail(
|
|
542
|
+
entry,
|
|
543
|
+
{ id: 'abc123' },
|
|
544
|
+
{ topo: app }
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
expect(result.isOk()).toBe(true);
|
|
548
|
+
expect(result.unwrap()).toEqual({ rotated: 'abc123' });
|
|
155
549
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
550
|
+
|
|
551
|
+
test('validates merged crossInput fields when invoking through ctx.cross', async () => {
|
|
552
|
+
const helper = trail('entity.prepare', {
|
|
553
|
+
blaze: (input: { forkedFrom: string; id: string }) =>
|
|
554
|
+
Result.ok({ summary: `${input.id}:${input.forkedFrom}` }),
|
|
555
|
+
crossInput: z.object({ forkedFrom: z.string() }),
|
|
556
|
+
input: z.object({ id: z.string() }),
|
|
557
|
+
output: z.object({ summary: z.string() }),
|
|
558
|
+
visibility: 'internal',
|
|
559
|
+
});
|
|
560
|
+
const entry = trail('entity.run', {
|
|
561
|
+
blaze: async (input: { id: string }, ctx) => {
|
|
562
|
+
const crossed = await requireCross(ctx)(helper, {
|
|
563
|
+
forkedFrom: 'entity.run',
|
|
564
|
+
id: input.id,
|
|
565
|
+
});
|
|
566
|
+
return crossed.match({
|
|
567
|
+
err: (error) => Result.err(error),
|
|
568
|
+
ok: (value) => Result.ok(value),
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
crosses: [helper],
|
|
572
|
+
input: z.object({ id: z.string() }),
|
|
573
|
+
output: z.object({ summary: z.string() }),
|
|
574
|
+
});
|
|
575
|
+
const app = topo('cross-input-topo', { entry, helper });
|
|
576
|
+
|
|
577
|
+
const result = await executeTrail(
|
|
578
|
+
entry,
|
|
579
|
+
{ id: 'abc123' },
|
|
580
|
+
{ topo: app }
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
expect(result.isOk()).toBe(true);
|
|
584
|
+
expect(result.unwrap()).toEqual({ summary: 'abc123:entity.run' });
|
|
162
585
|
});
|
|
163
586
|
|
|
164
|
-
|
|
587
|
+
test('executes batch ctx.cross() calls concurrently and preserves tuple order', async () => {
|
|
588
|
+
const completionOrder: string[] = [];
|
|
589
|
+
const slow = trail('entity.slow', {
|
|
590
|
+
blaze: async () => {
|
|
591
|
+
await Bun.sleep(10);
|
|
592
|
+
completionOrder.push('slow');
|
|
593
|
+
return Result.ok({ id: 'slow' });
|
|
594
|
+
},
|
|
595
|
+
input: z.object({}),
|
|
596
|
+
output: z.object({ id: z.string() }),
|
|
597
|
+
visibility: 'internal',
|
|
598
|
+
});
|
|
599
|
+
const fast = trail('entity.fast', {
|
|
600
|
+
blaze: async () => {
|
|
601
|
+
await Bun.sleep(0);
|
|
602
|
+
completionOrder.push('fast');
|
|
603
|
+
return Result.ok({ id: 'fast' });
|
|
604
|
+
},
|
|
605
|
+
input: z.object({}),
|
|
606
|
+
output: z.object({ id: z.string() }),
|
|
607
|
+
visibility: 'internal',
|
|
608
|
+
});
|
|
609
|
+
const entry = trail('entity.batch', {
|
|
610
|
+
blaze: async (_input, ctx) => {
|
|
611
|
+
const crossed = await requireCross(ctx)([
|
|
612
|
+
['entity.slow', {}],
|
|
613
|
+
['entity.fast', {}],
|
|
614
|
+
]);
|
|
615
|
+
return Result.ok({
|
|
616
|
+
completionOrder,
|
|
617
|
+
resultOrder: crossed.map((result) =>
|
|
618
|
+
result.match({
|
|
619
|
+
err: () => 'err',
|
|
620
|
+
ok: (value) => (value as { id: string }).id,
|
|
621
|
+
})
|
|
622
|
+
),
|
|
623
|
+
});
|
|
624
|
+
},
|
|
625
|
+
crosses: ['entity.fast', 'entity.slow'],
|
|
626
|
+
input: z.object({}),
|
|
627
|
+
output: z.object({
|
|
628
|
+
completionOrder: z.array(z.string()),
|
|
629
|
+
resultOrder: z.array(z.string()),
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
const app = topo('cross-batch-topo', { entry, fast, slow });
|
|
165
633
|
|
|
166
|
-
|
|
167
|
-
expect(result.unwrap()).toEqual({ source: 'async-factory' });
|
|
168
|
-
});
|
|
634
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
169
635
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return captures.createCalls;
|
|
636
|
+
expect(result.isOk()).toBe(true);
|
|
637
|
+
expect(result.unwrap()).toEqual({
|
|
638
|
+
completionOrder: ['fast', 'slow'],
|
|
639
|
+
resultOrder: ['slow', 'fast'],
|
|
640
|
+
});
|
|
176
641
|
});
|
|
177
|
-
const singletonTrail = createSingletonTrail(singleton);
|
|
178
642
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
643
|
+
test('returns every batch cross result without short-circuiting on errors', async () => {
|
|
644
|
+
const completions: string[] = [];
|
|
645
|
+
const failing = trail('entity.fail', {
|
|
646
|
+
blaze: async () => {
|
|
647
|
+
await Bun.sleep(0);
|
|
648
|
+
completions.push('fail');
|
|
649
|
+
return Result.err(new ValidationError('nope'));
|
|
650
|
+
},
|
|
651
|
+
input: z.object({}),
|
|
652
|
+
output: z.object({ ok: z.boolean() }),
|
|
653
|
+
visibility: 'internal',
|
|
654
|
+
});
|
|
655
|
+
const succeeding = trail('entity.ok', {
|
|
656
|
+
blaze: async () => {
|
|
657
|
+
await Bun.sleep(5);
|
|
658
|
+
completions.push('ok');
|
|
659
|
+
return Result.ok({ ok: true });
|
|
660
|
+
},
|
|
661
|
+
input: z.object({}),
|
|
662
|
+
output: z.object({ ok: z.boolean() }),
|
|
663
|
+
visibility: 'internal',
|
|
664
|
+
});
|
|
665
|
+
const entry = trail('entity.batch.errors', {
|
|
666
|
+
blaze: async (_input, ctx) => {
|
|
667
|
+
const crossed = await requireCross(ctx)([
|
|
668
|
+
[failing, {}],
|
|
669
|
+
[succeeding, {}],
|
|
670
|
+
] as const);
|
|
671
|
+
return Result.ok({
|
|
672
|
+
completions,
|
|
673
|
+
statuses: crossed.map((result) =>
|
|
674
|
+
result.match({
|
|
675
|
+
err: () => 'err',
|
|
676
|
+
ok: () => 'ok',
|
|
677
|
+
})
|
|
678
|
+
),
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
crosses: [failing, succeeding],
|
|
682
|
+
input: z.object({}),
|
|
683
|
+
output: z.object({
|
|
684
|
+
completions: z.array(z.string()),
|
|
685
|
+
statuses: z.array(z.string()),
|
|
686
|
+
}),
|
|
687
|
+
});
|
|
688
|
+
const app = topo('cross-batch-errors-topo', {
|
|
689
|
+
entry,
|
|
690
|
+
failing,
|
|
691
|
+
succeeding,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
695
|
+
|
|
696
|
+
expect(result.isOk()).toBe(true);
|
|
697
|
+
expect(result.unwrap()).toEqual({
|
|
698
|
+
completions: ['fail', 'ok'],
|
|
699
|
+
statuses: ['err', 'ok'],
|
|
700
|
+
});
|
|
701
|
+
});
|
|
183
702
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
703
|
+
test('returns an empty array for empty batch ctx.cross() calls', async () => {
|
|
704
|
+
const entry = trail('entity.batch.empty', {
|
|
705
|
+
blaze: async (_input, ctx) => {
|
|
706
|
+
const crossed = await requireCross(ctx)([]);
|
|
707
|
+
return Result.ok({ count: crossed.length });
|
|
708
|
+
},
|
|
709
|
+
input: z.object({}),
|
|
710
|
+
output: z.object({ count: z.number() }),
|
|
711
|
+
});
|
|
712
|
+
const app = topo('cross-batch-empty-topo', { entry });
|
|
713
|
+
|
|
714
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
187
715
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const envAwareProvision = provision(id, {
|
|
191
|
-
create: (ctx) => Result.ok({ value: String(ctx.env?.VAL) }),
|
|
716
|
+
expect(result.isOk()).toBe(true);
|
|
717
|
+
expect(result.unwrap()).toEqual({ count: 0 });
|
|
192
718
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
719
|
+
|
|
720
|
+
test('limits batch ctx.cross() execution to sequential flow when concurrency is 1', async () => {
|
|
721
|
+
const { captures, worker } = createConcurrencyWorkerScenario();
|
|
722
|
+
const entry = trail('entity.batch.sequential-limit', {
|
|
723
|
+
blaze: async (_input, ctx) => {
|
|
724
|
+
const crossed = await requireCross(ctx)(
|
|
725
|
+
[
|
|
726
|
+
[worker, { delayMs: 5, label: 'first' }],
|
|
727
|
+
[worker, { delayMs: 0, label: 'second' }],
|
|
728
|
+
[worker, { delayMs: 0, label: 'third' }],
|
|
729
|
+
] as const,
|
|
730
|
+
{ concurrency: 1 }
|
|
731
|
+
);
|
|
732
|
+
return Result.ok({
|
|
733
|
+
completionOrder: captures.completionOrder,
|
|
734
|
+
maxActive: captures.maxActive,
|
|
735
|
+
resultOrder: crossed.map((result) =>
|
|
736
|
+
result.match({
|
|
737
|
+
err: () => 'err',
|
|
738
|
+
ok: (value) => value.label,
|
|
739
|
+
})
|
|
740
|
+
),
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
crosses: [worker],
|
|
744
|
+
input: z.object({}),
|
|
745
|
+
output: z.object({
|
|
746
|
+
completionOrder: z.array(z.string()),
|
|
747
|
+
maxActive: z.number(),
|
|
748
|
+
resultOrder: z.array(z.string()),
|
|
749
|
+
}),
|
|
750
|
+
});
|
|
751
|
+
const app = topo('cross-batch-sequential-limit-topo', {
|
|
752
|
+
entry,
|
|
753
|
+
worker,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
757
|
+
|
|
758
|
+
expect(result.isOk()).toBe(true);
|
|
759
|
+
expect(result.unwrap()).toEqual({
|
|
760
|
+
completionOrder: ['first', 'second', 'third'],
|
|
761
|
+
maxActive: 1,
|
|
762
|
+
resultOrder: ['first', 'second', 'third'],
|
|
763
|
+
});
|
|
199
764
|
});
|
|
200
765
|
|
|
201
|
-
|
|
202
|
-
|
|
766
|
+
test('caps concurrent batch ctx.cross() execution and preserves input order', async () => {
|
|
767
|
+
const { captures, worker } = createConcurrencyWorkerScenario();
|
|
768
|
+
const labels = Array.from(
|
|
769
|
+
{ length: 10 },
|
|
770
|
+
(_, index) => `task-${index}`
|
|
771
|
+
);
|
|
772
|
+
const entry = trail('entity.batch.concurrent-limit', {
|
|
773
|
+
blaze: async (_input, ctx) => {
|
|
774
|
+
const crossed = await requireCross(ctx)(
|
|
775
|
+
labels.map((label) => [worker, { delayMs: 5, label }] as const),
|
|
776
|
+
{ concurrency: 3 }
|
|
777
|
+
);
|
|
778
|
+
return Result.ok({
|
|
779
|
+
completionCount: captures.completionOrder.length,
|
|
780
|
+
maxActive: captures.maxActive,
|
|
781
|
+
resultOrder: crossed.map((result) =>
|
|
782
|
+
result.match({
|
|
783
|
+
err: () => 'err',
|
|
784
|
+
ok: (value) => value.label,
|
|
785
|
+
})
|
|
786
|
+
),
|
|
787
|
+
});
|
|
788
|
+
},
|
|
789
|
+
crosses: [worker],
|
|
790
|
+
input: z.object({}),
|
|
791
|
+
output: z.object({
|
|
792
|
+
completionCount: z.number(),
|
|
793
|
+
maxActive: z.number(),
|
|
794
|
+
resultOrder: z.array(z.string()),
|
|
795
|
+
}),
|
|
796
|
+
});
|
|
797
|
+
const app = topo('cross-batch-concurrent-limit-topo', {
|
|
798
|
+
entry,
|
|
799
|
+
worker,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
803
|
+
|
|
804
|
+
expect(result.isOk()).toBe(true);
|
|
805
|
+
expect(result.unwrap()).toEqual({
|
|
806
|
+
completionCount: 10,
|
|
807
|
+
maxActive: 3,
|
|
808
|
+
resultOrder: labels,
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe.each([
|
|
813
|
+
{ label: '0', value: 0 },
|
|
814
|
+
{ label: '-1', value: -1 },
|
|
815
|
+
{ label: '1.5', value: 1.5 },
|
|
816
|
+
{ label: 'NaN', value: Number.NaN },
|
|
817
|
+
])(
|
|
818
|
+
'rejects batch ctx.cross() calls with invalid concurrency $label',
|
|
819
|
+
({ value }) => {
|
|
820
|
+
test('produces a ValidationError for every branch without running any', async () => {
|
|
821
|
+
const branchRuns: string[] = [];
|
|
822
|
+
const child = trail('entity.batch.invalid-concurrency.child', {
|
|
823
|
+
blaze: async () => {
|
|
824
|
+
branchRuns.push('ran');
|
|
825
|
+
return Result.ok({ id: 'child' });
|
|
826
|
+
},
|
|
827
|
+
input: z.object({}),
|
|
828
|
+
output: z.object({ id: z.string() }),
|
|
829
|
+
visibility: 'internal',
|
|
830
|
+
});
|
|
831
|
+
const entry = trail('entity.batch.invalid-concurrency.entry', {
|
|
832
|
+
blaze: async (_input, ctx) => {
|
|
833
|
+
const crossed = await requireCross(ctx)(
|
|
834
|
+
[
|
|
835
|
+
[child, {}],
|
|
836
|
+
[child, {}],
|
|
837
|
+
] as const,
|
|
838
|
+
{ concurrency: value }
|
|
839
|
+
);
|
|
840
|
+
return Result.ok({
|
|
841
|
+
statuses: crossed.map((result) =>
|
|
842
|
+
result.match({
|
|
843
|
+
err: (error) => ({
|
|
844
|
+
isValidation: error instanceof ValidationError,
|
|
845
|
+
message: error.message,
|
|
846
|
+
}),
|
|
847
|
+
ok: () => ({ isValidation: false, message: 'ok' }),
|
|
848
|
+
})
|
|
849
|
+
),
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
crosses: [child],
|
|
853
|
+
input: z.object({}),
|
|
854
|
+
output: z.object({
|
|
855
|
+
statuses: z.array(
|
|
856
|
+
z.object({
|
|
857
|
+
isValidation: z.boolean(),
|
|
858
|
+
message: z.string(),
|
|
859
|
+
})
|
|
860
|
+
),
|
|
861
|
+
}),
|
|
862
|
+
});
|
|
863
|
+
const app = topo(
|
|
864
|
+
`cross-batch-invalid-concurrency-${String(value)}-topo`,
|
|
865
|
+
{ child, entry }
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const result = await executeTrail(entry, {}, { topo: app });
|
|
869
|
+
|
|
870
|
+
expect(result.isOk()).toBe(true);
|
|
871
|
+
expect(result.unwrap()).toEqual({
|
|
872
|
+
statuses: [
|
|
873
|
+
{
|
|
874
|
+
isValidation: true,
|
|
875
|
+
message:
|
|
876
|
+
'ctx.cross() batch concurrency must be a positive integer',
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
isValidation: true,
|
|
880
|
+
message:
|
|
881
|
+
'ctx.cross() batch concurrency must be a positive integer',
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
});
|
|
885
|
+
expect(branchRuns).toEqual([]);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
describe('concurrent crossings', () => {
|
|
893
|
+
test('resolves concurrent branch resources from branch scope instead of inheriting parent resources', async () => {
|
|
894
|
+
const { app, captures, entry, id } =
|
|
895
|
+
createConcurrentBranchResourceScopeScenario();
|
|
896
|
+
|
|
897
|
+
const result = await executeTrail(
|
|
898
|
+
entry,
|
|
203
899
|
{},
|
|
204
900
|
{
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
env: { VAL: 'first' },
|
|
208
|
-
}),
|
|
901
|
+
ctx: { extensions: { [id]: { source: 'parent-scope' } } },
|
|
902
|
+
topo: app,
|
|
209
903
|
}
|
|
210
904
|
);
|
|
211
|
-
|
|
212
|
-
|
|
905
|
+
|
|
906
|
+
expect(result.isOk()).toBe(true);
|
|
907
|
+
expect(result.unwrap()).toEqual({
|
|
908
|
+
sources: ['branch-scope-1', 'branch-scope-1'],
|
|
909
|
+
});
|
|
910
|
+
expect(captures.createCalls).toBe(1);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test('propagates the same AbortSignal to every concurrent branch', async () => {
|
|
914
|
+
const seenSignals: AbortSignal[] = [];
|
|
915
|
+
const { app, entry } = createAbortSignalScenario(seenSignals);
|
|
916
|
+
const abortSignal = AbortSignal.timeout(5);
|
|
917
|
+
|
|
918
|
+
const result = await executeTrail(entry, {}, { abortSignal, topo: app });
|
|
919
|
+
|
|
920
|
+
expect(result.isOk()).toBe(true);
|
|
921
|
+
expect(result.unwrap()).toEqual({
|
|
922
|
+
statuses: ['cancelled', 'cancelled'],
|
|
923
|
+
});
|
|
924
|
+
expect(seenSignals).toEqual([abortSignal, abortSignal]);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test('propagates the parent permit to every concurrent branch', async () => {
|
|
928
|
+
const permit = Object.freeze({
|
|
929
|
+
id: 'permit-orders',
|
|
930
|
+
scopes: ['orders:read'] as const,
|
|
931
|
+
});
|
|
932
|
+
const captures: PermitCapture[] = [];
|
|
933
|
+
const { app, entry } = createPermitScenario(permit, captures);
|
|
934
|
+
|
|
935
|
+
const result = await executeTrail(
|
|
936
|
+
entry,
|
|
937
|
+
{},
|
|
938
|
+
{ ctx: { permit }, topo: app }
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
expect(result.isOk()).toBe(true);
|
|
942
|
+
expect(result.unwrap()).toEqual({
|
|
943
|
+
permits: [
|
|
944
|
+
{ permitId: 'permit-orders', sameReference: true },
|
|
945
|
+
{ permitId: 'permit-orders', sameReference: true },
|
|
946
|
+
],
|
|
947
|
+
});
|
|
948
|
+
expect(captures).toEqual([
|
|
949
|
+
{ permitId: 'permit-orders', sameReference: true },
|
|
950
|
+
{ permitId: 'permit-orders', sameReference: true },
|
|
951
|
+
]);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test("keeps one branch's failure from poisoning sibling branch scopes", async () => {
|
|
955
|
+
const { app, entry, failingId, succeedingId } =
|
|
956
|
+
createSiblingFailureScopeScenario();
|
|
957
|
+
|
|
958
|
+
const result = await executeTrail(
|
|
959
|
+
entry,
|
|
213
960
|
{},
|
|
214
961
|
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
962
|
+
ctx: {
|
|
963
|
+
extensions: {
|
|
964
|
+
[failingId]: { source: 'parent-failing-scope' },
|
|
965
|
+
[succeedingId]: { source: 'parent-success-scope' },
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
topo: app,
|
|
219
969
|
}
|
|
220
970
|
);
|
|
221
971
|
|
|
222
|
-
expect(
|
|
223
|
-
expect(
|
|
972
|
+
expect(result.isOk()).toBe(true);
|
|
973
|
+
expect(result.unwrap()).toEqual({
|
|
974
|
+
failed: 'failing branch scope exploded',
|
|
975
|
+
succeeded: 'sibling-branch-scope',
|
|
976
|
+
});
|
|
224
977
|
});
|
|
225
978
|
});
|
|
226
979
|
|
|
@@ -233,11 +986,11 @@ describe('executeTrail', () => {
|
|
|
233
986
|
});
|
|
234
987
|
});
|
|
235
988
|
|
|
236
|
-
describe('
|
|
237
|
-
test('composes
|
|
989
|
+
describe('layers', () => {
|
|
990
|
+
test('composes layers around execution', async () => {
|
|
238
991
|
const log: string[] = [];
|
|
239
|
-
const
|
|
240
|
-
name: 'test-
|
|
992
|
+
const layer: Layer = {
|
|
993
|
+
name: 'test-layer',
|
|
241
994
|
wrap(_trail, impl) {
|
|
242
995
|
return async (input, ctx) => {
|
|
243
996
|
log.push('before');
|
|
@@ -251,7 +1004,7 @@ describe('executeTrail', () => {
|
|
|
251
1004
|
const result = await executeTrail(
|
|
252
1005
|
echoTrail,
|
|
253
1006
|
{ value: 'x' },
|
|
254
|
-
{
|
|
1007
|
+
{ layers: [layer] }
|
|
255
1008
|
);
|
|
256
1009
|
|
|
257
1010
|
expect(result.isOk()).toBe(true);
|
|
@@ -361,26 +1114,49 @@ describe('executeTrail', () => {
|
|
|
361
1114
|
ctx: { extensions: { userId: '123' } },
|
|
362
1115
|
}
|
|
363
1116
|
);
|
|
364
|
-
|
|
1117
|
+
// User-authored extension keys must stay intact regardless of whether
|
|
1118
|
+
// tracing later adds its own internal bookkeeping.
|
|
1119
|
+
expect(captured?.extensions?.store).toBe('db');
|
|
1120
|
+
expect(captured?.extensions?.userId).toBe('123');
|
|
365
1121
|
});
|
|
366
1122
|
|
|
367
|
-
test('
|
|
1123
|
+
test('keeps ctx.trace in passthrough mode when NOOP_SINK is installed', async () => {
|
|
1124
|
+
clearTraceSink();
|
|
1125
|
+
|
|
1126
|
+
let sawTraceContext = true;
|
|
1127
|
+
const probe = trail('ctx.trace.noop-sink', {
|
|
1128
|
+
blaze: async (_input, ctx) => {
|
|
1129
|
+
const value = await ctx.trace('inner', () => Promise.resolve(42));
|
|
1130
|
+
sawTraceContext = ctx.extensions?.[TRACE_CONTEXT_KEY] !== undefined;
|
|
1131
|
+
return Result.ok({ value });
|
|
1132
|
+
},
|
|
1133
|
+
input: z.object({}),
|
|
1134
|
+
output: z.object({ value: z.number() }),
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const result = await executeTrail(probe, {});
|
|
1138
|
+
|
|
1139
|
+
expect(result).toEqual(Result.ok({ value: 42 }));
|
|
1140
|
+
expect(sawTraceContext).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test('rebinds ctx.resource after merging extension overrides from createContext', async () => {
|
|
368
1144
|
let resolvedSource: string | undefined;
|
|
369
|
-
const db =
|
|
1145
|
+
const db = resource(nextResourceId('context-override'), {
|
|
370
1146
|
create: () => Result.ok({ source: 'factory' }),
|
|
371
1147
|
});
|
|
372
|
-
const
|
|
1148
|
+
const resourceTrail = trail('ctx.resource.override', {
|
|
373
1149
|
blaze: (_input, ctx) => {
|
|
374
|
-
resolvedSource = ctx.
|
|
1150
|
+
resolvedSource = ctx.resource<{ source: string }>(db).source;
|
|
375
1151
|
return Result.ok({ source: resolvedSource });
|
|
376
1152
|
},
|
|
377
1153
|
input: z.object({}),
|
|
378
1154
|
output: z.object({ source: z.string() }),
|
|
379
|
-
|
|
1155
|
+
resources: [db],
|
|
380
1156
|
});
|
|
381
1157
|
|
|
382
1158
|
const result = await executeTrail(
|
|
383
|
-
|
|
1159
|
+
resourceTrail,
|
|
384
1160
|
{},
|
|
385
1161
|
{
|
|
386
1162
|
createContext: () =>
|
|
@@ -401,17 +1177,17 @@ describe('executeTrail', () => {
|
|
|
401
1177
|
expect(resolvedSource).toBe('override-context');
|
|
402
1178
|
});
|
|
403
1179
|
|
|
404
|
-
test('context factory seeds the
|
|
1180
|
+
test('context factory seeds the resource accessor when omitted', async () => {
|
|
405
1181
|
let capturedCtx: TrailContext | undefined;
|
|
406
|
-
const id =
|
|
1182
|
+
const id = nextResourceId('factory-seed');
|
|
407
1183
|
const widget = { id: 'widget-1' };
|
|
408
|
-
const widgetTrail = trail('
|
|
1184
|
+
const widgetTrail = trail('resource.factory-seed', {
|
|
409
1185
|
blaze: (_input, ctx) => {
|
|
410
1186
|
capturedCtx = ctx;
|
|
411
1187
|
return Result.ok(null);
|
|
412
1188
|
},
|
|
413
1189
|
input: z.object({}),
|
|
414
|
-
|
|
1190
|
+
resources: [],
|
|
415
1191
|
});
|
|
416
1192
|
|
|
417
1193
|
await executeTrail(
|
|
@@ -421,12 +1197,12 @@ describe('executeTrail', () => {
|
|
|
421
1197
|
createContext: () => ({
|
|
422
1198
|
abortSignal: new AbortController().signal,
|
|
423
1199
|
extensions: { [id]: widget },
|
|
424
|
-
requestId: 'seeded-
|
|
1200
|
+
requestId: 'seeded-resource',
|
|
425
1201
|
}),
|
|
426
1202
|
}
|
|
427
1203
|
);
|
|
428
1204
|
|
|
429
|
-
expect(capturedCtx?.
|
|
1205
|
+
expect(capturedCtx?.resource(id)).toBe(widget);
|
|
430
1206
|
});
|
|
431
1207
|
});
|
|
432
1208
|
|
|
@@ -447,22 +1223,22 @@ describe('executeTrail', () => {
|
|
|
447
1223
|
expect(result.error.message).toBe('kaboom');
|
|
448
1224
|
});
|
|
449
1225
|
|
|
450
|
-
test('short-circuits when a
|
|
451
|
-
const
|
|
1226
|
+
test('short-circuits when a resource factory returns Result.err', async () => {
|
|
1227
|
+
const failingResource = resource(nextResourceId('factory-error'), {
|
|
452
1228
|
create: () =>
|
|
453
1229
|
Result.err(new ValidationError('DATABASE_URL is required')),
|
|
454
1230
|
});
|
|
455
1231
|
let ran = false;
|
|
456
|
-
const
|
|
1232
|
+
const resourceTrail = trail('resource.factory-error', {
|
|
457
1233
|
blaze: () => {
|
|
458
1234
|
ran = true;
|
|
459
1235
|
return Result.ok(null);
|
|
460
1236
|
},
|
|
461
1237
|
input: z.object({}),
|
|
462
|
-
|
|
1238
|
+
resources: [failingResource],
|
|
463
1239
|
});
|
|
464
1240
|
|
|
465
|
-
const result = await executeTrail(
|
|
1241
|
+
const result = await executeTrail(resourceTrail, {});
|
|
466
1242
|
|
|
467
1243
|
expect(result.isErr()).toBe(true);
|
|
468
1244
|
expect(result.error).toBeInstanceOf(ValidationError);
|
|
@@ -470,50 +1246,50 @@ describe('executeTrail', () => {
|
|
|
470
1246
|
expect(ran).toBe(false);
|
|
471
1247
|
});
|
|
472
1248
|
|
|
473
|
-
test('wraps thrown
|
|
474
|
-
const
|
|
1249
|
+
test('wraps thrown resource factory exceptions with the resource ID in context', async () => {
|
|
1250
|
+
const explodingResource = resource(nextResourceId('factory-throw'), {
|
|
475
1251
|
create: () => {
|
|
476
1252
|
throw new Error('boom');
|
|
477
1253
|
},
|
|
478
1254
|
});
|
|
479
|
-
const
|
|
1255
|
+
const resourceTrail = trail('resource.factory-throw', {
|
|
480
1256
|
blaze: () => Result.ok(null),
|
|
481
1257
|
input: z.object({}),
|
|
482
|
-
|
|
1258
|
+
resources: [explodingResource],
|
|
483
1259
|
});
|
|
484
1260
|
|
|
485
|
-
const result = await executeTrail(
|
|
1261
|
+
const result = await executeTrail(resourceTrail, {});
|
|
486
1262
|
|
|
487
1263
|
expect(result.isErr()).toBe(true);
|
|
488
1264
|
expect(result.error).toBeInstanceOf(InternalError);
|
|
489
|
-
expect(result.error.message).toContain(
|
|
1265
|
+
expect(result.error.message).toContain(explodingResource.id);
|
|
490
1266
|
expect(result.error.context).toEqual({
|
|
491
|
-
|
|
1267
|
+
resourceId: explodingResource.id,
|
|
492
1268
|
});
|
|
493
1269
|
});
|
|
494
1270
|
|
|
495
|
-
test('prefers explicit
|
|
496
|
-
const id =
|
|
1271
|
+
test('prefers explicit resource overrides over cached or created instances', async () => {
|
|
1272
|
+
const id = nextResourceId('override');
|
|
497
1273
|
let createCalls = 0;
|
|
498
|
-
const db =
|
|
1274
|
+
const db = resource(id, {
|
|
499
1275
|
create: () => {
|
|
500
1276
|
createCalls += 1;
|
|
501
1277
|
return Result.ok({ source: 'factory' });
|
|
502
1278
|
},
|
|
503
1279
|
});
|
|
504
|
-
const
|
|
1280
|
+
const resourceTrail = trail('resource.override', {
|
|
505
1281
|
blaze: (_input, ctx) =>
|
|
506
1282
|
Result.ok({ source: db.from(ctx).source as string }),
|
|
507
1283
|
input: z.object({}),
|
|
508
1284
|
output: z.object({ source: z.string() }),
|
|
509
|
-
|
|
1285
|
+
resources: [db],
|
|
510
1286
|
});
|
|
511
1287
|
|
|
512
1288
|
const result = await executeTrail(
|
|
513
|
-
|
|
1289
|
+
resourceTrail,
|
|
514
1290
|
{},
|
|
515
1291
|
{
|
|
516
|
-
|
|
1292
|
+
resources: { [id]: { source: 'override' } },
|
|
517
1293
|
}
|
|
518
1294
|
);
|
|
519
1295
|
|
|
@@ -522,4 +1298,266 @@ describe('executeTrail', () => {
|
|
|
522
1298
|
expect(createCalls).toBe(0);
|
|
523
1299
|
});
|
|
524
1300
|
});
|
|
1301
|
+
|
|
1302
|
+
// ---------------------------------------------------------------------------
|
|
1303
|
+
// Detour execution
|
|
1304
|
+
// ---------------------------------------------------------------------------
|
|
1305
|
+
|
|
1306
|
+
describe('detour execution', () => {
|
|
1307
|
+
test('detour recovers successfully from matching error', async () => {
|
|
1308
|
+
const detourTrail = trail('detour.recover', {
|
|
1309
|
+
blaze: () => Result.err(new ConflictError('version mismatch')),
|
|
1310
|
+
detours: [
|
|
1311
|
+
{
|
|
1312
|
+
on: ConflictError,
|
|
1313
|
+
recover: async () => Result.ok({ recovered: true }),
|
|
1314
|
+
},
|
|
1315
|
+
],
|
|
1316
|
+
input: z.object({}),
|
|
1317
|
+
output: z.object({ recovered: z.boolean() }),
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const result = await executeTrail(detourTrail, {});
|
|
1321
|
+
|
|
1322
|
+
expect(result.isOk()).toBe(true);
|
|
1323
|
+
expect(result.unwrap()).toEqual({ recovered: true });
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test('exhaustion produces RetryExhaustedError with category inheritance', async () => {
|
|
1327
|
+
const detourTrail = trail('detour.exhaust', {
|
|
1328
|
+
blaze: () => Result.err(new ConflictError('version mismatch')),
|
|
1329
|
+
detours: [
|
|
1330
|
+
{
|
|
1331
|
+
maxAttempts: 2,
|
|
1332
|
+
on: ConflictError,
|
|
1333
|
+
recover: async () =>
|
|
1334
|
+
Result.err(new ConflictError('still conflicting')),
|
|
1335
|
+
},
|
|
1336
|
+
],
|
|
1337
|
+
input: z.object({}),
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
const result = await executeTrail(detourTrail, {});
|
|
1341
|
+
|
|
1342
|
+
expect(result.isErr()).toBe(true);
|
|
1343
|
+
const { error } = result;
|
|
1344
|
+
expect(error).toBeInstanceOf(RetryExhaustedError);
|
|
1345
|
+
expect(error).toBeInstanceOf(InternalError);
|
|
1346
|
+
const exhausted = error as RetryExhaustedError;
|
|
1347
|
+
expect(exhausted.category).toBe('conflict');
|
|
1348
|
+
expect(exhausted.retryable).toBe(false);
|
|
1349
|
+
expect(exhausted.cause).toBeInstanceOf(ConflictError);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test('non-matching error passes through unchanged', async () => {
|
|
1353
|
+
const detourTrail = trail('detour.nomatch', {
|
|
1354
|
+
blaze: () => Result.err(new NotFoundError('missing')),
|
|
1355
|
+
detours: [
|
|
1356
|
+
{
|
|
1357
|
+
on: ConflictError,
|
|
1358
|
+
recover: async () => Result.ok({ recovered: true }),
|
|
1359
|
+
},
|
|
1360
|
+
],
|
|
1361
|
+
input: z.object({}),
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const result = await executeTrail(detourTrail, {});
|
|
1365
|
+
|
|
1366
|
+
expect(result.isErr()).toBe(true);
|
|
1367
|
+
expect(result.error).toBeInstanceOf(NotFoundError);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test('declaration-order matching — first matching detour wins', async () => {
|
|
1371
|
+
const calls: string[] = [];
|
|
1372
|
+
const detourTrail = trail('detour.order', {
|
|
1373
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1374
|
+
detours: [
|
|
1375
|
+
{
|
|
1376
|
+
on: TrailsError,
|
|
1377
|
+
recover: async () => {
|
|
1378
|
+
calls.push('TrailsError');
|
|
1379
|
+
return Result.ok({ winner: 'superclass' });
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
on: ConflictError,
|
|
1384
|
+
recover: async () => {
|
|
1385
|
+
calls.push('ConflictError');
|
|
1386
|
+
return Result.ok({ winner: 'subclass' });
|
|
1387
|
+
},
|
|
1388
|
+
},
|
|
1389
|
+
],
|
|
1390
|
+
input: z.object({}),
|
|
1391
|
+
output: z.object({ winner: z.string() }),
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
const result = await executeTrail(detourTrail, {});
|
|
1395
|
+
|
|
1396
|
+
expect(result.isOk()).toBe(true);
|
|
1397
|
+
expect(result.unwrap()).toEqual({ winner: 'superclass' });
|
|
1398
|
+
expect(calls).toEqual(['TrailsError']);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test('multi-attempt success on 2nd try', async () => {
|
|
1402
|
+
let recoverCalls = 0;
|
|
1403
|
+
const responses: Result<{ attempt: number }, TrailsError>[] = [
|
|
1404
|
+
Result.err(new ConflictError('still conflicting')),
|
|
1405
|
+
Result.ok({ attempt: 2 }),
|
|
1406
|
+
];
|
|
1407
|
+
const detourTrail = trail('detour.multi', {
|
|
1408
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1409
|
+
detours: [
|
|
1410
|
+
{
|
|
1411
|
+
maxAttempts: 3,
|
|
1412
|
+
on: ConflictError,
|
|
1413
|
+
recover: async () => {
|
|
1414
|
+
recoverCalls += 1;
|
|
1415
|
+
return responses.shift() as Result<never, TrailsError>;
|
|
1416
|
+
},
|
|
1417
|
+
},
|
|
1418
|
+
],
|
|
1419
|
+
input: z.object({}),
|
|
1420
|
+
output: z.object({ attempt: z.number() }),
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
const result = await executeTrail(detourTrail, {});
|
|
1424
|
+
|
|
1425
|
+
expect(result.isOk()).toBe(true);
|
|
1426
|
+
expect(result.unwrap()).toEqual({ attempt: 2 });
|
|
1427
|
+
expect(recoverCalls).toBe(2);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test('non-TrailsError throw inside recover becomes InternalError', async () => {
|
|
1431
|
+
const detourTrail = trail('detour.throw', {
|
|
1432
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1433
|
+
detours: [
|
|
1434
|
+
{
|
|
1435
|
+
on: ConflictError,
|
|
1436
|
+
recover: async () => {
|
|
1437
|
+
throw new Error('plain error in recover');
|
|
1438
|
+
},
|
|
1439
|
+
},
|
|
1440
|
+
],
|
|
1441
|
+
input: z.object({}),
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
const result = await executeTrail(detourTrail, {});
|
|
1445
|
+
|
|
1446
|
+
expect(result.isErr()).toBe(true);
|
|
1447
|
+
expect(result.error).toBeInstanceOf(InternalError);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test('maxAttempts hard cap at 5', async () => {
|
|
1451
|
+
let recoverCalls = 0;
|
|
1452
|
+
const detourTrail = trail('detour.cap', {
|
|
1453
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1454
|
+
detours: [
|
|
1455
|
+
{
|
|
1456
|
+
maxAttempts: 100,
|
|
1457
|
+
on: ConflictError,
|
|
1458
|
+
recover: async () => {
|
|
1459
|
+
recoverCalls += 1;
|
|
1460
|
+
return Result.err(new ConflictError('still conflicting'));
|
|
1461
|
+
},
|
|
1462
|
+
},
|
|
1463
|
+
],
|
|
1464
|
+
input: z.object({}),
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
const result = await executeTrail(detourTrail, {});
|
|
1468
|
+
|
|
1469
|
+
expect(result.isErr()).toBe(true);
|
|
1470
|
+
expect(result.error).toBeInstanceOf(RetryExhaustedError);
|
|
1471
|
+
expect(recoverCalls).toBe(5);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
test('no detours declared — baseline unchanged', async () => {
|
|
1475
|
+
const noDetourTrail = trail('detour.none', {
|
|
1476
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1477
|
+
input: z.object({}),
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
const result = await executeTrail(noDetourTrail, {});
|
|
1481
|
+
|
|
1482
|
+
expect(result.isErr()).toBe(true);
|
|
1483
|
+
expect(result.error).toBeInstanceOf(ConflictError);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
test('cross-detour termination — different error type stops recovery', async () => {
|
|
1487
|
+
const detourTrail = trail('detour.cross-terminate', {
|
|
1488
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1489
|
+
detours: [
|
|
1490
|
+
{
|
|
1491
|
+
maxAttempts: 3,
|
|
1492
|
+
on: ConflictError,
|
|
1493
|
+
recover: async () => Result.err(new NetworkError('disconnected')),
|
|
1494
|
+
},
|
|
1495
|
+
],
|
|
1496
|
+
input: z.object({}),
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
const result = await executeTrail(detourTrail, {});
|
|
1500
|
+
|
|
1501
|
+
expect(result.isErr()).toBe(true);
|
|
1502
|
+
expect(result.error).toBeInstanceOf(NetworkError);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
describe('layer interaction', () => {
|
|
1506
|
+
const createCountingLayer = () => {
|
|
1507
|
+
let wrapCalls = 0;
|
|
1508
|
+
let execCalls = 0;
|
|
1509
|
+
const layer: Layer = {
|
|
1510
|
+
name: 'counting',
|
|
1511
|
+
wrap(_trail, impl) {
|
|
1512
|
+
wrapCalls += 1;
|
|
1513
|
+
return async (input, ctx) => {
|
|
1514
|
+
execCalls += 1;
|
|
1515
|
+
return await impl(input, ctx);
|
|
1516
|
+
};
|
|
1517
|
+
},
|
|
1518
|
+
};
|
|
1519
|
+
return {
|
|
1520
|
+
execCalls: () => execCalls,
|
|
1521
|
+
layer,
|
|
1522
|
+
wrapCalls: () => wrapCalls,
|
|
1523
|
+
};
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
test('layer wraps once per logical execution, not per attempt', async () => {
|
|
1527
|
+
const counting = createCountingLayer();
|
|
1528
|
+
let recoverCalls = 0;
|
|
1529
|
+
const responses: Result<{ done: boolean }, TrailsError>[] = [
|
|
1530
|
+
Result.err(new ConflictError('still conflicting')),
|
|
1531
|
+
Result.err(new ConflictError('still conflicting')),
|
|
1532
|
+
Result.ok({ done: true }),
|
|
1533
|
+
];
|
|
1534
|
+
const detourTrail = trail('detour.layer-count', {
|
|
1535
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
1536
|
+
detours: [
|
|
1537
|
+
{
|
|
1538
|
+
maxAttempts: 3,
|
|
1539
|
+
on: ConflictError,
|
|
1540
|
+
recover: async () => {
|
|
1541
|
+
recoverCalls += 1;
|
|
1542
|
+
return responses.shift() as Result<never, TrailsError>;
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1545
|
+
],
|
|
1546
|
+
input: z.object({}),
|
|
1547
|
+
output: z.object({ done: z.boolean() }),
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
const result = await executeTrail(
|
|
1551
|
+
detourTrail,
|
|
1552
|
+
{},
|
|
1553
|
+
{ layers: [counting.layer] }
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
expect(result.isOk()).toBe(true);
|
|
1557
|
+
expect(counting.wrapCalls()).toBe(1);
|
|
1558
|
+
expect(counting.execCalls()).toBe(1);
|
|
1559
|
+
expect(recoverCalls).toBe(3);
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
525
1563
|
});
|