@ontrails/core 1.0.0-beta.1 → 1.0.0-beta.11

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 +121 -0
  3. package/README.md +54 -11
  4. package/dist/context.d.ts +2 -2
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +12 -7
  7. package/dist/context.js.map +1 -1
  8. package/dist/derive.d.ts +1 -1
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +4 -1
  11. package/dist/derive.js.map +1 -1
  12. package/dist/dispatch.d.ts +27 -0
  13. package/dist/dispatch.d.ts.map +1 -0
  14. package/dist/dispatch.js +34 -0
  15. package/dist/dispatch.js.map +1 -0
  16. package/dist/event.d.ts +2 -2
  17. package/dist/event.d.ts.map +1 -1
  18. package/dist/event.js +1 -1
  19. package/dist/event.js.map +1 -1
  20. package/dist/execute.d.ts +33 -0
  21. package/dist/execute.d.ts.map +1 -0
  22. package/dist/execute.js +207 -0
  23. package/dist/execute.js.map +1 -0
  24. package/dist/index.d.ts +12 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/patterns/status.d.ts +1 -1
  29. package/dist/result.d.ts.map +1 -1
  30. package/dist/result.js +15 -4
  31. package/dist/result.js.map +1 -1
  32. package/dist/serialization.d.ts.map +1 -1
  33. package/dist/serialization.js +45 -7
  34. package/dist/serialization.js.map +1 -1
  35. package/dist/service.d.ts +69 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +56 -0
  38. package/dist/service.js.map +1 -0
  39. package/dist/topo.d.ts +11 -4
  40. package/dist/topo.d.ts.map +1 -1
  41. package/dist/topo.js +43 -18
  42. package/dist/topo.js.map +1 -1
  43. package/dist/trail.d.ts +21 -10
  44. package/dist/trail.d.ts.map +1 -1
  45. package/dist/trail.js +5 -2
  46. package/dist/trail.js.map +1 -1
  47. package/dist/type-utils.d.ts +24 -0
  48. package/dist/type-utils.d.ts.map +1 -0
  49. package/dist/type-utils.js +12 -0
  50. package/dist/type-utils.js.map +1 -0
  51. package/dist/types.d.ts +10 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/validate-topo.d.ts +2 -2
  54. package/dist/validate-topo.d.ts.map +1 -1
  55. package/dist/validate-topo.js +75 -9
  56. package/dist/validate-topo.js.map +1 -1
  57. package/dist/validation.d.ts.map +1 -1
  58. package/dist/validation.js +34 -3
  59. package/dist/validation.js.map +1 -1
  60. package/package.json +2 -2
  61. package/src/__tests__/context.test.ts +16 -5
  62. package/src/__tests__/derive.test.ts +44 -0
  63. package/src/__tests__/dispatch.test.ts +181 -0
  64. package/src/__tests__/event.test.ts +5 -5
  65. package/src/__tests__/execute.test.ts +523 -0
  66. package/src/__tests__/layer.test.ts +14 -113
  67. package/src/__tests__/serialization.test.ts +166 -1
  68. package/src/__tests__/service.test.ts +197 -0
  69. package/src/__tests__/topo.test.ts +171 -78
  70. package/src/__tests__/trail.test.ts +119 -37
  71. package/src/__tests__/type-utils.test.ts +90 -0
  72. package/src/__tests__/validate-topo.test.ts +140 -19
  73. package/src/__tests__/validation.test.ts +53 -0
  74. package/src/context.ts +18 -9
  75. package/src/derive.ts +12 -2
  76. package/src/dispatch.ts +54 -0
  77. package/src/event.ts +3 -3
  78. package/src/execute.ts +345 -0
  79. package/src/index.ts +39 -18
  80. package/src/result.ts +18 -4
  81. package/src/serialization.ts +56 -11
  82. package/src/service.ts +139 -0
  83. package/src/topo.ts +66 -27
  84. package/src/trail.ts +36 -13
  85. package/src/type-utils.ts +45 -0
  86. package/src/types.ts +11 -2
  87. package/src/validate-topo.ts +92 -10
  88. package/src/validation.ts +35 -3
  89. package/tsconfig.tsbuildinfo +1 -1
  90. package/dist/hike.d.ts +0 -36
  91. package/dist/hike.d.ts.map +0 -1
  92. package/dist/hike.js +0 -20
  93. package/dist/hike.js.map +0 -1
  94. package/src/__tests__/hike.test.ts +0 -117
  95. package/src/__tests__/job.test.ts +0 -98
  96. package/src/adapters.ts +0 -68
  97. package/src/health.ts +0 -23
  98. package/src/hike.ts +0 -77
  99. package/src/job.ts +0 -20
@@ -2,13 +2,24 @@ import { describe, test, expect } from 'bun:test';
2
2
 
3
3
  import {
4
4
  ValidationError,
5
+ AmbiguousError,
6
+ AssertionError,
5
7
  NetworkError,
6
8
  RateLimitError,
7
9
  InternalError,
8
10
  TimeoutError,
9
11
  NotFoundError,
12
+ AlreadyExistsError,
13
+ ConflictError,
14
+ PermissionError,
15
+ AuthError,
16
+ CancelledError,
10
17
  } from '../errors.js';
11
- import { serializeError, deserializeError } from '../serialization.js';
18
+ import {
19
+ serializeError,
20
+ deserializeError,
21
+ safeStringify,
22
+ } from '../serialization.js';
12
23
  import { Result } from '../result.js';
13
24
  import type { SerializedError } from '../serialization.js';
14
25
 
@@ -156,6 +167,66 @@ describe('deserializeError', () => {
156
167
  expect(err.category).toBe(category);
157
168
  }
158
169
  });
170
+
171
+ describe('round-trips all subclasses by name', () => {
172
+ const subclasses = [
173
+ { Ctor: ValidationError, category: 'validation' },
174
+ { Ctor: AmbiguousError, category: 'validation' },
175
+ { Ctor: AssertionError, category: 'internal' },
176
+ { Ctor: NotFoundError, category: 'not_found' },
177
+ { Ctor: AlreadyExistsError, category: 'conflict' },
178
+ { Ctor: ConflictError, category: 'conflict' },
179
+ { Ctor: PermissionError, category: 'permission' },
180
+ { Ctor: TimeoutError, category: 'timeout' },
181
+ { Ctor: NetworkError, category: 'network' },
182
+ { Ctor: InternalError, category: 'internal' },
183
+ { Ctor: AuthError, category: 'auth' },
184
+ { Ctor: CancelledError, category: 'cancelled' },
185
+ ] as const;
186
+
187
+ test.each(subclasses)(
188
+ '$Ctor.name round-trips with correct identity',
189
+ ({ Ctor, category }) => {
190
+ const original = new Ctor(`test ${Ctor.name}`, {
191
+ context: { key: 'value' },
192
+ });
193
+ const serialized = serializeError(original);
194
+ const restored = deserializeError(serialized);
195
+
196
+ expect(restored).toBeInstanceOf(Ctor);
197
+ expect(restored.constructor.name).toBe(Ctor.name);
198
+ expect(restored.name).toBe(Ctor.name);
199
+ expect(restored.category).toBe(category);
200
+ expect(restored.message).toBe(`test ${Ctor.name}`);
201
+ expect(restored.context).toEqual({ key: 'value' });
202
+ }
203
+ );
204
+
205
+ test('RateLimitError round-trips with retryAfter', () => {
206
+ const original = new RateLimitError('slow down', {
207
+ context: { endpoint: '/api' },
208
+ retryAfter: 42,
209
+ });
210
+ const serialized = serializeError(original);
211
+ const restored = deserializeError(serialized);
212
+
213
+ expect(restored).toBeInstanceOf(RateLimitError);
214
+ expect(restored.constructor.name).toBe('RateLimitError');
215
+ expect((restored as RateLimitError).retryAfter).toBe(42);
216
+ expect(restored.context).toEqual({ endpoint: '/api' });
217
+ });
218
+
219
+ test('falls back to category when name is unknown', () => {
220
+ const data: SerializedError = {
221
+ category: 'conflict',
222
+ message: 'custom error',
223
+ name: 'CustomConflictError',
224
+ };
225
+ const err = deserializeError(data);
226
+ expect(err).toBeInstanceOf(ConflictError);
227
+ expect(err.category).toBe('conflict');
228
+ });
229
+ });
159
230
  });
160
231
 
161
232
  // ---------------------------------------------------------------------------
@@ -233,4 +304,98 @@ describe('Result.toJson (safeStringify)', () => {
233
304
  expect(parsed['a']).toBe(1);
234
305
  expect(parsed['self']).toBe('[Circular]');
235
306
  });
307
+
308
+ test('serializes shared references in a DAG without marking as circular', () => {
309
+ const shared = { x: 1 };
310
+ const obj = { a: shared, b: shared };
311
+ const result = Result.toJson(obj);
312
+ expect(result.isOk()).toBe(true);
313
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
314
+ expect(parsed['a']).toEqual({ x: 1 });
315
+ expect(parsed['b']).toEqual({ x: 1 });
316
+ });
317
+
318
+ test('detects deep circular references', () => {
319
+ const inner: Record<string, unknown> = { value: 'deep' };
320
+ const obj: Record<string, unknown> = { child: { nested: inner } };
321
+ inner['back'] = obj;
322
+ const result = Result.toJson(obj);
323
+ expect(result.isOk()).toBe(true);
324
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
325
+ const child = parsed['child'] as Record<string, unknown>;
326
+ const nested = child['nested'] as Record<string, unknown>;
327
+ expect(nested['value']).toBe('deep');
328
+ expect(nested['back']).toBe('[Circular]');
329
+ });
330
+
331
+ test('handles shared ref used in sibling subtrees of a DAG', () => {
332
+ const shared = { id: 42 };
333
+ const obj = {
334
+ left: { extra: 'l', ref: shared },
335
+ right: { extra: 'r', ref: shared },
336
+ };
337
+ const result = Result.toJson(obj);
338
+ expect(result.isOk()).toBe(true);
339
+ const parsed = JSON.parse(result.unwrap()) as Record<
340
+ string,
341
+ Record<string, unknown>
342
+ >;
343
+ expect(parsed['left']?.['ref']).toEqual({ id: 42 });
344
+ expect(parsed['right']?.['ref']).toEqual({ id: 42 });
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // safeStringify (shared DAG / circular detection)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe('safeStringify', () => {
353
+ test('serializes shared references in a DAG without marking as circular', () => {
354
+ const shared = { x: 1 };
355
+ const obj = { a: shared, b: shared };
356
+ const result = safeStringify(obj);
357
+ expect(result.isOk()).toBe(true);
358
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
359
+ expect(parsed['a']).toEqual({ x: 1 });
360
+ expect(parsed['b']).toEqual({ x: 1 });
361
+ });
362
+
363
+ test('detects true circular references', () => {
364
+ const obj: Record<string, unknown> = { a: 1 };
365
+ obj['self'] = obj;
366
+ const result = safeStringify(obj);
367
+ expect(result.isOk()).toBe(true);
368
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
369
+ expect(parsed['a']).toBe(1);
370
+ expect(parsed['self']).toBe('[Circular]');
371
+ });
372
+
373
+ test('detects deep circular references', () => {
374
+ const inner: Record<string, unknown> = { value: 'deep' };
375
+ const obj: Record<string, unknown> = { child: { nested: inner } };
376
+ inner['back'] = obj;
377
+ const result = safeStringify(obj);
378
+ expect(result.isOk()).toBe(true);
379
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
380
+ const child = parsed['child'] as Record<string, unknown>;
381
+ const nested = child['nested'] as Record<string, unknown>;
382
+ expect(nested['value']).toBe('deep');
383
+ expect(nested['back']).toBe('[Circular]');
384
+ });
385
+
386
+ test('handles shared ref used in sibling subtrees of a DAG', () => {
387
+ const shared = { id: 42 };
388
+ const obj = {
389
+ left: { extra: 'l', ref: shared },
390
+ right: { extra: 'r', ref: shared },
391
+ };
392
+ const result = safeStringify(obj);
393
+ expect(result.isOk()).toBe(true);
394
+ const parsed = JSON.parse(result.unwrap()) as Record<
395
+ string,
396
+ Record<string, unknown>
397
+ >;
398
+ expect(parsed['left']?.['ref']).toEqual({ id: 42 });
399
+ expect(parsed['right']?.['ref']).toEqual({ id: 42 });
400
+ });
236
401
  });
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { z } from 'zod';
3
+
4
+ import {
5
+ Result,
6
+ createTrailContext,
7
+ findDuplicateServiceId,
8
+ isService,
9
+ service as defineService,
10
+ } from '../index.js';
11
+ import type {
12
+ Service,
13
+ ServiceContext,
14
+ ServiceSpec,
15
+ TrailContext,
16
+ } from '../index.js';
17
+
18
+ const serviceCtx: ServiceContext = {
19
+ cwd: '/tmp/trails',
20
+ env: { DATABASE_URL: 'file::memory:' },
21
+ workspaceRoot: '/tmp',
22
+ };
23
+
24
+ let disposedValue: number | undefined;
25
+
26
+ const counterServiceSpec: ServiceSpec<number> = {
27
+ create: () => Result.ok(3),
28
+ description: 'Counter service',
29
+ dispose: (service) => {
30
+ disposedValue = service;
31
+ },
32
+ health: (service) => Result.ok({ healthy: service > 0 }),
33
+ metadata: { domain: 'data' },
34
+ mock: () => 1,
35
+ };
36
+
37
+ const resolvedServiceCtx = (id: string, instance: unknown): TrailContext =>
38
+ createTrailContext({
39
+ extensions: { [id]: instance },
40
+ requestId: `${id}-request`,
41
+ signal: new AbortController().signal,
42
+ });
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');
49
+ });
50
+
51
+ test('ServiceSpec stores description and metadata', () => {
52
+ expect(counterServiceSpec.description).toBe('Counter service');
53
+ expect(counterServiceSpec.metadata).toEqual({ domain: 'data' });
54
+ });
55
+
56
+ test('ServiceSpec can reserve a config schema for future composition', () => {
57
+ const config = z.object({ url: z.string().url() });
58
+ const spec: ServiceSpec<number> = {
59
+ config,
60
+ create: () => Result.ok(1),
61
+ };
62
+
63
+ expect(spec.config).toBe(config);
64
+ });
65
+
66
+ test('ServiceSpec create and health callbacks are callable', async () => {
67
+ const result = await counterServiceSpec.create(serviceCtx);
68
+ expect(result.isOk()).toBe(true);
69
+ expect(result.unwrap()).toBe(3);
70
+
71
+ const health = await counterServiceSpec.health?.(result.unwrap());
72
+ expect(health?.isOk()).toBe(true);
73
+ expect(health?.unwrap()).toEqual({ healthy: true });
74
+ });
75
+
76
+ test('ServiceSpec mock and dispose callbacks are callable', async () => {
77
+ disposedValue = undefined;
78
+ const result = await counterServiceSpec.create(serviceCtx);
79
+ expect(result.isOk()).toBe(true);
80
+
81
+ const service = result.unwrap();
82
+ expect(await counterServiceSpec.mock?.()).toBe(1);
83
+
84
+ await counterServiceSpec.dispose?.(service);
85
+ expect(disposedValue).toBe(3);
86
+ });
87
+
88
+ test('ServiceSpec create can be async', async () => {
89
+ const spec: ServiceSpec<number> = {
90
+ create: async () => {
91
+ await Bun.sleep(0);
92
+ return Result.ok(7);
93
+ },
94
+ };
95
+
96
+ const result = await spec.create(serviceCtx);
97
+ expect(result.isOk()).toBe(true);
98
+ expect(result.unwrap()).toBe(7);
99
+ });
100
+
101
+ test('Service carries identity alongside the shared spec fields', async () => {
102
+ const service: Service<number> = {
103
+ from(ctx) {
104
+ return ctx.service(this);
105
+ },
106
+ id: 'counter.main',
107
+ kind: 'service',
108
+ ...counterServiceSpec,
109
+ };
110
+
111
+ expect(service.kind).toBe('service');
112
+ expect(service.id).toBe('counter.main');
113
+
114
+ const created = await service.create(serviceCtx);
115
+ expect(created.isOk()).toBe(true);
116
+ expect(created.unwrap()).toBe(3);
117
+ expect(await service.mock?.()).toBe(1);
118
+ });
119
+ });
120
+
121
+ describe('service()', () => {
122
+ test('returns a frozen service object with kind and id', () => {
123
+ const counter = defineService('counter.main', counterServiceSpec);
124
+
125
+ expect(counter.kind).toBe('service');
126
+ expect(counter.id).toBe('counter.main');
127
+ expect(Object.isFrozen(counter)).toBe(true);
128
+ });
129
+
130
+ test('infers the service type through from(ctx)', () => {
131
+ const db = defineService('db.main', {
132
+ create: () =>
133
+ Result.ok({
134
+ query(sql: string) {
135
+ return sql.length;
136
+ },
137
+ }),
138
+ description: 'Typed database service',
139
+ });
140
+
141
+ const store = {
142
+ query(sql: string) {
143
+ return sql.length;
144
+ },
145
+ };
146
+ const ctx = resolvedServiceCtx('db.main', store);
147
+
148
+ const resolved = db.from(ctx);
149
+ expect(resolved.query('select 1')).toBe(8);
150
+ });
151
+
152
+ test('from(ctx) throws when the service is missing', () => {
153
+ const counter = defineService('counter.main', counterServiceSpec);
154
+ const ctx = createTrailContext({
155
+ requestId: 'missing-service',
156
+ signal: new AbortController().signal,
157
+ });
158
+
159
+ expect(() => counter.from(ctx)).toThrow(
160
+ 'Service "counter.main" not found in trail context'
161
+ );
162
+ });
163
+
164
+ test('from(ctx) returns undefined when the service key exists with an undefined value', () => {
165
+ const optional = defineService<undefined>('optional.main', {
166
+ create: () => Result.ok<undefined>(),
167
+ });
168
+ const ctx = createTrailContext({
169
+ extensions: { [optional.id]: undefined },
170
+ requestId: 'undefined-service',
171
+ signal: new AbortController().signal,
172
+ });
173
+
174
+ expect(optional.from(ctx)).toBeUndefined();
175
+ });
176
+ });
177
+
178
+ describe('service helpers', () => {
179
+ test('isService identifies service definitions', () => {
180
+ const counter = defineService('counter.main', counterServiceSpec);
181
+
182
+ expect(isService(counter)).toBe(true);
183
+ expect(isService({ id: 'counter.main', kind: 'trail' })).toBe(false);
184
+ expect(isService(null)).toBe(false);
185
+ });
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);
191
+
192
+ expect(findDuplicateServiceId([first, other])).toBeUndefined();
193
+ expect(findDuplicateServiceId([first, other, duplicate])).toBe(
194
+ 'counter.main'
195
+ );
196
+ });
197
+ });