@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.
Files changed (99) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +67 -30
  3. package/README.md +19 -19
  4. package/dist/context.d.ts +1 -1
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +5 -4
  7. package/dist/context.js.map +1 -1
  8. package/dist/derive.d.ts +8 -3
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +7 -7
  11. package/dist/derive.js.map +1 -1
  12. package/dist/event.d.ts +4 -41
  13. package/dist/event.d.ts.map +1 -1
  14. package/dist/event.js +4 -14
  15. package/dist/event.js.map +1 -1
  16. package/dist/execute.d.ts +11 -9
  17. package/dist/execute.d.ts.map +1 -1
  18. package/dist/execute.js +34 -151
  19. package/dist/execute.js.map +1 -1
  20. package/dist/gate.d.ts +17 -0
  21. package/dist/gate.d.ts.map +1 -0
  22. package/dist/gate.js +21 -0
  23. package/dist/gate.js.map +1 -0
  24. package/dist/index.d.ts +10 -7
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -7
  27. package/dist/index.js.map +1 -1
  28. package/dist/layer.d.ts +5 -16
  29. package/dist/layer.d.ts.map +1 -1
  30. package/dist/layer.js +3 -20
  31. package/dist/layer.js.map +1 -1
  32. package/dist/provision-config.d.ts +22 -0
  33. package/dist/provision-config.d.ts.map +1 -0
  34. package/dist/provision-config.js +210 -0
  35. package/dist/provision-config.js.map +1 -0
  36. package/dist/provision.d.ts +71 -0
  37. package/dist/provision.d.ts.map +1 -0
  38. package/dist/provision.js +56 -0
  39. package/dist/provision.js.map +1 -0
  40. package/dist/run.d.ts +27 -0
  41. package/dist/run.d.ts.map +1 -0
  42. package/dist/run.js +34 -0
  43. package/dist/run.js.map +1 -0
  44. package/dist/service-config.d.ts +22 -0
  45. package/dist/service-config.d.ts.map +1 -0
  46. package/dist/service-config.js +210 -0
  47. package/dist/service-config.js.map +1 -0
  48. package/dist/service.d.ts +39 -37
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +21 -21
  51. package/dist/service.js.map +1 -1
  52. package/dist/signal.d.ts +33 -0
  53. package/dist/signal.d.ts.map +1 -0
  54. package/dist/signal.js +17 -0
  55. package/dist/signal.js.map +1 -0
  56. package/dist/topo.d.ts +10 -10
  57. package/dist/topo.d.ts.map +1 -1
  58. package/dist/topo.js +48 -35
  59. package/dist/topo.js.map +1 -1
  60. package/dist/trail.d.ts +19 -17
  61. package/dist/trail.d.ts.map +1 -1
  62. package/dist/trail.js +5 -4
  63. package/dist/trail.js.map +1 -1
  64. package/dist/types.d.ts +26 -9
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js +2 -1
  67. package/dist/types.js.map +1 -1
  68. package/dist/validate-topo.d.ts +2 -2
  69. package/dist/validate-topo.js +31 -31
  70. package/dist/validate-topo.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/context.test.ts +8 -8
  73. package/src/__tests__/execute.test.ts +124 -122
  74. package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
  75. package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
  76. package/src/__tests__/service-config.test.ts +228 -0
  77. package/src/__tests__/service.test.ts +71 -71
  78. package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
  79. package/src/__tests__/topo.test.ts +54 -54
  80. package/src/__tests__/trail-permit.test.ts +60 -0
  81. package/src/__tests__/trail.test.ts +58 -58
  82. package/src/__tests__/type-utils.test.ts +3 -3
  83. package/src/__tests__/validate-topo.test.ts +38 -38
  84. package/src/context.ts +5 -4
  85. package/src/derive.ts +8 -8
  86. package/src/event.ts +11 -73
  87. package/src/execute.ts +59 -238
  88. package/src/{layer.ts → gate.ts} +13 -13
  89. package/src/index.ts +26 -21
  90. package/src/provision-config.ts +373 -0
  91. package/src/provision.ts +148 -0
  92. package/src/{dispatch.ts → run.ts} +8 -8
  93. package/src/signal.ts +65 -0
  94. package/src/topo.ts +74 -52
  95. package/src/trail.ts +30 -23
  96. package/src/types.ts +30 -10
  97. package/src/validate-topo.ts +33 -33
  98. package/tsconfig.tsbuildinfo +1 -1
  99. 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 { dispatch } from '../dispatch';
6
+ import { run } from '../run';
7
7
  import { InternalError, NotFoundError, ValidationError } from '../errors';
8
- import type { Layer } from '../layer';
8
+ import type { Gate } from '../gate';
9
9
  import { Result } from '../result';
10
- import { service } from '../service';
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
- input: z.object({}),
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('dispatch', () => {
38
+ describe('run', () => {
39
39
  describe('happy path', () => {
40
- test('dispatches by ID and returns Result.ok with expected value', async () => {
41
- const result = await dispatch(testTopo, 'echo', { value: 'hello' });
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 service overrides through to executeTrail', async () => {
48
- const id = `dispatch.service.${Bun.randomUUIDv7()}`;
49
- const db = service(id, {
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
- run: (_input, ctx) => Result.ok({ source: db.from(ctx).source }),
56
- services: [db],
56
+ provisions: [db],
57
57
  });
58
- const searchTopo = topo('service-test', { searchTrail });
58
+ const searchTopo = topo('provision-test', { searchTrail });
59
59
 
60
- const result = await dispatch(
60
+ const result = await run(
61
61
  searchTopo,
62
62
  'search',
63
63
  {},
64
64
  {
65
- services: { [id]: { source: 'override' } },
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 dispatch(testTopo, 'nonexistent', {});
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 dispatch(testTopo, 'echo', { value: 42 });
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('layers', () => {
95
- test('layer composition works through dispatch', async () => {
94
+ describe('gates', () => {
95
+ test('gate composition works through run', async () => {
96
96
  const log: string[] = [];
97
- const layer: Layer = {
98
- name: 'test-layer',
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 dispatch(
109
+ const result = await run(
110
110
  testTopo,
111
111
  'echo',
112
112
  { value: 'x' },
113
- { layers: [layer] }
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 dispatch', async () => {
122
+ test('context overrides work through run', async () => {
123
123
  let capturedCtx: TrailContext | undefined;
124
- const ctxTrail = trail('ctx-dispatch-test', {
125
- input: z.object({}),
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 dispatch(
133
+ await run(
134
134
  ctxTopo,
135
- 'ctx-dispatch-test',
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 dispatch', async () => {
143
+ test('createContext factory works through run', async () => {
144
144
  let capturedCtx: TrailContext | undefined;
145
- const ctxTrail = trail('factory-dispatch-test', {
146
- input: z.object({}),
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 dispatch(
160
+ await run(
161
161
  ctxTopo,
162
- 'factory-dispatch-test',
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 dispatch(testTopo, 'throws', {});
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
- findDuplicateServiceId,
8
- isService,
9
- service as defineService,
7
+ findDuplicateProvisionId,
8
+ isProvision,
9
+ provision as defineProvision,
10
10
  } from '../index.js';
11
11
  import type {
12
- Service,
13
- ServiceContext,
14
- ServiceSpec,
12
+ Provision,
13
+ ProvisionContext,
14
+ ProvisionSpec,
15
15
  TrailContext,
16
16
  } from '../index.js';
17
17
 
18
- const serviceCtx: ServiceContext = {
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 counterServiceSpec: ServiceSpec<number> = {
26
+ const counterProvisionSpec: ProvisionSpec<number> = {
27
27
  create: () => Result.ok(3),
28
- description: 'Counter service',
29
- dispose: (service) => {
30
- disposedValue = service;
28
+ description: 'Counter provision',
29
+ dispose: (provision) => {
30
+ disposedValue = provision;
31
31
  },
32
- health: (service) => Result.ok({ healthy: service > 0 }),
33
- metadata: { domain: 'data' },
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('service types', () => {
45
- test('ServiceContext exposes the stable process-scoped fields', () => {
46
- expect(serviceCtx.cwd).toBe('/tmp/trails');
47
- expect(serviceCtx.env?.DATABASE_URL).toBe('file::memory:');
48
- expect(serviceCtx.workspaceRoot).toBe('/tmp');
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('ServiceSpec stores description and metadata', () => {
52
- expect(counterServiceSpec.description).toBe('Counter service');
53
- expect(counterServiceSpec.metadata).toEqual({ domain: 'data' });
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('ServiceSpec can reserve a config schema for future composition', () => {
56
+ test('ProvisionSpec can reserve a config schema for future composition', () => {
57
57
  const config = z.object({ url: z.string().url() });
58
- const spec: ServiceSpec<number> = {
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('ServiceSpec create and health callbacks are callable', async () => {
67
- const result = await counterServiceSpec.create(serviceCtx);
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 counterServiceSpec.health?.(result.unwrap());
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('ServiceSpec mock and dispose callbacks are callable', async () => {
76
+ test('ProvisionSpec mock and dispose callbacks are callable', async () => {
77
77
  disposedValue = undefined;
78
- const result = await counterServiceSpec.create(serviceCtx);
78
+ const result = await counterProvisionSpec.create(provisionCtx);
79
79
  expect(result.isOk()).toBe(true);
80
80
 
81
- const service = result.unwrap();
82
- expect(await counterServiceSpec.mock?.()).toBe(1);
81
+ const provision = result.unwrap();
82
+ expect(await counterProvisionSpec.mock?.()).toBe(1);
83
83
 
84
- await counterServiceSpec.dispose?.(service);
84
+ await counterProvisionSpec.dispose?.(provision);
85
85
  expect(disposedValue).toBe(3);
86
86
  });
87
87
 
88
- test('ServiceSpec create can be async', async () => {
89
- const spec: ServiceSpec<number> = {
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(serviceCtx);
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('Service carries identity alongside the shared spec fields', async () => {
102
- const service: Service<number> = {
101
+ test('Provision carries identity alongside the shared spec fields', async () => {
102
+ const counterProvision: Provision<number> = {
103
103
  from(ctx) {
104
- return ctx.service(this);
104
+ return ctx.provision(this);
105
105
  },
106
106
  id: 'counter.main',
107
- kind: 'service',
108
- ...counterServiceSpec,
107
+ kind: 'provision',
108
+ ...counterProvisionSpec,
109
109
  };
110
110
 
111
- expect(service.kind).toBe('service');
112
- expect(service.id).toBe('counter.main');
111
+ expect(counterProvision.kind).toBe('provision');
112
+ expect(counterProvision.id).toBe('counter.main');
113
113
 
114
- const created = await service.create(serviceCtx);
114
+ const created = await counterProvision.create(provisionCtx);
115
115
  expect(created.isOk()).toBe(true);
116
116
  expect(created.unwrap()).toBe(3);
117
- expect(await service.mock?.()).toBe(1);
117
+ expect(await counterProvision.mock?.()).toBe(1);
118
118
  });
119
119
  });
120
120
 
121
- describe('service()', () => {
122
- test('returns a frozen service object with kind and id', () => {
123
- const counter = defineService('counter.main', counterServiceSpec);
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('service');
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 service type through from(ctx)', () => {
131
- const db = defineService('db.main', {
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 service is missing', () => {
153
- const counter = defineService('counter.main', counterServiceSpec);
152
+ test('from(ctx) throws when the provision is missing', () => {
153
+ const counter = defineProvision('counter.main', counterProvisionSpec);
154
154
  const ctx = createTrailContext({
155
- requestId: 'missing-service',
156
- signal: new AbortController().signal,
155
+ abortSignal: new AbortController().signal,
156
+ requestId: 'missing-provision',
157
157
  });
158
158
 
159
159
  expect(() => counter.from(ctx)).toThrow(
160
- 'Service "counter.main" not found in trail context'
160
+ 'Provision "counter.main" not found in trail context'
161
161
  );
162
162
  });
163
163
 
164
- test('from(ctx) returns undefined when the service key exists with an undefined value', () => {
165
- const optional = defineService<undefined>('optional.main', {
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-service',
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('service helpers', () => {
179
- test('isService identifies service definitions', () => {
180
- const counter = defineService('counter.main', counterServiceSpec);
178
+ describe('provision helpers', () => {
179
+ test('isProvision identifies provision definitions', () => {
180
+ const counter = defineProvision('counter.main', counterProvisionSpec);
181
181
 
182
- expect(isService(counter)).toBe(true);
183
- expect(isService({ id: 'counter.main', kind: 'trail' })).toBe(false);
184
- expect(isService(null)).toBe(false);
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('findDuplicateServiceId returns the first repeated ID', () => {
188
- const first = defineService('counter.main', counterServiceSpec);
189
- const duplicate = defineService('counter.main', counterServiceSpec);
190
- const other = defineService('counter.secondary', counterServiceSpec);
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(findDuplicateServiceId([first, other])).toBeUndefined();
193
- expect(findDuplicateServiceId([first, other, duplicate])).toBe(
192
+ expect(findDuplicateProvisionId([first, other])).toBeUndefined();
193
+ expect(findDuplicateProvisionId([first, other, duplicate])).toBe(
194
194
  'counter.main'
195
195
  );
196
196
  });