@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.
@@ -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
  });
@@ -42,80 +42,116 @@ const mockEvent = (id: string) => ({
42
42
  // ---------------------------------------------------------------------------
43
43
 
44
44
  describe('topo', () => {
45
- test('returns Topo with name', () => {
46
- const t = topo('my-app');
47
- expect(t.name).toBe('my-app');
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
- test('collects trails from modules', () => {
51
- const mod = { myTrail: mockTrail('create-user') };
52
- const t = topo('app', mod);
51
+ test('collects trails from modules', () => {
52
+ const mod = { myTrail: mockTrail('create-user') };
53
+ const t = topo('app', mod);
53
54
 
54
- expect(t.trails.size).toBe(1);
55
- expect(t.trails.get('create-user')).toBe(mod.myTrail);
56
- });
55
+ expect(t.trails.size).toBe(1);
56
+ expect(t.trails.get('create-user')).toBe(mod.myTrail);
57
+ });
57
58
 
58
- test('auto-scans exports by kind discriminant', () => {
59
- const mod = {
60
- event1: mockEvent('e1'),
61
- hike1: mockHike('r1'),
62
- trail1: mockTrail('t1'),
63
- };
64
- const t = topo('app', mod);
65
-
66
- expect(t.trails.size).toBe(1);
67
- expect(t.hikes.size).toBe(1);
68
- expect(t.events.size).toBe(1);
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
- test('collects from multiple modules', () => {
72
- const mod1 = { a: mockTrail('t1') };
73
- const mod2 = { b: mockTrail('t2'), c: mockHike('r1') };
74
- const t = topo('app', mod1, mod2);
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
- expect(t.trails.size).toBe(2);
77
- expect(t.hikes.size).toBe(1);
78
- });
77
+ expect(t.trails.size).toBe(2);
78
+ expect(t.hikes.size).toBe(1);
79
+ });
79
80
 
80
- test('non-trail exports are silently ignored', () => {
81
- const mod = {
82
- config: { port: 3000 },
83
- helper: () => 'not a trail',
84
- name: 'some-string',
85
- nothing: null,
86
- num: 42,
87
- trail1: mockTrail('t1'),
88
- undef: undefined,
89
- };
90
- const t = topo('app', mod);
91
-
92
- expect(t.trails.size).toBe(1);
93
- expect(t.hikes.size).toBe(0);
94
- expect(t.events.size).toBe(0);
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
- test('rejects duplicate trail IDs', () => {
98
- const mod1 = { a: mockTrail('dup') };
99
- const mod2 = { b: mockTrail('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
102
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate trail ID: "dup"');
102
+ expect(t.trails.size).toBe(1);
103
+ expect(t.hikes.size).toBe(1);
104
+ });
103
105
  });
104
106
 
105
- test('rejects duplicate hike IDs', () => {
106
- const mod1 = { a: mockHike('dup') };
107
- const mod2 = { b: mockHike('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
110
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate hike ID: "dup"');
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
- test('rejects duplicate event IDs', () => {
114
- const mod1 = { a: mockEvent('dup') };
115
- const mod2 = { b: mockEvent('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
118
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate event ID: "dup"');
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).toHaveLength(1);
101
- expect(issues[0]?.rule).toBe('no-self-follow');
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('error example with invalid input is allowed', () => {
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: 'string' | 'number' | 'boolean' | 'enum' | 'multiselect';
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 { options: undefined, type: 'string' };
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
@@ -133,6 +133,10 @@ export {
133
133
  getRelativePath,
134
134
  } from './workspace.js';
135
135
 
136
+ // Blob
137
+ export { createBlobRef, isBlobRef } from './blob-ref.js';
138
+ export type { BlobRef } from './blob-ref.js';
139
+
136
140
  // Guards
137
141
  export {
138
142
  isDefined,
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
- const seen = new WeakSet();
155
- const json = JSON.stringify(value, (_key, val: unknown) => {
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 (seen.has(val)) {
169
+ if (stack.includes(val)) {
158
170
  return '[Circular]';
159
171
  }
160
- seen.add(val);
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', {
@@ -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 category = data.category ?? 'internal';
121
- const error = createErrorByCategory(
122
- category,
123
- data.message,
124
- data.context,
125
- data.retryAfter
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
- const seen = new WeakSet();
159
- const json = JSON.stringify(value, (_key, val: unknown) => {
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 (seen.has(val)) {
205
+ if (stack.includes(val)) {
162
206
  return '[Circular]';
163
207
  }
164
- seen.add(val);
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
  };