@ontrails/core 1.0.0-beta.11 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +67 -30
- package/README.md +19 -19
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +5 -4
- package/dist/context.js.map +1 -1
- package/dist/derive.d.ts +8 -3
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +7 -7
- package/dist/derive.js.map +1 -1
- package/dist/event.d.ts +4 -41
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +4 -14
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +11 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +34 -151
- package/dist/execute.js.map +1 -1
- package/dist/gate.d.ts +17 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +21 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +10 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +1 -1
- package/dist/layer.d.ts +5 -16
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +3 -20
- package/dist/layer.js.map +1 -1
- package/dist/provision-config.d.ts +22 -0
- package/dist/provision-config.d.ts.map +1 -0
- package/dist/provision-config.js +210 -0
- package/dist/provision-config.js.map +1 -0
- package/dist/provision.d.ts +71 -0
- package/dist/provision.d.ts.map +1 -0
- package/dist/provision.js +56 -0
- package/dist/provision.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +34 -0
- package/dist/run.js.map +1 -0
- package/dist/service-config.d.ts +22 -0
- package/dist/service-config.d.ts.map +1 -0
- package/dist/service-config.js +210 -0
- package/dist/service-config.js.map +1 -0
- package/dist/service.d.ts +39 -37
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +21 -21
- package/dist/service.js.map +1 -1
- package/dist/signal.d.ts +33 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +17 -0
- package/dist/signal.js.map +1 -0
- package/dist/topo.d.ts +10 -10
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +48 -35
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +19 -17
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +5 -4
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +26 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-topo.d.ts +2 -2
- package/dist/validate-topo.js +31 -31
- package/dist/validate-topo.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/context.test.ts +8 -8
- package/src/__tests__/execute.test.ts +124 -122
- package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
- package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
- package/src/__tests__/service-config.test.ts +228 -0
- package/src/__tests__/service.test.ts +71 -71
- package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
- package/src/__tests__/topo.test.ts +54 -54
- package/src/__tests__/trail-permit.test.ts +60 -0
- package/src/__tests__/trail.test.ts +58 -58
- package/src/__tests__/type-utils.test.ts +3 -3
- package/src/__tests__/validate-topo.test.ts +38 -38
- package/src/context.ts +5 -4
- package/src/derive.ts +8 -8
- package/src/event.ts +11 -73
- package/src/execute.ts +59 -238
- package/src/{layer.ts → gate.ts} +13 -13
- package/src/index.ts +26 -21
- package/src/provision-config.ts +373 -0
- package/src/provision.ts +148 -0
- package/src/{dispatch.ts → run.ts} +8 -8
- package/src/signal.ts +65 -0
- package/src/topo.ts +74 -52
- package/src/trail.ts +30 -23
- package/src/types.ts +30 -10
- package/src/validate-topo.ts +33 -33
- package/tsconfig.tsbuildinfo +1 -1
- package/src/service.ts +0 -139
|
@@ -3,11 +3,11 @@ import { describe, test, expect } from 'bun:test';
|
|
|
3
3
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { run } from '../run';
|
|
7
7
|
import { InternalError, NotFoundError, ValidationError } from '../errors';
|
|
8
|
-
import type {
|
|
8
|
+
import type { Gate } from '../gate';
|
|
9
9
|
import { Result } from '../result';
|
|
10
|
-
import {
|
|
10
|
+
import { provision } from '../provision';
|
|
11
11
|
import { topo } from '../topo';
|
|
12
12
|
import { trail } from '../trail';
|
|
13
13
|
import type { TrailContext, TrailContextInit } from '../types';
|
|
@@ -17,16 +17,16 @@ import type { TrailContext, TrailContextInit } from '../types';
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
19
|
const echoTrail = trail('echo', {
|
|
20
|
+
blaze: (input) => Result.ok({ value: input.value }),
|
|
20
21
|
input: z.object({ value: z.string() }),
|
|
21
22
|
output: z.object({ value: z.string() }),
|
|
22
|
-
run: (input) => Result.ok({ value: input.value }),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
const throwingTrail = trail('throws', {
|
|
26
|
-
|
|
27
|
-
run: () => {
|
|
26
|
+
blaze: () => {
|
|
28
27
|
throw new Error('kaboom');
|
|
29
28
|
},
|
|
29
|
+
input: z.object({}),
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
const testTopo = topo('test', { echoTrail, throwingTrail });
|
|
@@ -35,34 +35,34 @@ const testTopo = topo('test', { echoTrail, throwingTrail });
|
|
|
35
35
|
// Tests
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
|
|
38
|
-
describe('
|
|
38
|
+
describe('run', () => {
|
|
39
39
|
describe('happy path', () => {
|
|
40
|
-
test('
|
|
41
|
-
const result = await
|
|
40
|
+
test('executes by ID and returns Result.ok with expected value', async () => {
|
|
41
|
+
const result = await run(testTopo, 'echo', { value: 'hello' });
|
|
42
42
|
|
|
43
43
|
expect(result.isOk()).toBe(true);
|
|
44
44
|
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test('passes
|
|
48
|
-
const id = `
|
|
49
|
-
const db =
|
|
47
|
+
test('passes provision overrides through to executeTrail', async () => {
|
|
48
|
+
const id = `run.provision.${Bun.randomUUIDv7()}`;
|
|
49
|
+
const db = provision(id, {
|
|
50
50
|
create: () => Result.ok({ source: 'factory' }),
|
|
51
51
|
});
|
|
52
52
|
const searchTrail = trail('search', {
|
|
53
|
+
blaze: (_input, ctx) => Result.ok({ source: db.from(ctx).source }),
|
|
53
54
|
input: z.object({}),
|
|
54
55
|
output: z.object({ source: z.string() }),
|
|
55
|
-
|
|
56
|
-
services: [db],
|
|
56
|
+
provisions: [db],
|
|
57
57
|
});
|
|
58
|
-
const searchTopo = topo('
|
|
58
|
+
const searchTopo = topo('provision-test', { searchTrail });
|
|
59
59
|
|
|
60
|
-
const result = await
|
|
60
|
+
const result = await run(
|
|
61
61
|
searchTopo,
|
|
62
62
|
'search',
|
|
63
63
|
{},
|
|
64
64
|
{
|
|
65
|
-
|
|
65
|
+
provisions: { [id]: { source: 'override' } },
|
|
66
66
|
}
|
|
67
67
|
);
|
|
68
68
|
|
|
@@ -73,7 +73,7 @@ describe('dispatch', () => {
|
|
|
73
73
|
|
|
74
74
|
describe('not found', () => {
|
|
75
75
|
test('returns NotFoundError for unknown trail ID', async () => {
|
|
76
|
-
const result = await
|
|
76
|
+
const result = await run(testTopo, 'nonexistent', {});
|
|
77
77
|
|
|
78
78
|
expect(result.isErr()).toBe(true);
|
|
79
79
|
expect(result.error).toBeInstanceOf(NotFoundError);
|
|
@@ -84,18 +84,18 @@ describe('dispatch', () => {
|
|
|
84
84
|
|
|
85
85
|
describe('validation', () => {
|
|
86
86
|
test('returns ValidationError for invalid input', async () => {
|
|
87
|
-
const result = await
|
|
87
|
+
const result = await run(testTopo, 'echo', { value: 42 });
|
|
88
88
|
|
|
89
89
|
expect(result.isErr()).toBe(true);
|
|
90
90
|
expect(result.error).toBeInstanceOf(ValidationError);
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
describe('
|
|
95
|
-
test('
|
|
94
|
+
describe('gates', () => {
|
|
95
|
+
test('gate composition works through run', async () => {
|
|
96
96
|
const log: string[] = [];
|
|
97
|
-
const
|
|
98
|
-
name: 'test-
|
|
97
|
+
const gate: Gate = {
|
|
98
|
+
name: 'test-gate',
|
|
99
99
|
wrap(_trail, impl) {
|
|
100
100
|
return async (input, ctx) => {
|
|
101
101
|
log.push('before');
|
|
@@ -106,11 +106,11 @@ describe('dispatch', () => {
|
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
const result = await
|
|
109
|
+
const result = await run(
|
|
110
110
|
testTopo,
|
|
111
111
|
'echo',
|
|
112
112
|
{ value: 'x' },
|
|
113
|
-
{
|
|
113
|
+
{ gates: [gate] }
|
|
114
114
|
);
|
|
115
115
|
|
|
116
116
|
expect(result.isOk()).toBe(true);
|
|
@@ -119,20 +119,20 @@ describe('dispatch', () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
describe('context', () => {
|
|
122
|
-
test('context overrides work through
|
|
122
|
+
test('context overrides work through run', async () => {
|
|
123
123
|
let capturedCtx: TrailContext | undefined;
|
|
124
|
-
const ctxTrail = trail('ctx-
|
|
125
|
-
|
|
126
|
-
run: (_input, ctx) => {
|
|
124
|
+
const ctxTrail = trail('ctx-run-test', {
|
|
125
|
+
blaze: (_input, ctx) => {
|
|
127
126
|
capturedCtx = ctx;
|
|
128
127
|
return Result.ok(null);
|
|
129
128
|
},
|
|
129
|
+
input: z.object({}),
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
const ctxTopo = topo('ctx-test', { ctxTrail });
|
|
133
|
-
await
|
|
133
|
+
await run(
|
|
134
134
|
ctxTopo,
|
|
135
|
-
'ctx-
|
|
135
|
+
'ctx-run-test',
|
|
136
136
|
{},
|
|
137
137
|
{ ctx: { requestId: 'override-id' } }
|
|
138
138
|
);
|
|
@@ -140,26 +140,26 @@ describe('dispatch', () => {
|
|
|
140
140
|
expect(capturedCtx?.requestId).toBe('override-id');
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
test('createContext factory works through
|
|
143
|
+
test('createContext factory works through run', async () => {
|
|
144
144
|
let capturedCtx: TrailContext | undefined;
|
|
145
|
-
const ctxTrail = trail('factory-
|
|
146
|
-
|
|
147
|
-
run: (_input, ctx) => {
|
|
145
|
+
const ctxTrail = trail('factory-run-test', {
|
|
146
|
+
blaze: (_input, ctx) => {
|
|
148
147
|
capturedCtx = ctx;
|
|
149
148
|
return Result.ok(null);
|
|
150
149
|
},
|
|
150
|
+
input: z.object({}),
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
const ctxTopo = topo('factory-test', { ctxTrail });
|
|
154
154
|
const customCtx: TrailContextInit = {
|
|
155
|
+
abortSignal: new AbortController().signal,
|
|
155
156
|
cwd: '/custom',
|
|
156
157
|
requestId: 'factory-id',
|
|
157
|
-
signal: new AbortController().signal,
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
-
await
|
|
160
|
+
await run(
|
|
161
161
|
ctxTopo,
|
|
162
|
-
'factory-
|
|
162
|
+
'factory-run-test',
|
|
163
163
|
{},
|
|
164
164
|
{ createContext: () => customCtx }
|
|
165
165
|
);
|
|
@@ -171,7 +171,7 @@ describe('dispatch', () => {
|
|
|
171
171
|
|
|
172
172
|
describe('error handling', () => {
|
|
173
173
|
test('never throws — exceptions become InternalError', async () => {
|
|
174
|
-
const result = await
|
|
174
|
+
const result = await run(testTopo, 'throws', {});
|
|
175
175
|
|
|
176
176
|
expect(result.isErr()).toBe(true);
|
|
177
177
|
expect(result.error).toBeInstanceOf(InternalError);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { executeTrail } from '../execute.js';
|
|
7
|
+
import { Result } from '../result.js';
|
|
8
|
+
import { provision } from '../provision.js';
|
|
9
|
+
import { trail } from '../trail.js';
|
|
10
|
+
import type { ProvisionContext } from '../provision.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const nextId = (name: string): string =>
|
|
17
|
+
`test.svc-config.${name}.${Bun.randomUUIDv7()}`;
|
|
18
|
+
|
|
19
|
+
const createSingletonConfigTrail = (id: string) => {
|
|
20
|
+
const captures = { createCalls: 0 };
|
|
21
|
+
const svc = provision(id, {
|
|
22
|
+
config: z.object({ key: z.string() }),
|
|
23
|
+
create: (ctx: ProvisionContext<{ key: string }>) => {
|
|
24
|
+
captures.createCalls += 1;
|
|
25
|
+
return Result.ok({
|
|
26
|
+
createCall: captures.createCalls,
|
|
27
|
+
key: ctx.config.key,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
captures,
|
|
34
|
+
trail: trail('svc-config.singleton', {
|
|
35
|
+
blaze: (_input, ctx) =>
|
|
36
|
+
Result.ok({
|
|
37
|
+
createCall: svc.from(ctx).createCall,
|
|
38
|
+
key: svc.from(ctx).key,
|
|
39
|
+
}),
|
|
40
|
+
input: z.object({}),
|
|
41
|
+
output: z.object({ createCall: z.number(), key: z.string() }),
|
|
42
|
+
provisions: [svc],
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tests
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('ProvisionContext.config', () => {
|
|
52
|
+
test('provision with config schema receives validated config in svc.config', async () => {
|
|
53
|
+
const id = nextId('typed-config');
|
|
54
|
+
let capturedConfig: unknown;
|
|
55
|
+
|
|
56
|
+
const db = provision(id, {
|
|
57
|
+
config: z.object({ poolSize: z.number(), url: z.string().url() }),
|
|
58
|
+
create: (svc: ProvisionContext<{ url: string; poolSize: number }>) => {
|
|
59
|
+
capturedConfig = svc.config;
|
|
60
|
+
return Result.ok({ connected: true });
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const dbTrail = trail('svc-config.typed', {
|
|
65
|
+
blaze: (_input, ctx) => Result.ok({ connected: db.from(ctx).connected }),
|
|
66
|
+
input: z.object({}),
|
|
67
|
+
output: z.object({ connected: z.boolean() }),
|
|
68
|
+
provisions: [db],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await executeTrail(
|
|
72
|
+
dbTrail,
|
|
73
|
+
{},
|
|
74
|
+
{
|
|
75
|
+
configValues: {
|
|
76
|
+
[id]: { poolSize: 5, url: 'https://example.com' },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.isOk()).toBe(true);
|
|
82
|
+
expect(capturedConfig).toEqual({ poolSize: 5, url: 'https://example.com' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('provision without config still works — svc.config is undefined', async () => {
|
|
86
|
+
const id = nextId('no-config');
|
|
87
|
+
let capturedConfig: unknown = 'sentinel';
|
|
88
|
+
|
|
89
|
+
const counter = provision(id, {
|
|
90
|
+
create: (svc) => {
|
|
91
|
+
capturedConfig = svc.config;
|
|
92
|
+
return Result.ok(42);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const counterTrail = trail('svc-config.no-config', {
|
|
97
|
+
blaze: (_input, ctx) => Result.ok({ value: counter.from(ctx) }),
|
|
98
|
+
input: z.object({}),
|
|
99
|
+
provisions: [counter],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await executeTrail(counterTrail, {});
|
|
103
|
+
|
|
104
|
+
expect(result.isOk()).toBe(true);
|
|
105
|
+
expect(capturedConfig).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('config validation failure returns Result.err at provision creation time', async () => {
|
|
109
|
+
const id = nextId('invalid-config');
|
|
110
|
+
|
|
111
|
+
const db = provision(id, {
|
|
112
|
+
config: z.object({ url: z.string().url() }),
|
|
113
|
+
create: () => Result.ok({ connected: true }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const dbTrail = trail('svc-config.invalid', {
|
|
117
|
+
blaze: () => Result.ok(null),
|
|
118
|
+
input: z.object({}),
|
|
119
|
+
provisions: [db],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await executeTrail(
|
|
123
|
+
dbTrail,
|
|
124
|
+
{},
|
|
125
|
+
{
|
|
126
|
+
configValues: {
|
|
127
|
+
[id]: { url: 'not-a-url' },
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(result.isErr()).toBe(true);
|
|
133
|
+
expect(result.error.message).toContain(id);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('missing configValues for a provision with config schema returns Result.err', async () => {
|
|
137
|
+
const id = nextId('missing-config');
|
|
138
|
+
|
|
139
|
+
const db = provision(id, {
|
|
140
|
+
config: z.object({ url: z.string().url() }),
|
|
141
|
+
create: () => Result.ok({ connected: true }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const dbTrail = trail('svc-config.missing', {
|
|
145
|
+
blaze: () => Result.ok(null),
|
|
146
|
+
input: z.object({}),
|
|
147
|
+
provisions: [db],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await executeTrail(dbTrail, {});
|
|
151
|
+
|
|
152
|
+
expect(result.isErr()).toBe(true);
|
|
153
|
+
expect(result.error.message).toContain(id);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('provision override bypasses config validation', async () => {
|
|
157
|
+
const id = nextId('override-bypass');
|
|
158
|
+
|
|
159
|
+
const db = provision(id, {
|
|
160
|
+
config: z.object({ url: z.string().url() }),
|
|
161
|
+
create: () => Result.ok({ connected: true }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const dbTrail = trail('svc-config.override', {
|
|
165
|
+
blaze: (_input, ctx) => Result.ok({ value: db.from(ctx) as number }),
|
|
166
|
+
input: z.object({}),
|
|
167
|
+
output: z.object({ value: z.number() }),
|
|
168
|
+
provisions: [db],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Provide the provision via overrides without any configValues — should NOT fail
|
|
172
|
+
const result = await executeTrail(
|
|
173
|
+
dbTrail,
|
|
174
|
+
{},
|
|
175
|
+
{ provisions: { [id]: 42 } }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(result.isOk()).toBe(true);
|
|
179
|
+
expect(result.unwrap()).toEqual({ value: 42 });
|
|
180
|
+
});
|
|
181
|
+
test('config values passed through ExecuteTrailOptions.configValues', async () => {
|
|
182
|
+
const id = nextId('options-config');
|
|
183
|
+
const captures: unknown[] = [];
|
|
184
|
+
|
|
185
|
+
const svc = provision(id, {
|
|
186
|
+
config: z.object({ key: z.string() }),
|
|
187
|
+
create: (ctx: ProvisionContext<{ key: string }>) => {
|
|
188
|
+
captures.push(ctx.config);
|
|
189
|
+
return Result.ok({ key: ctx.config.key });
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const svcTrail = trail('svc-config.options', {
|
|
194
|
+
blaze: (_input, ctx) => Result.ok({ key: svc.from(ctx).key }),
|
|
195
|
+
input: z.object({}),
|
|
196
|
+
output: z.object({ key: z.string() }),
|
|
197
|
+
provisions: [svc],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const result = await executeTrail(
|
|
201
|
+
svcTrail,
|
|
202
|
+
{},
|
|
203
|
+
{
|
|
204
|
+
configValues: { [id]: { key: 'hello' } },
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.isOk()).toBe(true);
|
|
209
|
+
expect(result.unwrap()).toEqual({ key: 'hello' });
|
|
210
|
+
expect(captures).toEqual([{ key: 'hello' }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('config-aware singleton provisions reuse the cached instance', async () => {
|
|
214
|
+
const id = nextId('singleton-config');
|
|
215
|
+
const { captures, trail: singletonTrail } = createSingletonConfigTrail(id);
|
|
216
|
+
const options = {
|
|
217
|
+
configValues: { [id]: { key: 'hello' } },
|
|
218
|
+
};
|
|
219
|
+
const first = await executeTrail(singletonTrail, {}, options);
|
|
220
|
+
const second = await executeTrail(singletonTrail, {}, options);
|
|
221
|
+
|
|
222
|
+
expect(first.isOk()).toBe(true);
|
|
223
|
+
expect(second.isOk()).toBe(true);
|
|
224
|
+
expect(first.unwrap()).toEqual({ createCall: 1, key: 'hello' });
|
|
225
|
+
expect(second.unwrap()).toEqual({ createCall: 1, key: 'hello' });
|
|
226
|
+
expect(captures.createCalls).toBe(1);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -4,18 +4,18 @@ import { z } from 'zod';
|
|
|
4
4
|
import {
|
|
5
5
|
Result,
|
|
6
6
|
createTrailContext,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
findDuplicateProvisionId,
|
|
8
|
+
isProvision,
|
|
9
|
+
provision as defineProvision,
|
|
10
10
|
} from '../index.js';
|
|
11
11
|
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Provision,
|
|
13
|
+
ProvisionContext,
|
|
14
|
+
ProvisionSpec,
|
|
15
15
|
TrailContext,
|
|
16
16
|
} from '../index.js';
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const provisionCtx: ProvisionContext = {
|
|
19
19
|
cwd: '/tmp/trails',
|
|
20
20
|
env: { DATABASE_URL: 'file::memory:' },
|
|
21
21
|
workspaceRoot: '/tmp',
|
|
@@ -23,39 +23,39 @@ const serviceCtx: ServiceContext = {
|
|
|
23
23
|
|
|
24
24
|
let disposedValue: number | undefined;
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const counterProvisionSpec: ProvisionSpec<number> = {
|
|
27
27
|
create: () => Result.ok(3),
|
|
28
|
-
description: 'Counter
|
|
29
|
-
dispose: (
|
|
30
|
-
disposedValue =
|
|
28
|
+
description: 'Counter provision',
|
|
29
|
+
dispose: (provision) => {
|
|
30
|
+
disposedValue = provision;
|
|
31
31
|
},
|
|
32
|
-
health: (
|
|
33
|
-
|
|
32
|
+
health: (provision) => Result.ok({ healthy: provision > 0 }),
|
|
33
|
+
meta: { domain: 'data' },
|
|
34
34
|
mock: () => 1,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
const resolvedServiceCtx = (id: string, instance: unknown): TrailContext =>
|
|
38
38
|
createTrailContext({
|
|
39
|
+
abortSignal: new AbortController().signal,
|
|
39
40
|
extensions: { [id]: instance },
|
|
40
41
|
requestId: `${id}-request`,
|
|
41
|
-
signal: new AbortController().signal,
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
describe('
|
|
45
|
-
test('
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
48
|
-
expect(
|
|
44
|
+
describe('provision types', () => {
|
|
45
|
+
test('ProvisionContext exposes the stable process-scoped fields', () => {
|
|
46
|
+
expect(provisionCtx.cwd).toBe('/tmp/trails');
|
|
47
|
+
expect(provisionCtx.env?.DATABASE_URL).toBe('file::memory:');
|
|
48
|
+
expect(provisionCtx.workspaceRoot).toBe('/tmp');
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
51
|
+
test('ProvisionSpec stores description and meta', () => {
|
|
52
|
+
expect(counterProvisionSpec.description).toBe('Counter provision');
|
|
53
|
+
expect(counterProvisionSpec.meta).toEqual({ domain: 'data' });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test('
|
|
56
|
+
test('ProvisionSpec can reserve a config schema for future composition', () => {
|
|
57
57
|
const config = z.object({ url: z.string().url() });
|
|
58
|
-
const spec:
|
|
58
|
+
const spec: ProvisionSpec<number> = {
|
|
59
59
|
config,
|
|
60
60
|
create: () => Result.ok(1),
|
|
61
61
|
};
|
|
@@ -63,72 +63,72 @@ describe('service types', () => {
|
|
|
63
63
|
expect(spec.config).toBe(config);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
test('
|
|
67
|
-
const result = await
|
|
66
|
+
test('ProvisionSpec create and health callbacks are callable', async () => {
|
|
67
|
+
const result = await counterProvisionSpec.create(provisionCtx);
|
|
68
68
|
expect(result.isOk()).toBe(true);
|
|
69
69
|
expect(result.unwrap()).toBe(3);
|
|
70
70
|
|
|
71
|
-
const health = await
|
|
71
|
+
const health = await counterProvisionSpec.health?.(result.unwrap());
|
|
72
72
|
expect(health?.isOk()).toBe(true);
|
|
73
73
|
expect(health?.unwrap()).toEqual({ healthy: true });
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test('
|
|
76
|
+
test('ProvisionSpec mock and dispose callbacks are callable', async () => {
|
|
77
77
|
disposedValue = undefined;
|
|
78
|
-
const result = await
|
|
78
|
+
const result = await counterProvisionSpec.create(provisionCtx);
|
|
79
79
|
expect(result.isOk()).toBe(true);
|
|
80
80
|
|
|
81
|
-
const
|
|
82
|
-
expect(await
|
|
81
|
+
const provision = result.unwrap();
|
|
82
|
+
expect(await counterProvisionSpec.mock?.()).toBe(1);
|
|
83
83
|
|
|
84
|
-
await
|
|
84
|
+
await counterProvisionSpec.dispose?.(provision);
|
|
85
85
|
expect(disposedValue).toBe(3);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
test('
|
|
89
|
-
const spec:
|
|
88
|
+
test('ProvisionSpec create can be async', async () => {
|
|
89
|
+
const spec: ProvisionSpec<number> = {
|
|
90
90
|
create: async () => {
|
|
91
91
|
await Bun.sleep(0);
|
|
92
92
|
return Result.ok(7);
|
|
93
93
|
},
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
const result = await spec.create(
|
|
96
|
+
const result = await spec.create(provisionCtx);
|
|
97
97
|
expect(result.isOk()).toBe(true);
|
|
98
98
|
expect(result.unwrap()).toBe(7);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
test('
|
|
102
|
-
const
|
|
101
|
+
test('Provision carries identity alongside the shared spec fields', async () => {
|
|
102
|
+
const counterProvision: Provision<number> = {
|
|
103
103
|
from(ctx) {
|
|
104
|
-
return ctx.
|
|
104
|
+
return ctx.provision(this);
|
|
105
105
|
},
|
|
106
106
|
id: 'counter.main',
|
|
107
|
-
kind: '
|
|
108
|
-
...
|
|
107
|
+
kind: 'provision',
|
|
108
|
+
...counterProvisionSpec,
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
111
|
+
expect(counterProvision.kind).toBe('provision');
|
|
112
|
+
expect(counterProvision.id).toBe('counter.main');
|
|
113
113
|
|
|
114
|
-
const created = await
|
|
114
|
+
const created = await counterProvision.create(provisionCtx);
|
|
115
115
|
expect(created.isOk()).toBe(true);
|
|
116
116
|
expect(created.unwrap()).toBe(3);
|
|
117
|
-
expect(await
|
|
117
|
+
expect(await counterProvision.mock?.()).toBe(1);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
describe('
|
|
122
|
-
test('returns a frozen
|
|
123
|
-
const counter =
|
|
121
|
+
describe('provision()', () => {
|
|
122
|
+
test('returns a frozen provision object with kind and id', () => {
|
|
123
|
+
const counter = defineProvision('counter.main', counterProvisionSpec);
|
|
124
124
|
|
|
125
|
-
expect(counter.kind).toBe('
|
|
125
|
+
expect(counter.kind).toBe('provision');
|
|
126
126
|
expect(counter.id).toBe('counter.main');
|
|
127
127
|
expect(Object.isFrozen(counter)).toBe(true);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
test('infers the
|
|
131
|
-
const db =
|
|
130
|
+
test('infers the provision type through from(ctx)', () => {
|
|
131
|
+
const db = defineProvision('db.main', {
|
|
132
132
|
create: () =>
|
|
133
133
|
Result.ok({
|
|
134
134
|
query(sql: string) {
|
|
@@ -149,48 +149,48 @@ describe('service()', () => {
|
|
|
149
149
|
expect(resolved.query('select 1')).toBe(8);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
-
test('from(ctx) throws when the
|
|
153
|
-
const counter =
|
|
152
|
+
test('from(ctx) throws when the provision is missing', () => {
|
|
153
|
+
const counter = defineProvision('counter.main', counterProvisionSpec);
|
|
154
154
|
const ctx = createTrailContext({
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
abortSignal: new AbortController().signal,
|
|
156
|
+
requestId: 'missing-provision',
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
expect(() => counter.from(ctx)).toThrow(
|
|
160
|
-
'
|
|
160
|
+
'Provision "counter.main" not found in trail context'
|
|
161
161
|
);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
test('from(ctx) returns undefined when the
|
|
165
|
-
const optional =
|
|
164
|
+
test('from(ctx) returns undefined when the provision key exists with an undefined value', () => {
|
|
165
|
+
const optional = defineProvision<undefined>('optional.main', {
|
|
166
166
|
create: () => Result.ok<undefined>(),
|
|
167
167
|
});
|
|
168
168
|
const ctx = createTrailContext({
|
|
169
|
+
abortSignal: new AbortController().signal,
|
|
169
170
|
extensions: { [optional.id]: undefined },
|
|
170
|
-
requestId: 'undefined-
|
|
171
|
-
signal: new AbortController().signal,
|
|
171
|
+
requestId: 'undefined-provision',
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
expect(optional.from(ctx)).toBeUndefined();
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
describe('
|
|
179
|
-
test('
|
|
180
|
-
const counter =
|
|
178
|
+
describe('provision helpers', () => {
|
|
179
|
+
test('isProvision identifies provision definitions', () => {
|
|
180
|
+
const counter = defineProvision('counter.main', counterProvisionSpec);
|
|
181
181
|
|
|
182
|
-
expect(
|
|
183
|
-
expect(
|
|
184
|
-
expect(
|
|
182
|
+
expect(isProvision(counter)).toBe(true);
|
|
183
|
+
expect(isProvision({ id: 'counter.main', kind: 'trail' })).toBe(false);
|
|
184
|
+
expect(isProvision(null)).toBe(false);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
test('
|
|
188
|
-
const first =
|
|
189
|
-
const duplicate =
|
|
190
|
-
const other =
|
|
187
|
+
test('findDuplicateProvisionId returns the first repeated ID', () => {
|
|
188
|
+
const first = defineProvision('counter.main', counterProvisionSpec);
|
|
189
|
+
const duplicate = defineProvision('counter.main', counterProvisionSpec);
|
|
190
|
+
const other = defineProvision('counter.secondary', counterProvisionSpec);
|
|
191
191
|
|
|
192
|
-
expect(
|
|
193
|
-
expect(
|
|
192
|
+
expect(findDuplicateProvisionId([first, other])).toBeUndefined();
|
|
193
|
+
expect(findDuplicateProvisionId([first, other, duplicate])).toBe(
|
|
194
194
|
'counter.main'
|
|
195
195
|
);
|
|
196
196
|
});
|