@ontrails/core 1.0.0-beta.15 → 1.0.0-beta.16
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 +63 -0
- package/README.md +61 -16
- package/package.json +9 -8
- package/src/activation-provenance.ts +111 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-projection.ts +221 -0
- package/src/activation-source.ts +91 -0
- package/src/blob-ref.ts +51 -0
- package/src/context.ts +4 -0
- package/src/{internal/cross-batch.ts → cross-batch.ts} +3 -11
- package/src/detours.ts +8 -0
- package/src/draft.ts +4 -0
- package/src/error-projection.ts +51 -0
- package/src/errors.ts +192 -39
- package/src/execute.ts +443 -44
- package/src/fire.ts +965 -91
- package/src/index.ts +285 -38
- package/src/layer-projection.ts +192 -0
- package/src/layer.ts +32 -1
- package/src/observe.ts +361 -0
- package/src/permits.ts +12 -0
- package/src/redaction/patterns.ts +6 -3
- package/src/resilience.ts +8 -8
- package/src/resource-config.ts +477 -44
- package/src/resource.ts +21 -1
- package/src/result.ts +18 -0
- package/src/run.ts +2 -2
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +324 -0
- package/src/serialization.ts +90 -57
- package/src/signal-diagnostics.ts +633 -0
- package/src/{internal/signal-ref.ts → signal-ref.ts} +1 -1
- package/src/signal.ts +37 -0
- package/src/store/accessor-protocol.ts +5 -5
- package/src/structured-examples.ts +245 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +7 -13
- package/src/topo.ts +386 -6
- package/src/tracing.ts +345 -0
- package/src/trail.ts +168 -22
- package/src/trails/derive-trail.ts +1 -1
- package/src/trails/ingest.ts +7 -15
- package/src/{internal/trails-db.ts → trails-db.ts} +42 -21
- package/src/transport-error-map.ts +146 -28
- package/src/types.ts +82 -28
- package/src/validate-established-topo.ts +6 -0
- package/src/validate-topo.ts +212 -5
- package/src/validation.ts +58 -1
- package/src/webhook.ts +276 -0
- package/src/{internal/zod-wrappers.ts → zod-wrappers.ts} +0 -6
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/blob-ref.d.ts +0 -20
- package/dist/blob-ref.d.ts.map +0 -1
- package/dist/blob-ref.js +0 -22
- package/dist/blob-ref.js.map +0 -1
- package/dist/branded.d.ts +0 -36
- package/dist/branded.d.ts.map +0 -1
- package/dist/branded.js +0 -89
- package/dist/branded.js.map +0 -1
- package/dist/collections.d.ts +0 -31
- package/dist/collections.d.ts.map +0 -1
- package/dist/collections.js +0 -60
- package/dist/collections.js.map +0 -1
- package/dist/context.d.ts +0 -22
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -37
- package/dist/context.js.map +0 -1
- package/dist/contour.d.ts +0 -81
- package/dist/contour.d.ts.map +0 -1
- package/dist/contour.js +0 -176
- package/dist/contour.js.map +0 -1
- package/dist/cross-schema.d.ts +0 -18
- package/dist/cross-schema.d.ts.map +0 -1
- package/dist/cross-schema.js +0 -29
- package/dist/cross-schema.js.map +0 -1
- package/dist/derive.d.ts +0 -44
- package/dist/derive.d.ts.map +0 -1
- package/dist/derive.js +0 -149
- package/dist/derive.js.map +0 -1
- package/dist/draft.d.ts +0 -28
- package/dist/draft.d.ts.map +0 -1
- package/dist/draft.js +0 -157
- package/dist/draft.js.map +0 -1
- package/dist/errors.d.ts +0 -141
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -185
- package/dist/errors.js.map +0 -1
- package/dist/execute.d.ts +0 -52
- package/dist/execute.d.ts.map +0 -1
- package/dist/execute.js +0 -511
- package/dist/execute.js.map +0 -1
- package/dist/fetch.d.ts +0 -15
- package/dist/fetch.d.ts.map +0 -1
- package/dist/fetch.js +0 -102
- package/dist/fetch.js.map +0 -1
- package/dist/fire.d.ts +0 -43
- package/dist/fire.d.ts.map +0 -1
- package/dist/fire.js +0 -185
- package/dist/fire.js.map +0 -1
- package/dist/guards.d.ts +0 -17
- package/dist/guards.d.ts.map +0 -1
- package/dist/guards.js +0 -25
- package/dist/guards.js.map +0 -1
- package/dist/index.d.ts +0 -59
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -61
- package/dist/index.js.map +0 -1
- package/dist/internal/cross-batch.d.ts +0 -44
- package/dist/internal/cross-batch.d.ts.map +0 -1
- package/dist/internal/cross-batch.js +0 -58
- package/dist/internal/cross-batch.js.map +0 -1
- package/dist/internal/fork-ctx.d.ts +0 -41
- package/dist/internal/fork-ctx.d.ts.map +0 -1
- package/dist/internal/fork-ctx.js +0 -47
- package/dist/internal/fork-ctx.js.map +0 -1
- package/dist/internal/signal-ref.d.ts +0 -15
- package/dist/internal/signal-ref.d.ts.map +0 -1
- package/dist/internal/signal-ref.js +0 -41
- package/dist/internal/signal-ref.js.map +0 -1
- package/dist/internal/topo-saves.d.ts +0 -53
- package/dist/internal/topo-saves.d.ts.map +0 -1
- package/dist/internal/topo-saves.js +0 -449
- package/dist/internal/topo-saves.js.map +0 -1
- package/dist/internal/topo-snapshots.d.ts +0 -57
- package/dist/internal/topo-snapshots.d.ts.map +0 -1
- package/dist/internal/topo-snapshots.js +0 -372
- package/dist/internal/topo-snapshots.js.map +0 -1
- package/dist/internal/topo-store-read.d.ts +0 -70
- package/dist/internal/topo-store-read.d.ts.map +0 -1
- package/dist/internal/topo-store-read.js +0 -221
- package/dist/internal/topo-store-read.js.map +0 -1
- package/dist/internal/topo-store.d.ts +0 -26
- package/dist/internal/topo-store.d.ts.map +0 -1
- package/dist/internal/topo-store.js +0 -621
- package/dist/internal/topo-store.js.map +0 -1
- package/dist/internal/tracing.d.ts +0 -98
- package/dist/internal/tracing.d.ts.map +0 -1
- package/dist/internal/tracing.js +0 -102
- package/dist/internal/tracing.js.map +0 -1
- package/dist/internal/trails-db.d.ts +0 -16
- package/dist/internal/trails-db.d.ts.map +0 -1
- package/dist/internal/trails-db.js +0 -118
- package/dist/internal/trails-db.js.map +0 -1
- package/dist/internal/zod-wrappers.d.ts +0 -40
- package/dist/internal/zod-wrappers.d.ts.map +0 -1
- package/dist/internal/zod-wrappers.js +0 -59
- package/dist/internal/zod-wrappers.js.map +0 -1
- package/dist/layer.d.ts +0 -23
- package/dist/layer.d.ts.map +0 -1
- package/dist/layer.js +0 -21
- package/dist/layer.js.map +0 -1
- package/dist/path-security.d.ts +0 -28
- package/dist/path-security.d.ts.map +0 -1
- package/dist/path-security.js +0 -63
- package/dist/path-security.js.map +0 -1
- package/dist/patterns/bulk.d.ts +0 -15
- package/dist/patterns/bulk.d.ts.map +0 -1
- package/dist/patterns/bulk.js +0 -14
- package/dist/patterns/bulk.js.map +0 -1
- package/dist/patterns/change.d.ts +0 -10
- package/dist/patterns/change.d.ts.map +0 -1
- package/dist/patterns/change.js +0 -10
- package/dist/patterns/change.js.map +0 -1
- package/dist/patterns/date-range.d.ts +0 -10
- package/dist/patterns/date-range.d.ts.map +0 -1
- package/dist/patterns/date-range.js +0 -10
- package/dist/patterns/date-range.js.map +0 -1
- package/dist/patterns/index.d.ts +0 -9
- package/dist/patterns/index.d.ts.map +0 -1
- package/dist/patterns/index.js +0 -9
- package/dist/patterns/index.js.map +0 -1
- package/dist/patterns/pagination.d.ts +0 -18
- package/dist/patterns/pagination.d.ts.map +0 -1
- package/dist/patterns/pagination.js +0 -18
- package/dist/patterns/pagination.js.map +0 -1
- package/dist/patterns/progress.d.ts +0 -11
- package/dist/patterns/progress.d.ts.map +0 -1
- package/dist/patterns/progress.js +0 -11
- package/dist/patterns/progress.js.map +0 -1
- package/dist/patterns/sorting.d.ts +0 -13
- package/dist/patterns/sorting.d.ts.map +0 -1
- package/dist/patterns/sorting.js +0 -10
- package/dist/patterns/sorting.js.map +0 -1
- package/dist/patterns/status.d.ts +0 -15
- package/dist/patterns/status.d.ts.map +0 -1
- package/dist/patterns/status.js +0 -9
- package/dist/patterns/status.js.map +0 -1
- package/dist/patterns/timestamps.d.ts +0 -10
- package/dist/patterns/timestamps.d.ts.map +0 -1
- package/dist/patterns/timestamps.js +0 -10
- package/dist/patterns/timestamps.js.map +0 -1
- package/dist/redaction/index.d.ts +0 -4
- package/dist/redaction/index.d.ts.map +0 -1
- package/dist/redaction/index.js +0 -3
- package/dist/redaction/index.js.map +0 -1
- package/dist/redaction/patterns.d.ts +0 -9
- package/dist/redaction/patterns.d.ts.map +0 -1
- package/dist/redaction/patterns.js +0 -39
- package/dist/redaction/patterns.js.map +0 -1
- package/dist/redaction/redactor.d.ts +0 -27
- package/dist/redaction/redactor.d.ts.map +0 -1
- package/dist/redaction/redactor.js +0 -89
- package/dist/redaction/redactor.js.map +0 -1
- package/dist/resilience.d.ts +0 -34
- package/dist/resilience.d.ts.map +0 -1
- package/dist/resilience.js +0 -164
- package/dist/resilience.js.map +0 -1
- package/dist/resource-config.d.ts +0 -22
- package/dist/resource-config.d.ts.map +0 -1
- package/dist/resource-config.js +0 -210
- package/dist/resource-config.js.map +0 -1
- package/dist/resource.d.ts +0 -74
- package/dist/resource.d.ts.map +0 -1
- package/dist/resource.js +0 -61
- package/dist/resource.js.map +0 -1
- package/dist/result.d.ts +0 -57
- package/dist/result.d.ts.map +0 -1
- package/dist/result.js +0 -156
- package/dist/result.js.map +0 -1
- package/dist/run.d.ts +0 -28
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -35
- package/dist/run.js.map +0 -1
- package/dist/serialization.d.ts +0 -27
- package/dist/serialization.d.ts.map +0 -1
- package/dist/serialization.js +0 -154
- package/dist/serialization.js.map +0 -1
- package/dist/signal.d.ts +0 -33
- package/dist/signal.d.ts.map +0 -1
- package/dist/signal.js +0 -17
- package/dist/signal.js.map +0 -1
- package/dist/store/accessor-protocol.d.ts +0 -51
- package/dist/store/accessor-protocol.d.ts.map +0 -1
- package/dist/store/accessor-protocol.js +0 -2
- package/dist/store/accessor-protocol.js.map +0 -1
- package/dist/store/index.d.ts +0 -2
- package/dist/store/index.d.ts.map +0 -1
- package/dist/store/index.js +0 -2
- package/dist/store/index.js.map +0 -1
- package/dist/surface-filter.d.ts +0 -13
- package/dist/surface-filter.d.ts.map +0 -1
- package/dist/surface-filter.js +0 -76
- package/dist/surface-filter.js.map +0 -1
- package/dist/topo-store.d.ts +0 -62
- package/dist/topo-store.d.ts.map +0 -1
- package/dist/topo-store.js +0 -319
- package/dist/topo-store.js.map +0 -1
- package/dist/topo.d.ts +0 -39
- package/dist/topo.d.ts.map +0 -1
- package/dist/topo.js +0 -241
- package/dist/topo.js.map +0 -1
- package/dist/trail.d.ts +0 -163
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -75
- package/dist/trail.js.map +0 -1
- package/dist/trails/derive-trail.d.ts +0 -66
- package/dist/trails/derive-trail.d.ts.map +0 -1
- package/dist/trails/derive-trail.js +0 -378
- package/dist/trails/derive-trail.js.map +0 -1
- package/dist/trails/index.d.ts +0 -5
- package/dist/trails/index.d.ts.map +0 -1
- package/dist/trails/index.js +0 -3
- package/dist/trails/index.js.map +0 -1
- package/dist/trails/ingest.d.ts +0 -24
- package/dist/trails/ingest.d.ts.map +0 -1
- package/dist/trails/ingest.js +0 -63
- package/dist/trails/ingest.js.map +0 -1
- package/dist/transport-error-map.d.ts +0 -106
- package/dist/transport-error-map.d.ts.map +0 -1
- package/dist/transport-error-map.js +0 -24
- package/dist/transport-error-map.js.map +0 -1
- package/dist/type-checks.test-d.d.ts +0 -71
- package/dist/type-checks.test-d.d.ts.map +0 -1
- package/dist/type-checks.test-d.js +0 -12
- package/dist/type-checks.test-d.js.map +0 -1
- package/dist/type-utils.d.ts +0 -33
- package/dist/type-utils.d.ts.map +0 -1
- package/dist/type-utils.js +0 -12
- package/dist/type-utils.js.map +0 -1
- package/dist/types.d.ts +0 -177
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
- package/dist/validate-established-topo.d.ts +0 -76
- package/dist/validate-established-topo.d.ts.map +0 -1
- package/dist/validate-established-topo.js +0 -43
- package/dist/validate-established-topo.js.map +0 -1
- package/dist/validate-topo.d.ts +0 -24
- package/dist/validate-topo.d.ts.map +0 -1
- package/dist/validate-topo.js +0 -193
- package/dist/validate-topo.js.map +0 -1
- package/dist/validation.d.ts +0 -27
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -165
- package/dist/validation.js.map +0 -1
- package/dist/workspace.d.ts +0 -25
- package/dist/workspace.d.ts.map +0 -1
- package/dist/workspace.js +0 -57
- package/dist/workspace.js.map +0 -1
- package/src/__tests__/blob-ref.test.ts +0 -103
- package/src/__tests__/branded.test.ts +0 -148
- package/src/__tests__/collections.test.ts +0 -126
- package/src/__tests__/context.test.ts +0 -77
- package/src/__tests__/contour.test.ts +0 -263
- package/src/__tests__/derive-trail.test.ts +0 -668
- package/src/__tests__/derive.test.ts +0 -260
- package/src/__tests__/errors.test.ts +0 -427
- package/src/__tests__/execute.test.ts +0 -1563
- package/src/__tests__/fetch.test.ts +0 -217
- package/src/__tests__/fire.test.ts +0 -1056
- package/src/__tests__/fork-ctx.test.ts +0 -83
- package/src/__tests__/guards.test.ts +0 -102
- package/src/__tests__/ingest.test.ts +0 -191
- package/src/__tests__/layer.test.ts +0 -125
- package/src/__tests__/path-security.test.ts +0 -114
- package/src/__tests__/patterns.test.ts +0 -273
- package/src/__tests__/redaction.test.ts +0 -244
- package/src/__tests__/resilience.test.ts +0 -246
- package/src/__tests__/resource.test.ts +0 -204
- package/src/__tests__/result.test.ts +0 -155
- package/src/__tests__/run.test.ts +0 -181
- package/src/__tests__/serialization.test.ts +0 -401
- package/src/__tests__/service-config.test.ts +0 -224
- package/src/__tests__/signal.test.ts +0 -82
- package/src/__tests__/surface-filter.test.ts +0 -196
- package/src/__tests__/topo-store-read.test.ts +0 -256
- package/src/__tests__/topo-store.test.ts +0 -869
- package/src/__tests__/topo.test.ts +0 -573
- package/src/__tests__/trail-permit.test.ts +0 -60
- package/src/__tests__/trail.test.ts +0 -426
- package/src/__tests__/trails-db.test.ts +0 -198
- package/src/__tests__/transport-error-map.test.ts +0 -82
- package/src/__tests__/type-utils.test.ts +0 -118
- package/src/__tests__/validate-topo.test.ts +0 -611
- package/src/__tests__/validation.test.ts +0 -336
- package/src/__tests__/workspace.test.ts +0 -183
- package/src/__tests__/zod-wrappers.test.ts +0 -62
- package/src/internal/topo-snapshots.ts +0 -517
- package/src/internal/topo-store-read.ts +0 -481
- package/src/internal/topo-store.ts +0 -1192
- package/src/internal/tracing.ts +0 -187
- package/src/topo-store.ts +0 -510
- package/src/type-checks.test-d.ts +0 -106
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,1563 +0,0 @@
|
|
|
1
|
-
/* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
|
|
2
|
-
import { describe, test, expect } from 'bun:test';
|
|
3
|
-
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
CancelledError,
|
|
8
|
-
ConflictError,
|
|
9
|
-
InternalError,
|
|
10
|
-
NetworkError,
|
|
11
|
-
NotFoundError,
|
|
12
|
-
RetryExhaustedError,
|
|
13
|
-
TrailsError,
|
|
14
|
-
ValidationError,
|
|
15
|
-
} from '../errors';
|
|
16
|
-
import { executeTrail } from '../execute';
|
|
17
|
-
import { TRACE_CONTEXT_KEY, clearTraceSink } from '../internal/tracing';
|
|
18
|
-
import { createTrailContext } from '../context';
|
|
19
|
-
import type { Layer } from '../layer';
|
|
20
|
-
import { Result } from '../result';
|
|
21
|
-
import { resource } from '../resource';
|
|
22
|
-
import { topo } from '../topo';
|
|
23
|
-
import { trail } from '../trail';
|
|
24
|
-
import type { TrailContext, TrailContextInit } from '../types';
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Fixtures
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
const echoTrail = trail('echo', {
|
|
31
|
-
blaze: (input) => Result.ok({ value: input.value }),
|
|
32
|
-
input: z.object({ value: z.string() }),
|
|
33
|
-
output: z.object({ value: z.string() }),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const failingTrail = trail('fails', {
|
|
37
|
-
blaze: () => Result.err(new ValidationError('bad input')),
|
|
38
|
-
input: z.object({}),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const throwingTrail = trail('throws', {
|
|
42
|
-
blaze: () => {
|
|
43
|
-
throw new Error('kaboom');
|
|
44
|
-
},
|
|
45
|
-
input: z.object({}),
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const nextResourceId = (name: string): string =>
|
|
49
|
-
`test.resource.${name}.${Bun.randomUUIDv7()}`;
|
|
50
|
-
|
|
51
|
-
const createResolvedValueResource = (
|
|
52
|
-
id: string,
|
|
53
|
-
onCreate: () => void,
|
|
54
|
-
value: number
|
|
55
|
-
) =>
|
|
56
|
-
resource(id, {
|
|
57
|
-
create: () => {
|
|
58
|
-
onCreate();
|
|
59
|
-
return Result.ok({ value });
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const createEagerResourceTrail = (
|
|
64
|
-
id: string,
|
|
65
|
-
counter: ReturnType<typeof createResolvedValueResource>,
|
|
66
|
-
onRun: (value: number) => void
|
|
67
|
-
) =>
|
|
68
|
-
trail('resource.eager', {
|
|
69
|
-
blaze: (_input, ctx) => {
|
|
70
|
-
const fromAccessor = ctx.resource<{ value: number }>(id);
|
|
71
|
-
const fromDefinition = counter.from(ctx);
|
|
72
|
-
onRun(fromDefinition.value);
|
|
73
|
-
return Result.ok({ total: fromAccessor.value + 1 });
|
|
74
|
-
},
|
|
75
|
-
input: z.object({}),
|
|
76
|
-
output: z.object({ total: z.number() }),
|
|
77
|
-
resources: [counter],
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const createResourceProbeGate = (
|
|
81
|
-
counter: ReturnType<typeof createResolvedValueResource>,
|
|
82
|
-
onResolve: (value: number) => void
|
|
83
|
-
): Layer => ({
|
|
84
|
-
name: 'uses-resource',
|
|
85
|
-
wrap(_trail, impl) {
|
|
86
|
-
return async (input, ctx) => {
|
|
87
|
-
onResolve(ctx.resource<{ value: number }>(counter).value);
|
|
88
|
-
return await impl(input, ctx);
|
|
89
|
-
};
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const createSingletonResource = (id: string, onCreate: () => number) =>
|
|
94
|
-
resource(id, {
|
|
95
|
-
create: () => Result.ok({ createdAtCall: onCreate() }),
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const createSingletonTrail = (
|
|
99
|
-
singleton: ReturnType<typeof createSingletonResource>
|
|
100
|
-
) =>
|
|
101
|
-
trail('resource.singleton', {
|
|
102
|
-
blaze: (_input, ctx) =>
|
|
103
|
-
Result.ok({
|
|
104
|
-
createdAtCall: singleton.from(ctx).createdAtCall,
|
|
105
|
-
}),
|
|
106
|
-
input: z.object({}),
|
|
107
|
-
output: z.object({ createdAtCall: z.number() }),
|
|
108
|
-
resources: [singleton],
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const unwrapExecution = async (
|
|
112
|
-
target: ReturnType<typeof createSingletonTrail>
|
|
113
|
-
) => {
|
|
114
|
-
const result = await executeTrail(target, {});
|
|
115
|
-
return result.unwrap();
|
|
116
|
-
};
|
|
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
|
-
|
|
391
|
-
// ---------------------------------------------------------------------------
|
|
392
|
-
// Tests
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
|
|
395
|
-
describe('executeTrail', () => {
|
|
396
|
-
describe('happy path', () => {
|
|
397
|
-
describe('execution and resources', () => {
|
|
398
|
-
test('validates input and executes trail', async () => {
|
|
399
|
-
const result = await executeTrail(echoTrail, { value: 'hello' });
|
|
400
|
-
|
|
401
|
-
expect(result.isOk()).toBe(true);
|
|
402
|
-
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
403
|
-
});
|
|
404
|
-
|
|
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);
|
|
436
|
-
});
|
|
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' });
|
|
457
|
-
});
|
|
458
|
-
|
|
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
|
-
];
|
|
472
|
-
|
|
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
|
-
});
|
|
514
|
-
});
|
|
515
|
-
|
|
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' });
|
|
549
|
-
});
|
|
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' });
|
|
585
|
-
});
|
|
586
|
-
|
|
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 });
|
|
633
|
-
|
|
634
|
-
const result = await executeTrail(entry, {}, { topo: app });
|
|
635
|
-
|
|
636
|
-
expect(result.isOk()).toBe(true);
|
|
637
|
-
expect(result.unwrap()).toEqual({
|
|
638
|
-
completionOrder: ['fast', 'slow'],
|
|
639
|
-
resultOrder: ['slow', 'fast'],
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
|
|
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
|
-
});
|
|
702
|
-
|
|
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 });
|
|
715
|
-
|
|
716
|
-
expect(result.isOk()).toBe(true);
|
|
717
|
-
expect(result.unwrap()).toEqual({ count: 0 });
|
|
718
|
-
});
|
|
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
|
-
});
|
|
764
|
-
});
|
|
765
|
-
|
|
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,
|
|
899
|
-
{},
|
|
900
|
-
{
|
|
901
|
-
ctx: { extensions: { [id]: { source: 'parent-scope' } } },
|
|
902
|
-
topo: app,
|
|
903
|
-
}
|
|
904
|
-
);
|
|
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,
|
|
960
|
-
{},
|
|
961
|
-
{
|
|
962
|
-
ctx: {
|
|
963
|
-
extensions: {
|
|
964
|
-
[failingId]: { source: 'parent-failing-scope' },
|
|
965
|
-
[succeedingId]: { source: 'parent-success-scope' },
|
|
966
|
-
},
|
|
967
|
-
},
|
|
968
|
-
topo: app,
|
|
969
|
-
}
|
|
970
|
-
);
|
|
971
|
-
|
|
972
|
-
expect(result.isOk()).toBe(true);
|
|
973
|
-
expect(result.unwrap()).toEqual({
|
|
974
|
-
failed: 'failing branch scope exploded',
|
|
975
|
-
succeeded: 'sibling-branch-scope',
|
|
976
|
-
});
|
|
977
|
-
});
|
|
978
|
-
});
|
|
979
|
-
|
|
980
|
-
describe('validation', () => {
|
|
981
|
-
test('returns validation error for invalid input', async () => {
|
|
982
|
-
const result = await executeTrail(echoTrail, { value: 42 });
|
|
983
|
-
|
|
984
|
-
expect(result.isErr()).toBe(true);
|
|
985
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
986
|
-
});
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
describe('layers', () => {
|
|
990
|
-
test('composes layers around execution', async () => {
|
|
991
|
-
const log: string[] = [];
|
|
992
|
-
const layer: Layer = {
|
|
993
|
-
name: 'test-layer',
|
|
994
|
-
wrap(_trail, impl) {
|
|
995
|
-
return async (input, ctx) => {
|
|
996
|
-
log.push('before');
|
|
997
|
-
const r = await impl(input, ctx);
|
|
998
|
-
log.push('after');
|
|
999
|
-
return r;
|
|
1000
|
-
};
|
|
1001
|
-
},
|
|
1002
|
-
};
|
|
1003
|
-
|
|
1004
|
-
const result = await executeTrail(
|
|
1005
|
-
echoTrail,
|
|
1006
|
-
{ value: 'x' },
|
|
1007
|
-
{ layers: [layer] }
|
|
1008
|
-
);
|
|
1009
|
-
|
|
1010
|
-
expect(result.isOk()).toBe(true);
|
|
1011
|
-
expect(log).toEqual(['before', 'after']);
|
|
1012
|
-
});
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
describe('context', () => {
|
|
1016
|
-
test('accepts context overrides', async () => {
|
|
1017
|
-
let capturedCtx: TrailContext | undefined;
|
|
1018
|
-
const ctxTrail = trail('ctx-test', {
|
|
1019
|
-
blaze: (_input, ctx) => {
|
|
1020
|
-
capturedCtx = ctx;
|
|
1021
|
-
return Result.ok(null);
|
|
1022
|
-
},
|
|
1023
|
-
input: z.object({}),
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
await executeTrail(ctxTrail, {}, { ctx: { requestId: 'override-id' } });
|
|
1027
|
-
|
|
1028
|
-
expect(capturedCtx?.requestId).toBe('override-id');
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
test('accepts abortSignal override', async () => {
|
|
1032
|
-
let capturedSignal: AbortSignal | undefined;
|
|
1033
|
-
const sigTrail = trail('sig-test', {
|
|
1034
|
-
blaze: (_input, ctx) => {
|
|
1035
|
-
capturedSignal = ctx.abortSignal;
|
|
1036
|
-
return Result.ok(null);
|
|
1037
|
-
},
|
|
1038
|
-
input: z.object({}),
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
const abortSignal = AbortSignal.timeout(9999);
|
|
1042
|
-
await executeTrail(sigTrail, {}, { abortSignal });
|
|
1043
|
-
|
|
1044
|
-
expect(capturedSignal).toBe(abortSignal);
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
test('accepts context factory', async () => {
|
|
1048
|
-
let capturedCtx: TrailContext | undefined;
|
|
1049
|
-
const ctxTrail = trail('factory-test', {
|
|
1050
|
-
blaze: (_input, ctx) => {
|
|
1051
|
-
capturedCtx = ctx;
|
|
1052
|
-
return Result.ok(null);
|
|
1053
|
-
},
|
|
1054
|
-
input: z.object({}),
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
const customCtx: TrailContextInit = {
|
|
1058
|
-
abortSignal: new AbortController().signal,
|
|
1059
|
-
cwd: '/custom',
|
|
1060
|
-
requestId: 'factory-id',
|
|
1061
|
-
};
|
|
1062
|
-
|
|
1063
|
-
await executeTrail(ctxTrail, {}, { createContext: () => customCtx });
|
|
1064
|
-
|
|
1065
|
-
expect(capturedCtx?.requestId).toBe('factory-id');
|
|
1066
|
-
expect(capturedCtx?.cwd).toBe('/custom');
|
|
1067
|
-
});
|
|
1068
|
-
|
|
1069
|
-
test('context factory + ctx overrides merge correctly', async () => {
|
|
1070
|
-
let capturedCtx: TrailContext | undefined;
|
|
1071
|
-
const ctxTrail = trail('merge-test', {
|
|
1072
|
-
blaze: (_input, ctx) => {
|
|
1073
|
-
capturedCtx = ctx;
|
|
1074
|
-
return Result.ok(null);
|
|
1075
|
-
},
|
|
1076
|
-
input: z.object({}),
|
|
1077
|
-
});
|
|
1078
|
-
|
|
1079
|
-
const baseCtx: TrailContextInit = {
|
|
1080
|
-
abortSignal: new AbortController().signal,
|
|
1081
|
-
cwd: '/factory',
|
|
1082
|
-
requestId: 'factory-id',
|
|
1083
|
-
};
|
|
1084
|
-
|
|
1085
|
-
await executeTrail(
|
|
1086
|
-
ctxTrail,
|
|
1087
|
-
{},
|
|
1088
|
-
{
|
|
1089
|
-
createContext: () => baseCtx,
|
|
1090
|
-
ctx: { requestId: 'overridden-id' },
|
|
1091
|
-
}
|
|
1092
|
-
);
|
|
1093
|
-
|
|
1094
|
-
expect(capturedCtx?.requestId).toBe('overridden-id');
|
|
1095
|
-
expect(capturedCtx?.cwd).toBe('/factory');
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
test('deep-merges extensions from factory and overrides', async () => {
|
|
1099
|
-
let captured: TrailContext | undefined;
|
|
1100
|
-
const t = trail('ext.test', {
|
|
1101
|
-
blaze: (_input, ctx) => {
|
|
1102
|
-
captured = ctx;
|
|
1103
|
-
return Result.ok({});
|
|
1104
|
-
},
|
|
1105
|
-
input: z.object({}),
|
|
1106
|
-
output: z.object({}),
|
|
1107
|
-
});
|
|
1108
|
-
await executeTrail(
|
|
1109
|
-
t,
|
|
1110
|
-
{},
|
|
1111
|
-
{
|
|
1112
|
-
createContext: () =>
|
|
1113
|
-
createTrailContext({ extensions: { store: 'db' } }),
|
|
1114
|
-
ctx: { extensions: { userId: '123' } },
|
|
1115
|
-
}
|
|
1116
|
-
);
|
|
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');
|
|
1121
|
-
});
|
|
1122
|
-
|
|
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 () => {
|
|
1144
|
-
let resolvedSource: string | undefined;
|
|
1145
|
-
const db = resource(nextResourceId('context-override'), {
|
|
1146
|
-
create: () => Result.ok({ source: 'factory' }),
|
|
1147
|
-
});
|
|
1148
|
-
const resourceTrail = trail('ctx.resource.override', {
|
|
1149
|
-
blaze: (_input, ctx) => {
|
|
1150
|
-
resolvedSource = ctx.resource<{ source: string }>(db).source;
|
|
1151
|
-
return Result.ok({ source: resolvedSource });
|
|
1152
|
-
},
|
|
1153
|
-
input: z.object({}),
|
|
1154
|
-
output: z.object({ source: z.string() }),
|
|
1155
|
-
resources: [db],
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
const result = await executeTrail(
|
|
1159
|
-
resourceTrail,
|
|
1160
|
-
{},
|
|
1161
|
-
{
|
|
1162
|
-
createContext: () =>
|
|
1163
|
-
createTrailContext({
|
|
1164
|
-
extensions: {
|
|
1165
|
-
[db.id]: { source: 'factory-context' },
|
|
1166
|
-
},
|
|
1167
|
-
}),
|
|
1168
|
-
ctx: {
|
|
1169
|
-
extensions: {
|
|
1170
|
-
[db.id]: { source: 'override-context' },
|
|
1171
|
-
},
|
|
1172
|
-
},
|
|
1173
|
-
}
|
|
1174
|
-
);
|
|
1175
|
-
|
|
1176
|
-
expect(result.unwrap()).toEqual({ source: 'override-context' });
|
|
1177
|
-
expect(resolvedSource).toBe('override-context');
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
test('context factory seeds the resource accessor when omitted', async () => {
|
|
1181
|
-
let capturedCtx: TrailContext | undefined;
|
|
1182
|
-
const id = nextResourceId('factory-seed');
|
|
1183
|
-
const widget = { id: 'widget-1' };
|
|
1184
|
-
const widgetTrail = trail('resource.factory-seed', {
|
|
1185
|
-
blaze: (_input, ctx) => {
|
|
1186
|
-
capturedCtx = ctx;
|
|
1187
|
-
return Result.ok(null);
|
|
1188
|
-
},
|
|
1189
|
-
input: z.object({}),
|
|
1190
|
-
resources: [],
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
await executeTrail(
|
|
1194
|
-
widgetTrail,
|
|
1195
|
-
{},
|
|
1196
|
-
{
|
|
1197
|
-
createContext: () => ({
|
|
1198
|
-
abortSignal: new AbortController().signal,
|
|
1199
|
-
extensions: { [id]: widget },
|
|
1200
|
-
requestId: 'seeded-resource',
|
|
1201
|
-
}),
|
|
1202
|
-
}
|
|
1203
|
-
);
|
|
1204
|
-
|
|
1205
|
-
expect(capturedCtx?.resource(id)).toBe(widget);
|
|
1206
|
-
});
|
|
1207
|
-
});
|
|
1208
|
-
|
|
1209
|
-
describe('error handling', () => {
|
|
1210
|
-
test('propagates Result.err from run function', async () => {
|
|
1211
|
-
const result = await executeTrail(failingTrail, {});
|
|
1212
|
-
|
|
1213
|
-
expect(result.isErr()).toBe(true);
|
|
1214
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
1215
|
-
expect(result.error.message).toBe('bad input');
|
|
1216
|
-
});
|
|
1217
|
-
|
|
1218
|
-
test('catches thrown exceptions and returns InternalError', async () => {
|
|
1219
|
-
const result = await executeTrail(throwingTrail, {});
|
|
1220
|
-
|
|
1221
|
-
expect(result.isErr()).toBe(true);
|
|
1222
|
-
expect(result.error).toBeInstanceOf(InternalError);
|
|
1223
|
-
expect(result.error.message).toBe('kaboom');
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
test('short-circuits when a resource factory returns Result.err', async () => {
|
|
1227
|
-
const failingResource = resource(nextResourceId('factory-error'), {
|
|
1228
|
-
create: () =>
|
|
1229
|
-
Result.err(new ValidationError('DATABASE_URL is required')),
|
|
1230
|
-
});
|
|
1231
|
-
let ran = false;
|
|
1232
|
-
const resourceTrail = trail('resource.factory-error', {
|
|
1233
|
-
blaze: () => {
|
|
1234
|
-
ran = true;
|
|
1235
|
-
return Result.ok(null);
|
|
1236
|
-
},
|
|
1237
|
-
input: z.object({}),
|
|
1238
|
-
resources: [failingResource],
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
const result = await executeTrail(resourceTrail, {});
|
|
1242
|
-
|
|
1243
|
-
expect(result.isErr()).toBe(true);
|
|
1244
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
1245
|
-
expect(result.error.message).toBe('DATABASE_URL is required');
|
|
1246
|
-
expect(ran).toBe(false);
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
test('wraps thrown resource factory exceptions with the resource ID in context', async () => {
|
|
1250
|
-
const explodingResource = resource(nextResourceId('factory-throw'), {
|
|
1251
|
-
create: () => {
|
|
1252
|
-
throw new Error('boom');
|
|
1253
|
-
},
|
|
1254
|
-
});
|
|
1255
|
-
const resourceTrail = trail('resource.factory-throw', {
|
|
1256
|
-
blaze: () => Result.ok(null),
|
|
1257
|
-
input: z.object({}),
|
|
1258
|
-
resources: [explodingResource],
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
const result = await executeTrail(resourceTrail, {});
|
|
1262
|
-
|
|
1263
|
-
expect(result.isErr()).toBe(true);
|
|
1264
|
-
expect(result.error).toBeInstanceOf(InternalError);
|
|
1265
|
-
expect(result.error.message).toContain(explodingResource.id);
|
|
1266
|
-
expect(result.error.context).toEqual({
|
|
1267
|
-
resourceId: explodingResource.id,
|
|
1268
|
-
});
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
|
-
test('prefers explicit resource overrides over cached or created instances', async () => {
|
|
1272
|
-
const id = nextResourceId('override');
|
|
1273
|
-
let createCalls = 0;
|
|
1274
|
-
const db = resource(id, {
|
|
1275
|
-
create: () => {
|
|
1276
|
-
createCalls += 1;
|
|
1277
|
-
return Result.ok({ source: 'factory' });
|
|
1278
|
-
},
|
|
1279
|
-
});
|
|
1280
|
-
const resourceTrail = trail('resource.override', {
|
|
1281
|
-
blaze: (_input, ctx) =>
|
|
1282
|
-
Result.ok({ source: db.from(ctx).source as string }),
|
|
1283
|
-
input: z.object({}),
|
|
1284
|
-
output: z.object({ source: z.string() }),
|
|
1285
|
-
resources: [db],
|
|
1286
|
-
});
|
|
1287
|
-
|
|
1288
|
-
const result = await executeTrail(
|
|
1289
|
-
resourceTrail,
|
|
1290
|
-
{},
|
|
1291
|
-
{
|
|
1292
|
-
resources: { [id]: { source: 'override' } },
|
|
1293
|
-
}
|
|
1294
|
-
);
|
|
1295
|
-
|
|
1296
|
-
expect(result.isOk()).toBe(true);
|
|
1297
|
-
expect(result.unwrap()).toEqual({ source: 'override' });
|
|
1298
|
-
expect(createCalls).toBe(0);
|
|
1299
|
-
});
|
|
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
|
-
});
|
|
1563
|
-
});
|