@ontrails/core 1.0.0-beta.2 → 1.0.0-beta.3
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 +16 -0
- package/dist/derive.d.ts +1 -1
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +4 -1
- package/dist/derive.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/result.d.ts.map +1 -1
- package/dist/result.js +15 -4
- package/dist/result.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +45 -7
- package/dist/serialization.js.map +1 -1
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +6 -0
- package/dist/topo.js.map +1 -1
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +49 -1
- package/dist/validate-topo.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/derive.test.ts +44 -0
- package/src/__tests__/serialization.test.ts +166 -1
- package/src/__tests__/topo.test.ts +97 -61
- package/src/__tests__/validate-topo.test.ts +82 -3
- package/src/derive.ts +12 -2
- package/src/index.ts +4 -0
- package/src/result.ts +18 -4
- package/src/serialization.ts +56 -11
- package/src/topo.ts +10 -0
- package/src/validate-topo.ts +60 -1
|
@@ -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 {
|
|
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
|
});
|
|
@@ -42,80 +42,116 @@ const mockEvent = (id: string) => ({
|
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
|
|
44
44
|
describe('topo', () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
describe('collection', () => {
|
|
46
|
+
test('returns Topo with name', () => {
|
|
47
|
+
const t = topo('my-app');
|
|
48
|
+
expect(t.name).toBe('my-app');
|
|
49
|
+
});
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
test('collects trails from modules', () => {
|
|
52
|
+
const mod = { myTrail: mockTrail('create-user') };
|
|
53
|
+
const t = topo('app', mod);
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
expect(t.trails.size).toBe(1);
|
|
56
|
+
expect(t.trails.get('create-user')).toBe(mod.myTrail);
|
|
57
|
+
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
test('auto-scans exports by kind discriminant', () => {
|
|
60
|
+
const mod = {
|
|
61
|
+
event1: mockEvent('e1'),
|
|
62
|
+
hike1: mockHike('r1'),
|
|
63
|
+
trail1: mockTrail('t1'),
|
|
64
|
+
};
|
|
65
|
+
const t = topo('app', mod);
|
|
66
|
+
|
|
67
|
+
expect(t.trails.size).toBe(1);
|
|
68
|
+
expect(t.hikes.size).toBe(1);
|
|
69
|
+
expect(t.events.size).toBe(1);
|
|
70
|
+
});
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
test('collects from multiple modules', () => {
|
|
73
|
+
const mod1 = { a: mockTrail('t1') };
|
|
74
|
+
const mod2 = { b: mockTrail('t2'), c: mockHike('r1') };
|
|
75
|
+
const t = topo('app', mod1, mod2);
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
expect(t.trails.size).toBe(2);
|
|
78
|
+
expect(t.hikes.size).toBe(1);
|
|
79
|
+
});
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
81
|
+
test('non-trail exports are silently ignored', () => {
|
|
82
|
+
const mod = {
|
|
83
|
+
config: { port: 3000 },
|
|
84
|
+
helper: () => 'not a trail',
|
|
85
|
+
name: 'some-string',
|
|
86
|
+
nothing: null,
|
|
87
|
+
num: 42,
|
|
88
|
+
trail1: mockTrail('t1'),
|
|
89
|
+
undef: undefined,
|
|
90
|
+
};
|
|
91
|
+
const t = topo('app', mod);
|
|
92
|
+
|
|
93
|
+
expect(t.trails.size).toBe(1);
|
|
94
|
+
expect(t.hikes.size).toBe(0);
|
|
95
|
+
expect(t.events.size).toBe(0);
|
|
96
|
+
});
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
test('allows different IDs across trails and hikes', () => {
|
|
99
|
+
const mod = { h: mockHike('hike-1'), t: mockTrail('trail-1') };
|
|
100
|
+
const t = topo('app', mod);
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
expect(t.trails.size).toBe(1);
|
|
103
|
+
expect(t.hikes.size).toBe(1);
|
|
104
|
+
});
|
|
103
105
|
});
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
describe('duplicate rejection', () => {
|
|
108
|
+
test('rejects duplicate trail IDs', () => {
|
|
109
|
+
const mod1 = { a: mockTrail('dup') };
|
|
110
|
+
const mod2 = { b: mockTrail('dup') };
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
113
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
114
|
+
'Duplicate trail ID: "dup"'
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('rejects duplicate hike IDs', () => {
|
|
119
|
+
const mod1 = { a: mockHike('dup') };
|
|
120
|
+
const mod2 = { b: mockHike('dup') };
|
|
121
|
+
|
|
122
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
123
|
+
expect(() => topo('app', mod1, mod2)).toThrow('Duplicate hike ID: "dup"');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('rejects duplicate event IDs', () => {
|
|
127
|
+
const mod1 = { a: mockEvent('dup') };
|
|
128
|
+
const mod2 = { b: mockEvent('dup') };
|
|
112
129
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
131
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
132
|
+
'Duplicate event ID: "dup"'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
116
135
|
|
|
117
|
-
|
|
118
|
-
|
|
136
|
+
test('rejects a hike whose ID collides with an existing trail', () => {
|
|
137
|
+
const modTrail = { a: mockTrail('shared-id') };
|
|
138
|
+
const modHike = { b: mockHike('shared-id') };
|
|
139
|
+
|
|
140
|
+
expect(() => topo('app', modTrail, modHike)).toThrow(ValidationError);
|
|
141
|
+
expect(() => topo('app', modTrail, modHike)).toThrow(
|
|
142
|
+
/ID collision.*hike "shared-id".*trail/
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('rejects a trail whose ID collides with an existing hike', () => {
|
|
147
|
+
const modHike = { a: mockHike('shared-id') };
|
|
148
|
+
const modTrail = { b: mockTrail('shared-id') };
|
|
149
|
+
|
|
150
|
+
expect(() => topo('app', modHike, modTrail)).toThrow(ValidationError);
|
|
151
|
+
expect(() => topo('app', modHike, modTrail)).toThrow(
|
|
152
|
+
/ID collision.*trail "shared-id".*hike/
|
|
153
|
+
);
|
|
154
|
+
});
|
|
119
155
|
});
|
|
120
156
|
});
|
|
121
157
|
|
|
@@ -97,8 +97,49 @@ describe('validateTopo', () => {
|
|
|
97
97
|
expect(result.isErr()).toBe(true);
|
|
98
98
|
|
|
99
99
|
const issues = extractIssues(result);
|
|
100
|
-
expect(issues).
|
|
101
|
-
|
|
100
|
+
expect(issues.some((i) => i.rule === 'no-self-follow')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('two-node cycle (a→b→a) is detected', () => {
|
|
104
|
+
const app = topo('app', {
|
|
105
|
+
a: mockHike('a', ['b']),
|
|
106
|
+
b: mockHike('b', ['a']),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = validateTopo(app);
|
|
110
|
+
expect(result.isErr()).toBe(true);
|
|
111
|
+
|
|
112
|
+
const issues = extractIssues(result);
|
|
113
|
+
const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
|
|
114
|
+
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
115
|
+
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('three-node cycle (a→b→c→a) is detected', () => {
|
|
119
|
+
const app = topo('app', {
|
|
120
|
+
a: mockHike('a', ['b']),
|
|
121
|
+
b: mockHike('b', ['c']),
|
|
122
|
+
c: mockHike('c', ['a']),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = validateTopo(app);
|
|
126
|
+
expect(result.isErr()).toBe(true);
|
|
127
|
+
|
|
128
|
+
const issues = extractIssues(result);
|
|
129
|
+
const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
|
|
130
|
+
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
131
|
+
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('valid DAG with shared targets is not flagged', () => {
|
|
135
|
+
const app = topo('app', {
|
|
136
|
+
a: mockHike('a', ['c']),
|
|
137
|
+
b: mockHike('b', ['c']),
|
|
138
|
+
c: mockHike('c', []),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = validateTopo(app);
|
|
142
|
+
expect(result.isOk()).toBe(true);
|
|
102
143
|
});
|
|
103
144
|
});
|
|
104
145
|
|
|
@@ -140,7 +181,7 @@ describe('validateTopo', () => {
|
|
|
140
181
|
expect(issues[0]?.rule).toBe('output-schema-present');
|
|
141
182
|
});
|
|
142
183
|
|
|
143
|
-
test('
|
|
184
|
+
test('ValidationError example with invalid input is allowed', () => {
|
|
144
185
|
const app = topo('app', {
|
|
145
186
|
show: mockTrail('entity.show', {
|
|
146
187
|
examples: [
|
|
@@ -156,6 +197,44 @@ describe('validateTopo', () => {
|
|
|
156
197
|
const result = validateTopo(app);
|
|
157
198
|
expect(result.isOk()).toBe(true);
|
|
158
199
|
});
|
|
200
|
+
|
|
201
|
+
test('NotFoundError example with invalid input fails', () => {
|
|
202
|
+
const app = topo('app', {
|
|
203
|
+
show: mockTrail('entity.show', {
|
|
204
|
+
examples: [
|
|
205
|
+
{
|
|
206
|
+
error: 'NotFoundError',
|
|
207
|
+
input: { name: 123 },
|
|
208
|
+
name: 'Not found case',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const result = validateTopo(app);
|
|
215
|
+
expect(result.isErr()).toBe(true);
|
|
216
|
+
|
|
217
|
+
const issues = extractIssues(result);
|
|
218
|
+
expect(issues).toHaveLength(1);
|
|
219
|
+
expect(issues[0]?.rule).toBe('example-input-valid');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('NotFoundError example with valid input passes', () => {
|
|
223
|
+
const app = topo('app', {
|
|
224
|
+
show: mockTrail('entity.show', {
|
|
225
|
+
examples: [
|
|
226
|
+
{
|
|
227
|
+
error: 'NotFoundError',
|
|
228
|
+
input: { name: 'test' },
|
|
229
|
+
name: 'Not found case',
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const result = validateTopo(app);
|
|
236
|
+
expect(result.isOk()).toBe(true);
|
|
237
|
+
});
|
|
159
238
|
});
|
|
160
239
|
|
|
161
240
|
describe('event origins', () => {
|
package/src/derive.ts
CHANGED
|
@@ -14,7 +14,14 @@ import type { z } from 'zod';
|
|
|
14
14
|
/** A surface-agnostic field descriptor derived from a Zod schema. */
|
|
15
15
|
export interface Field {
|
|
16
16
|
readonly name: string;
|
|
17
|
-
readonly type:
|
|
17
|
+
readonly type:
|
|
18
|
+
| 'string'
|
|
19
|
+
| 'number'
|
|
20
|
+
| 'boolean'
|
|
21
|
+
| 'enum'
|
|
22
|
+
| 'multiselect'
|
|
23
|
+
| 'string[]'
|
|
24
|
+
| 'number[]';
|
|
18
25
|
readonly label: string;
|
|
19
26
|
readonly required: boolean;
|
|
20
27
|
readonly default?: unknown | undefined;
|
|
@@ -141,7 +148,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
|
|
|
141
148
|
const entries = element._zod.def['entries'] as Record<string, string>;
|
|
142
149
|
return { options: Object.values(entries), type: 'multiselect' };
|
|
143
150
|
}
|
|
144
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
options: undefined,
|
|
153
|
+
type: elementType === 'number' ? 'number[]' : 'string[]',
|
|
154
|
+
};
|
|
145
155
|
},
|
|
146
156
|
boolean: () => ({ options: undefined, type: 'boolean' }),
|
|
147
157
|
enum: (s) => {
|
package/src/index.ts
CHANGED
package/src/result.ts
CHANGED
|
@@ -151,16 +151,30 @@ export const Result = {
|
|
|
151
151
|
*/
|
|
152
152
|
toJson(value: unknown): Result<string, InternalError> {
|
|
153
153
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// Track the current ancestor chain, not every object ever visited.
|
|
155
|
+
// This allows shared references in a DAG while still detecting cycles.
|
|
156
|
+
const stack: unknown[] = [];
|
|
157
|
+
const keys: string[] = [];
|
|
158
|
+
|
|
159
|
+
const json = JSON.stringify(value, function json(key, val: unknown) {
|
|
160
|
+
if (stack.length > 0) {
|
|
161
|
+
// `this` is the object that contains `key`. Trim the stack back
|
|
162
|
+
// to `this` so we only track the current ancestor path.
|
|
163
|
+
const thisIndex = stack.lastIndexOf(this as unknown);
|
|
164
|
+
stack.splice(thisIndex + 1);
|
|
165
|
+
keys.splice(thisIndex);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
if (typeof val === 'object' && val !== null) {
|
|
157
|
-
if (
|
|
169
|
+
if (stack.includes(val)) {
|
|
158
170
|
return '[Circular]';
|
|
159
171
|
}
|
|
160
|
-
|
|
172
|
+
stack.push(val);
|
|
173
|
+
keys.push(key);
|
|
161
174
|
}
|
|
162
175
|
return val;
|
|
163
176
|
});
|
|
177
|
+
|
|
164
178
|
if (json === undefined) {
|
|
165
179
|
return new Err(
|
|
166
180
|
new InternalError('Value is not JSON-serializable', {
|
package/src/serialization.ts
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
import type { ErrorCategory, TrailsError } from './errors.js';
|
|
9
9
|
import {
|
|
10
10
|
ValidationError,
|
|
11
|
+
AmbiguousError,
|
|
12
|
+
AssertionError,
|
|
11
13
|
NotFoundError,
|
|
14
|
+
AlreadyExistsError,
|
|
12
15
|
ConflictError,
|
|
13
16
|
PermissionError,
|
|
14
17
|
TimeoutError,
|
|
@@ -89,6 +92,31 @@ const createErrorByCategory = (
|
|
|
89
92
|
return factory(message, opts, retryAfter);
|
|
90
93
|
};
|
|
91
94
|
|
|
95
|
+
/** Map error class names to their constructors for precise round-tripping. */
|
|
96
|
+
const errorConstructorsByName: Record<string, ErrorFactory> = {
|
|
97
|
+
AlreadyExistsError: (msg, opts) => new AlreadyExistsError(msg, opts),
|
|
98
|
+
AmbiguousError: (msg, opts) => new AmbiguousError(msg, opts),
|
|
99
|
+
AssertionError: (msg, opts) => new AssertionError(msg, opts),
|
|
100
|
+
AuthError: (msg, opts) => new AuthError(msg, opts),
|
|
101
|
+
CancelledError: (msg, opts) => new CancelledError(msg, opts),
|
|
102
|
+
ConflictError: (msg, opts) => new ConflictError(msg, opts),
|
|
103
|
+
InternalError: (msg, opts) => new InternalError(msg, opts),
|
|
104
|
+
NetworkError: (msg, opts) => new NetworkError(msg, opts),
|
|
105
|
+
NotFoundError: (msg, opts) => new NotFoundError(msg, opts),
|
|
106
|
+
PermissionError: (msg, opts) => new PermissionError(msg, opts),
|
|
107
|
+
RateLimitError: (msg, opts, retryAfter) => {
|
|
108
|
+
const rlOpts: { context?: Record<string, unknown>; retryAfter?: number } = {
|
|
109
|
+
...opts,
|
|
110
|
+
};
|
|
111
|
+
if (retryAfter !== undefined) {
|
|
112
|
+
rlOpts.retryAfter = retryAfter;
|
|
113
|
+
}
|
|
114
|
+
return new RateLimitError(msg, rlOpts);
|
|
115
|
+
},
|
|
116
|
+
TimeoutError: (msg, opts) => new TimeoutError(msg, opts),
|
|
117
|
+
ValidationError: (msg, opts) => new ValidationError(msg, opts),
|
|
118
|
+
};
|
|
119
|
+
|
|
92
120
|
// ---------------------------------------------------------------------------
|
|
93
121
|
// Error serialization
|
|
94
122
|
// ---------------------------------------------------------------------------
|
|
@@ -117,13 +145,17 @@ export const serializeError = (error: Error): SerializedError => {
|
|
|
117
145
|
|
|
118
146
|
/** Reconstruct a TrailsError from serialized data. */
|
|
119
147
|
export const deserializeError = (data: SerializedError): TrailsError => {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
data.
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
const opts = buildOpts(data.context);
|
|
149
|
+
const nameFactory = errorConstructorsByName[data.name];
|
|
150
|
+
|
|
151
|
+
const error = nameFactory
|
|
152
|
+
? nameFactory(data.message, opts, data.retryAfter)
|
|
153
|
+
: createErrorByCategory(
|
|
154
|
+
data.category ?? 'internal',
|
|
155
|
+
data.message,
|
|
156
|
+
data.context,
|
|
157
|
+
data.retryAfter
|
|
158
|
+
);
|
|
127
159
|
|
|
128
160
|
if (data.stack) {
|
|
129
161
|
error.stack = data.stack;
|
|
@@ -155,13 +187,26 @@ export const safeStringify = (
|
|
|
155
187
|
value: unknown
|
|
156
188
|
): Result<string, InternalError> => {
|
|
157
189
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
190
|
+
// Track the current ancestor chain, not every object ever visited.
|
|
191
|
+
// This allows shared references in a DAG while still detecting cycles.
|
|
192
|
+
const stack: unknown[] = [];
|
|
193
|
+
const keys: string[] = [];
|
|
194
|
+
|
|
195
|
+
const json = JSON.stringify(value, function json(key, val: unknown) {
|
|
196
|
+
if (stack.length > 0) {
|
|
197
|
+
// `this` is the object that contains `key`. Trim the stack back
|
|
198
|
+
// to `this` so we only track the current ancestor path.
|
|
199
|
+
const thisIndex = stack.lastIndexOf(this as unknown);
|
|
200
|
+
stack.splice(thisIndex + 1);
|
|
201
|
+
keys.splice(thisIndex);
|
|
202
|
+
}
|
|
203
|
+
|
|
160
204
|
if (typeof val === 'object' && val !== null) {
|
|
161
|
-
if (
|
|
205
|
+
if (stack.includes(val)) {
|
|
162
206
|
return '[Circular]';
|
|
163
207
|
}
|
|
164
|
-
|
|
208
|
+
stack.push(val);
|
|
209
|
+
keys.push(key);
|
|
165
210
|
}
|
|
166
211
|
return val;
|
|
167
212
|
});
|
package/src/topo.ts
CHANGED
|
@@ -91,12 +91,22 @@ const register = (
|
|
|
91
91
|
if (hikes.has(id)) {
|
|
92
92
|
throw new ValidationError(`Duplicate hike ID: "${id}"`);
|
|
93
93
|
}
|
|
94
|
+
if (trails.has(id)) {
|
|
95
|
+
throw new ValidationError(
|
|
96
|
+
`ID collision: hike "${id}" conflicts with an existing trail of the same ID`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
94
99
|
hikes.set(id, value as AnyHike);
|
|
95
100
|
},
|
|
96
101
|
trail: () => {
|
|
97
102
|
if (trails.has(id)) {
|
|
98
103
|
throw new ValidationError(`Duplicate trail ID: "${id}"`);
|
|
99
104
|
}
|
|
105
|
+
if (hikes.has(id)) {
|
|
106
|
+
throw new ValidationError(
|
|
107
|
+
`ID collision: trail "${id}" conflicts with an existing hike of the same ID`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
100
110
|
trails.set(id, value as AnyTrail);
|
|
101
111
|
},
|
|
102
112
|
};
|