@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Result,
|
|
6
|
+
createTrailContext,
|
|
7
|
+
findDuplicateResourceId,
|
|
8
|
+
isResource,
|
|
9
|
+
resource,
|
|
10
|
+
} from '../index.js';
|
|
11
|
+
import type {
|
|
12
|
+
Resource,
|
|
13
|
+
ResourceContext,
|
|
14
|
+
ResourceSpec,
|
|
15
|
+
TrailContext,
|
|
16
|
+
} from '../index.js';
|
|
17
|
+
|
|
18
|
+
const resourceCtx: ResourceContext = {
|
|
19
|
+
config: {},
|
|
20
|
+
cwd: '/tmp/trails',
|
|
21
|
+
env: { DATABASE_URL: 'file::memory:' },
|
|
22
|
+
workspaceRoot: '/tmp',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let disposedValue: number | undefined;
|
|
26
|
+
|
|
27
|
+
const counterResourceSpec: ResourceSpec<number> = {
|
|
28
|
+
create: () => Result.ok(3),
|
|
29
|
+
description: 'Counter resource',
|
|
30
|
+
dispose: (value) => {
|
|
31
|
+
disposedValue = value;
|
|
32
|
+
},
|
|
33
|
+
health: (value) => Result.ok({ healthy: value > 0 }),
|
|
34
|
+
meta: { domain: 'data' },
|
|
35
|
+
mock: () => 1,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const resolvedResourceCtx = (id: string, instance: unknown): TrailContext =>
|
|
39
|
+
createTrailContext({
|
|
40
|
+
abortSignal: new AbortController().signal,
|
|
41
|
+
extensions: { [id]: instance },
|
|
42
|
+
requestId: `${id}-request`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('resource types', () => {
|
|
46
|
+
test('ResourceContext exposes the stable process-scoped fields', () => {
|
|
47
|
+
expect(resourceCtx.cwd).toBe('/tmp/trails');
|
|
48
|
+
expect(resourceCtx.env?.DATABASE_URL).toBe('file::memory:');
|
|
49
|
+
expect(resourceCtx.workspaceRoot).toBe('/tmp');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('ResourceSpec stores description and meta', () => {
|
|
53
|
+
expect(counterResourceSpec.description).toBe('Counter resource');
|
|
54
|
+
expect(counterResourceSpec.meta).toEqual({ domain: 'data' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('ResourceSpec can reserve a config schema for future composition', () => {
|
|
58
|
+
const config = z.object({ url: z.string().url() });
|
|
59
|
+
const spec: ResourceSpec<number> = {
|
|
60
|
+
config,
|
|
61
|
+
create: () => Result.ok(1),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
expect(spec.config).toBe(config);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('ResourceSpec create and health callbacks are callable', async () => {
|
|
68
|
+
const result = await counterResourceSpec.create(resourceCtx);
|
|
69
|
+
expect(result.isOk()).toBe(true);
|
|
70
|
+
expect(result.unwrap()).toBe(3);
|
|
71
|
+
|
|
72
|
+
const health = await counterResourceSpec.health?.(result.unwrap());
|
|
73
|
+
expect(health?.isOk()).toBe(true);
|
|
74
|
+
expect(health?.unwrap()).toEqual({ healthy: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('ResourceSpec mock and dispose callbacks are callable', async () => {
|
|
78
|
+
disposedValue = undefined;
|
|
79
|
+
const result = await counterResourceSpec.create(resourceCtx);
|
|
80
|
+
expect(result.isOk()).toBe(true);
|
|
81
|
+
|
|
82
|
+
const instance = result.unwrap();
|
|
83
|
+
expect(await counterResourceSpec.mock?.()).toBe(1);
|
|
84
|
+
|
|
85
|
+
await counterResourceSpec.dispose?.(instance);
|
|
86
|
+
expect(disposedValue).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('ResourceSpec create can be async', async () => {
|
|
90
|
+
const spec: ResourceSpec<number> = {
|
|
91
|
+
create: async () => {
|
|
92
|
+
await Bun.sleep(0);
|
|
93
|
+
return Result.ok(7);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const result = await spec.create(resourceCtx);
|
|
98
|
+
expect(result.isOk()).toBe(true);
|
|
99
|
+
expect(result.unwrap()).toBe(7);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('Resource carries identity alongside the shared spec fields', async () => {
|
|
103
|
+
const counterResource: Resource<number> = {
|
|
104
|
+
from(ctx) {
|
|
105
|
+
return ctx.resource(this);
|
|
106
|
+
},
|
|
107
|
+
id: 'counter.main',
|
|
108
|
+
kind: 'resource',
|
|
109
|
+
...counterResourceSpec,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
expect(counterResource.kind).toBe('resource');
|
|
113
|
+
expect(counterResource.id).toBe('counter.main');
|
|
114
|
+
|
|
115
|
+
const created = await counterResource.create(resourceCtx);
|
|
116
|
+
expect(created.isOk()).toBe(true);
|
|
117
|
+
expect(created.unwrap()).toBe(3);
|
|
118
|
+
expect(await counterResource.mock?.()).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('resource()', () => {
|
|
123
|
+
test('returns a frozen resource object with kind and id', () => {
|
|
124
|
+
const counter = resource('counter.main', counterResourceSpec);
|
|
125
|
+
|
|
126
|
+
expect(counter.kind).toBe('resource');
|
|
127
|
+
expect(counter.id).toBe('counter.main');
|
|
128
|
+
expect(Object.isFrozen(counter)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects resource ids containing a scope separator', () => {
|
|
132
|
+
expect(() => resource('billing:primary', counterResourceSpec)).toThrow(
|
|
133
|
+
'Resource "billing:primary" is invalid because resource ids may not contain ":"'
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('infers the resource type through from(ctx)', () => {
|
|
138
|
+
const db = resource('db.main', {
|
|
139
|
+
create: () =>
|
|
140
|
+
Result.ok({
|
|
141
|
+
query(sql: string) {
|
|
142
|
+
return sql.length;
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
description: 'Typed database resource',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const store = {
|
|
149
|
+
query(sql: string) {
|
|
150
|
+
return sql.length;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const ctx = resolvedResourceCtx('db.main', store);
|
|
154
|
+
|
|
155
|
+
const resolved = db.from(ctx);
|
|
156
|
+
expect(resolved.query('select 1')).toBe(8);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('from(ctx) throws when the resource is missing', () => {
|
|
160
|
+
const counter = resource('counter.main', counterResourceSpec);
|
|
161
|
+
const ctx = createTrailContext({
|
|
162
|
+
abortSignal: new AbortController().signal,
|
|
163
|
+
requestId: 'missing-resource',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(() => counter.from(ctx)).toThrow(
|
|
167
|
+
'Resource "counter.main" not provisioned in context'
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('from(ctx) returns undefined when the resource key exists with an undefined value', () => {
|
|
172
|
+
const optional = resource<undefined>('optional.main', {
|
|
173
|
+
create: () => Result.ok<undefined>(),
|
|
174
|
+
});
|
|
175
|
+
const ctx = createTrailContext({
|
|
176
|
+
abortSignal: new AbortController().signal,
|
|
177
|
+
extensions: { [optional.id]: undefined },
|
|
178
|
+
requestId: 'undefined-resource',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(optional.from(ctx)).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('resource helpers', () => {
|
|
186
|
+
test('isResource identifies resource definitions', () => {
|
|
187
|
+
const counter = resource('counter.main', counterResourceSpec);
|
|
188
|
+
|
|
189
|
+
expect(isResource(counter)).toBe(true);
|
|
190
|
+
expect(isResource({ id: 'counter.main', kind: 'trail' })).toBe(false);
|
|
191
|
+
expect(isResource(null)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('findDuplicateResourceId returns the first repeated ID', () => {
|
|
195
|
+
const first = resource('counter.main', counterResourceSpec);
|
|
196
|
+
const duplicate = resource('counter.main', counterResourceSpec);
|
|
197
|
+
const other = resource('counter.secondary', counterResourceSpec);
|
|
198
|
+
|
|
199
|
+
expect(findDuplicateResourceId([first, other])).toBeUndefined();
|
|
200
|
+
expect(findDuplicateResourceId([first, other, duplicate])).toBe(
|
|
201
|
+
'counter.main'
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -5,9 +5,9 @@ import { z } from 'zod';
|
|
|
5
5
|
|
|
6
6
|
import { run } from '../run';
|
|
7
7
|
import { InternalError, NotFoundError, ValidationError } from '../errors';
|
|
8
|
-
import type {
|
|
8
|
+
import type { Layer } from '../layer';
|
|
9
9
|
import { Result } from '../result';
|
|
10
|
-
import {
|
|
10
|
+
import { resource } from '../resource';
|
|
11
11
|
import { topo } from '../topo';
|
|
12
12
|
import { trail } from '../trail';
|
|
13
13
|
import type { TrailContext, TrailContextInit } from '../types';
|
|
@@ -44,25 +44,25 @@ describe('run', () => {
|
|
|
44
44
|
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test('passes
|
|
48
|
-
const id = `run.
|
|
49
|
-
const db =
|
|
47
|
+
test('passes resource overrides through to executeTrail', async () => {
|
|
48
|
+
const id = `run.resource.${Bun.randomUUIDv7()}`;
|
|
49
|
+
const db = resource(id, {
|
|
50
50
|
create: () => Result.ok({ source: 'factory' }),
|
|
51
51
|
});
|
|
52
52
|
const searchTrail = trail('search', {
|
|
53
53
|
blaze: (_input, ctx) => Result.ok({ source: db.from(ctx).source }),
|
|
54
54
|
input: z.object({}),
|
|
55
55
|
output: z.object({ source: z.string() }),
|
|
56
|
-
|
|
56
|
+
resources: [db],
|
|
57
57
|
});
|
|
58
|
-
const searchTopo = topo('
|
|
58
|
+
const searchTopo = topo('resource-test', { searchTrail });
|
|
59
59
|
|
|
60
60
|
const result = await run(
|
|
61
61
|
searchTopo,
|
|
62
62
|
'search',
|
|
63
63
|
{},
|
|
64
64
|
{
|
|
65
|
-
|
|
65
|
+
resources: { [id]: { source: 'override' } },
|
|
66
66
|
}
|
|
67
67
|
);
|
|
68
68
|
|
|
@@ -91,11 +91,11 @@ describe('run', () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
describe('
|
|
95
|
-
test('
|
|
94
|
+
describe('layers', () => {
|
|
95
|
+
test('layer composition works through run', async () => {
|
|
96
96
|
const log: string[] = [];
|
|
97
|
-
const
|
|
98
|
-
name: 'test-
|
|
97
|
+
const layer: Layer = {
|
|
98
|
+
name: 'test-layer',
|
|
99
99
|
wrap(_trail, impl) {
|
|
100
100
|
return async (input, ctx) => {
|
|
101
101
|
log.push('before');
|
|
@@ -110,7 +110,7 @@ describe('run', () => {
|
|
|
110
110
|
testTopo,
|
|
111
111
|
'echo',
|
|
112
112
|
{ value: 'x' },
|
|
113
|
-
{
|
|
113
|
+
{ layers: [layer] }
|
|
114
114
|
);
|
|
115
115
|
|
|
116
116
|
expect(result.isOk()).toBe(true);
|
|
@@ -5,9 +5,9 @@ import { z } from 'zod';
|
|
|
5
5
|
|
|
6
6
|
import { executeTrail } from '../execute.js';
|
|
7
7
|
import { Result } from '../result.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resource } from '../resource.js';
|
|
9
9
|
import { trail } from '../trail.js';
|
|
10
|
-
import type {
|
|
10
|
+
import type { ResourceContext } from '../resource.js';
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Helpers
|
|
@@ -18,9 +18,9 @@ const nextId = (name: string): string =>
|
|
|
18
18
|
|
|
19
19
|
const createSingletonConfigTrail = (id: string) => {
|
|
20
20
|
const captures = { createCalls: 0 };
|
|
21
|
-
const svc =
|
|
21
|
+
const svc = resource(id, {
|
|
22
22
|
config: z.object({ key: z.string() }),
|
|
23
|
-
create: (ctx:
|
|
23
|
+
create: (ctx: ResourceContext<{ key: string }>) => {
|
|
24
24
|
captures.createCalls += 1;
|
|
25
25
|
return Result.ok({
|
|
26
26
|
createCall: captures.createCalls,
|
|
@@ -39,7 +39,7 @@ const createSingletonConfigTrail = (id: string) => {
|
|
|
39
39
|
}),
|
|
40
40
|
input: z.object({}),
|
|
41
41
|
output: z.object({ createCall: z.number(), key: z.string() }),
|
|
42
|
-
|
|
42
|
+
resources: [svc],
|
|
43
43
|
}),
|
|
44
44
|
};
|
|
45
45
|
};
|
|
@@ -48,14 +48,14 @@ const createSingletonConfigTrail = (id: string) => {
|
|
|
48
48
|
// Tests
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
|
|
51
|
-
describe('
|
|
52
|
-
test('
|
|
51
|
+
describe('ResourceContext.config', () => {
|
|
52
|
+
test('resource with config schema receives validated config in svc.config', async () => {
|
|
53
53
|
const id = nextId('typed-config');
|
|
54
54
|
let capturedConfig: unknown;
|
|
55
55
|
|
|
56
|
-
const db =
|
|
56
|
+
const db = resource(id, {
|
|
57
57
|
config: z.object({ poolSize: z.number(), url: z.string().url() }),
|
|
58
|
-
create: (svc:
|
|
58
|
+
create: (svc: ResourceContext<{ url: string; poolSize: number }>) => {
|
|
59
59
|
capturedConfig = svc.config;
|
|
60
60
|
return Result.ok({ connected: true });
|
|
61
61
|
},
|
|
@@ -65,7 +65,7 @@ describe('ProvisionContext.config', () => {
|
|
|
65
65
|
blaze: (_input, ctx) => Result.ok({ connected: db.from(ctx).connected }),
|
|
66
66
|
input: z.object({}),
|
|
67
67
|
output: z.object({ connected: z.boolean() }),
|
|
68
|
-
|
|
68
|
+
resources: [db],
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
const result = await executeTrail(
|
|
@@ -82,11 +82,11 @@ describe('ProvisionContext.config', () => {
|
|
|
82
82
|
expect(capturedConfig).toEqual({ poolSize: 5, url: 'https://example.com' });
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
test('
|
|
85
|
+
test('resource without config still works — svc.config is undefined', async () => {
|
|
86
86
|
const id = nextId('no-config');
|
|
87
87
|
let capturedConfig: unknown = 'sentinel';
|
|
88
88
|
|
|
89
|
-
const counter =
|
|
89
|
+
const counter = resource(id, {
|
|
90
90
|
create: (svc) => {
|
|
91
91
|
capturedConfig = svc.config;
|
|
92
92
|
return Result.ok(42);
|
|
@@ -96,7 +96,7 @@ describe('ProvisionContext.config', () => {
|
|
|
96
96
|
const counterTrail = trail('svc-config.no-config', {
|
|
97
97
|
blaze: (_input, ctx) => Result.ok({ value: counter.from(ctx) }),
|
|
98
98
|
input: z.object({}),
|
|
99
|
-
|
|
99
|
+
resources: [counter],
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
const result = await executeTrail(counterTrail, {});
|
|
@@ -105,10 +105,10 @@ describe('ProvisionContext.config', () => {
|
|
|
105
105
|
expect(capturedConfig).toBeUndefined();
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
test('config validation failure returns Result.err at
|
|
108
|
+
test('config validation failure returns Result.err at resource creation time', async () => {
|
|
109
109
|
const id = nextId('invalid-config');
|
|
110
110
|
|
|
111
|
-
const db =
|
|
111
|
+
const db = resource(id, {
|
|
112
112
|
config: z.object({ url: z.string().url() }),
|
|
113
113
|
create: () => Result.ok({ connected: true }),
|
|
114
114
|
});
|
|
@@ -116,7 +116,7 @@ describe('ProvisionContext.config', () => {
|
|
|
116
116
|
const dbTrail = trail('svc-config.invalid', {
|
|
117
117
|
blaze: () => Result.ok(null),
|
|
118
118
|
input: z.object({}),
|
|
119
|
-
|
|
119
|
+
resources: [db],
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
const result = await executeTrail(
|
|
@@ -133,10 +133,10 @@ describe('ProvisionContext.config', () => {
|
|
|
133
133
|
expect(result.error.message).toContain(id);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test('missing configValues for a
|
|
136
|
+
test('missing configValues for a resource with config schema returns Result.err', async () => {
|
|
137
137
|
const id = nextId('missing-config');
|
|
138
138
|
|
|
139
|
-
const db =
|
|
139
|
+
const db = resource(id, {
|
|
140
140
|
config: z.object({ url: z.string().url() }),
|
|
141
141
|
create: () => Result.ok({ connected: true }),
|
|
142
142
|
});
|
|
@@ -144,7 +144,7 @@ describe('ProvisionContext.config', () => {
|
|
|
144
144
|
const dbTrail = trail('svc-config.missing', {
|
|
145
145
|
blaze: () => Result.ok(null),
|
|
146
146
|
input: z.object({}),
|
|
147
|
-
|
|
147
|
+
resources: [db],
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
const result = await executeTrail(dbTrail, {});
|
|
@@ -153,10 +153,10 @@ describe('ProvisionContext.config', () => {
|
|
|
153
153
|
expect(result.error.message).toContain(id);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
test('
|
|
156
|
+
test('resource override bypasses config validation', async () => {
|
|
157
157
|
const id = nextId('override-bypass');
|
|
158
158
|
|
|
159
|
-
const db =
|
|
159
|
+
const db = resource(id, {
|
|
160
160
|
config: z.object({ url: z.string().url() }),
|
|
161
161
|
create: () => Result.ok({ connected: true }),
|
|
162
162
|
});
|
|
@@ -165,15 +165,11 @@ describe('ProvisionContext.config', () => {
|
|
|
165
165
|
blaze: (_input, ctx) => Result.ok({ value: db.from(ctx) as number }),
|
|
166
166
|
input: z.object({}),
|
|
167
167
|
output: z.object({ value: z.number() }),
|
|
168
|
-
|
|
168
|
+
resources: [db],
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
// Provide the
|
|
172
|
-
const result = await executeTrail(
|
|
173
|
-
dbTrail,
|
|
174
|
-
{},
|
|
175
|
-
{ provisions: { [id]: 42 } }
|
|
176
|
-
);
|
|
171
|
+
// Provide the resource via overrides without any configValues — should NOT fail
|
|
172
|
+
const result = await executeTrail(dbTrail, {}, { resources: { [id]: 42 } });
|
|
177
173
|
|
|
178
174
|
expect(result.isOk()).toBe(true);
|
|
179
175
|
expect(result.unwrap()).toEqual({ value: 42 });
|
|
@@ -182,9 +178,9 @@ describe('ProvisionContext.config', () => {
|
|
|
182
178
|
const id = nextId('options-config');
|
|
183
179
|
const captures: unknown[] = [];
|
|
184
180
|
|
|
185
|
-
const svc =
|
|
181
|
+
const svc = resource(id, {
|
|
186
182
|
config: z.object({ key: z.string() }),
|
|
187
|
-
create: (ctx:
|
|
183
|
+
create: (ctx: ResourceContext<{ key: string }>) => {
|
|
188
184
|
captures.push(ctx.config);
|
|
189
185
|
return Result.ok({ key: ctx.config.key });
|
|
190
186
|
},
|
|
@@ -194,7 +190,7 @@ describe('ProvisionContext.config', () => {
|
|
|
194
190
|
blaze: (_input, ctx) => Result.ok({ key: svc.from(ctx).key }),
|
|
195
191
|
input: z.object({}),
|
|
196
192
|
output: z.object({ key: z.string() }),
|
|
197
|
-
|
|
193
|
+
resources: [svc],
|
|
198
194
|
});
|
|
199
195
|
|
|
200
196
|
const result = await executeTrail(
|
|
@@ -210,7 +206,7 @@ describe('ProvisionContext.config', () => {
|
|
|
210
206
|
expect(captures).toEqual([{ key: 'hello' }]);
|
|
211
207
|
});
|
|
212
208
|
|
|
213
|
-
test('config-aware singleton
|
|
209
|
+
test('config-aware singleton resources reuse the cached instance', async () => {
|
|
214
210
|
const id = nextId('singleton-config');
|
|
215
211
|
const { captures, trail: singletonTrail } = createSingletonConfigTrail(id);
|
|
216
212
|
const options = {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { Result } from '../result.js';
|
|
6
|
+
import { filterSurfaceTrails, matchesTrailPattern } from '../surface-filter.js';
|
|
7
|
+
import { signal } from '../signal.js';
|
|
8
|
+
import { trail } from '../trail.js';
|
|
9
|
+
|
|
10
|
+
const orderPlaced = signal('order.placed', {
|
|
11
|
+
payload: z.object({ orderId: z.string() }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const publicTrail = trail('entity.show', {
|
|
15
|
+
blaze: () => Result.ok({ ok: true }),
|
|
16
|
+
input: z.object({}),
|
|
17
|
+
intent: 'read',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const nestedTrail = trail('entity.admin.audit', {
|
|
21
|
+
blaze: () => Result.ok({ ok: true }),
|
|
22
|
+
input: z.object({}),
|
|
23
|
+
intent: 'destroy',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const internalTrail = trail('entity.secret.rotate', {
|
|
27
|
+
blaze: () => Result.ok({ ok: true }),
|
|
28
|
+
input: z.object({}),
|
|
29
|
+
visibility: 'internal',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const legacyInternalTrail = trail('entity.legacy.rotate', {
|
|
33
|
+
blaze: () => Result.ok({ ok: true }),
|
|
34
|
+
input: z.object({}),
|
|
35
|
+
meta: { internal: true },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const bareEntityTrail = trail('entity', {
|
|
39
|
+
blaze: () => Result.ok({ ok: true }),
|
|
40
|
+
input: z.object({}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const otherTrail = trail('other', {
|
|
44
|
+
blaze: () => Result.ok({ ok: true }),
|
|
45
|
+
input: z.object({}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const consumerTrail = trail('notify.email', {
|
|
49
|
+
blaze: () => Result.ok({ ok: true }),
|
|
50
|
+
input: z.object({}),
|
|
51
|
+
on: [orderPlaced],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('matchesTrailPattern', () => {
|
|
55
|
+
test('exact trail IDs match directly', () => {
|
|
56
|
+
expect(matchesTrailPattern('entity.show', 'entity.show')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('* matches a single dotted segment', () => {
|
|
60
|
+
expect(matchesTrailPattern('entity.show', 'entity.*')).toBe(true);
|
|
61
|
+
expect(matchesTrailPattern('entity.admin.show', 'entity.*')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('** matches any remaining dotted depth', () => {
|
|
65
|
+
expect(matchesTrailPattern('entity.admin.show', 'entity.**')).toBe(true);
|
|
66
|
+
expect(matchesTrailPattern('entity.show', 'entity.**')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('** terminal segment matches entity and all descendants', () => {
|
|
70
|
+
// Zero-remaining-segments case: bare parent matches entity.**.
|
|
71
|
+
expect(matchesTrailPattern('entity', 'entity.**')).toBe(true);
|
|
72
|
+
// One-remaining-segment case for completeness.
|
|
73
|
+
expect(matchesTrailPattern('entity.read', 'entity.**')).toBe(true);
|
|
74
|
+
// Sibling namespace must not match entity.**.
|
|
75
|
+
expect(matchesTrailPattern('other', 'entity.**')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('filterSurfaceTrails', () => {
|
|
80
|
+
describe('visibility defaults', () => {
|
|
81
|
+
test('includes public trails by default', () => {
|
|
82
|
+
expect(filterSurfaceTrails([publicTrail])).toEqual([publicTrail]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('excludes internal trails by default', () => {
|
|
86
|
+
expect(filterSurfaceTrails([internalTrail])).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('does not expose internal trails for wildcard includes', () => {
|
|
90
|
+
expect(
|
|
91
|
+
filterSurfaceTrails([internalTrail], { include: ['entity.**'] })
|
|
92
|
+
).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('allows exact include to expose an internal trail', () => {
|
|
96
|
+
expect(
|
|
97
|
+
filterSurfaceTrails([internalTrail], {
|
|
98
|
+
include: ['entity.secret.rotate'],
|
|
99
|
+
})
|
|
100
|
+
).toEqual([internalTrail]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('legacy meta.internal is treated as internal visibility', () => {
|
|
104
|
+
// Default filter: legacy internal trails must not leak out.
|
|
105
|
+
expect(filterSurfaceTrails([legacyInternalTrail])).toEqual([]);
|
|
106
|
+
|
|
107
|
+
// Wildcard include must also refuse to expose the legacy internal trail.
|
|
108
|
+
expect(
|
|
109
|
+
filterSurfaceTrails([legacyInternalTrail], { include: ['entity.**'] })
|
|
110
|
+
).toEqual([]);
|
|
111
|
+
|
|
112
|
+
// Explicit exact-id include is the documented escape hatch.
|
|
113
|
+
expect(
|
|
114
|
+
filterSurfaceTrails([legacyInternalTrail], {
|
|
115
|
+
include: ['entity.legacy.rotate'],
|
|
116
|
+
})
|
|
117
|
+
).toEqual([legacyInternalTrail]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('include and exclude patterns', () => {
|
|
122
|
+
test('exclude patterns remove matches before include narrowing', () => {
|
|
123
|
+
expect(
|
|
124
|
+
filterSurfaceTrails([publicTrail], {
|
|
125
|
+
exclude: ['entity.*'],
|
|
126
|
+
include: ['entity.show'],
|
|
127
|
+
})
|
|
128
|
+
).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('include patterns narrow the visible trail set', () => {
|
|
132
|
+
expect(
|
|
133
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
134
|
+
include: ['entity.**'],
|
|
135
|
+
})
|
|
136
|
+
).toEqual([publicTrail, nestedTrail]);
|
|
137
|
+
expect(
|
|
138
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
139
|
+
include: ['entity.*'],
|
|
140
|
+
})
|
|
141
|
+
).toEqual([publicTrail]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('entity.** include matches bare entity and descendants', () => {
|
|
145
|
+
// Regression guard for the ** terminal-segment zero-depth case.
|
|
146
|
+
expect(
|
|
147
|
+
filterSurfaceTrails([bareEntityTrail, publicTrail, otherTrail], {
|
|
148
|
+
include: ['entity.**'],
|
|
149
|
+
})
|
|
150
|
+
).toEqual([bareEntityTrail, publicTrail]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('intent filtering', () => {
|
|
155
|
+
test('intent filtering narrows visible trails by behavior class', () => {
|
|
156
|
+
expect(
|
|
157
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
158
|
+
intent: ['read'],
|
|
159
|
+
})
|
|
160
|
+
).toEqual([publicTrail]);
|
|
161
|
+
|
|
162
|
+
expect(
|
|
163
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
164
|
+
intent: ['destroy'],
|
|
165
|
+
})
|
|
166
|
+
).toEqual([nestedTrail]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('intent filtering composes with glob include patterns using AND logic', () => {
|
|
170
|
+
expect(
|
|
171
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
172
|
+
include: ['entity.**'],
|
|
173
|
+
intent: ['read'],
|
|
174
|
+
})
|
|
175
|
+
).toEqual([publicTrail]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('empty intent filters behave like no filter', () => {
|
|
179
|
+
expect(
|
|
180
|
+
filterSurfaceTrails([publicTrail, nestedTrail], {
|
|
181
|
+
intent: [],
|
|
182
|
+
})
|
|
183
|
+
).toEqual([publicTrail, nestedTrail]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('consumer trails', () => {
|
|
188
|
+
test('consumer trails are never exposed on trailheads', () => {
|
|
189
|
+
expect(
|
|
190
|
+
filterSurfaceTrails([consumerTrail], {
|
|
191
|
+
include: ['notify.email'],
|
|
192
|
+
})
|
|
193
|
+
).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|