@ontrails/core 1.0.0-beta.0 → 1.0.0-beta.10

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 (84) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +101 -131
  4. package/dist/derive.d.ts +1 -1
  5. package/dist/derive.d.ts.map +1 -1
  6. package/dist/derive.js +4 -1
  7. package/dist/derive.js.map +1 -1
  8. package/dist/dispatch.d.ts +27 -0
  9. package/dist/dispatch.d.ts.map +1 -0
  10. package/dist/dispatch.js +34 -0
  11. package/dist/dispatch.js.map +1 -0
  12. package/dist/event.d.ts +2 -2
  13. package/dist/event.d.ts.map +1 -1
  14. package/dist/event.js +1 -1
  15. package/dist/event.js.map +1 -1
  16. package/dist/execute.d.ts +30 -0
  17. package/dist/execute.d.ts.map +1 -0
  18. package/dist/execute.js +64 -0
  19. package/dist/execute.js.map +1 -0
  20. package/dist/index.d.ts +10 -6
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/patterns/status.d.ts +1 -1
  25. package/dist/result.d.ts +11 -11
  26. package/dist/result.d.ts.map +1 -1
  27. package/dist/result.js +15 -4
  28. package/dist/result.js.map +1 -1
  29. package/dist/serialization.d.ts.map +1 -1
  30. package/dist/serialization.js +45 -7
  31. package/dist/serialization.js.map +1 -1
  32. package/dist/topo.d.ts +4 -4
  33. package/dist/topo.d.ts.map +1 -1
  34. package/dist/topo.js +12 -16
  35. package/dist/topo.js.map +1 -1
  36. package/dist/trail.d.ts +16 -10
  37. package/dist/trail.d.ts.map +1 -1
  38. package/dist/trail.js +4 -2
  39. package/dist/trail.js.map +1 -1
  40. package/dist/type-utils.d.ts +24 -0
  41. package/dist/type-utils.d.ts.map +1 -0
  42. package/dist/type-utils.js +12 -0
  43. package/dist/type-utils.js.map +1 -0
  44. package/dist/types.d.ts +1 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/validate-topo.d.ts +2 -2
  47. package/dist/validate-topo.d.ts.map +1 -1
  48. package/dist/validate-topo.js +59 -9
  49. package/dist/validate-topo.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/context.test.ts +4 -5
  52. package/src/__tests__/derive.test.ts +44 -0
  53. package/src/__tests__/dispatch.test.ts +154 -0
  54. package/src/__tests__/event.test.ts +5 -5
  55. package/src/__tests__/execute.test.ts +208 -0
  56. package/src/__tests__/layer.test.ts +11 -111
  57. package/src/__tests__/serialization.test.ts +166 -1
  58. package/src/__tests__/topo.test.ts +101 -79
  59. package/src/__tests__/trail.test.ts +73 -35
  60. package/src/__tests__/type-utils.test.ts +90 -0
  61. package/src/__tests__/validate-topo.test.ts +97 -20
  62. package/src/derive.ts +12 -2
  63. package/src/dispatch.ts +54 -0
  64. package/src/event.ts +3 -3
  65. package/src/execute.ts +96 -0
  66. package/src/index.ts +22 -18
  67. package/src/result.ts +29 -15
  68. package/src/serialization.ts +56 -11
  69. package/src/topo.ts +18 -23
  70. package/src/trail.ts +24 -13
  71. package/src/type-utils.ts +45 -0
  72. package/src/types.ts +1 -3
  73. package/src/validate-topo.ts +70 -10
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/dist/hike.d.ts +0 -36
  76. package/dist/hike.d.ts.map +0 -1
  77. package/dist/hike.js +0 -20
  78. package/dist/hike.js.map +0 -1
  79. package/src/__tests__/hike.test.ts +0 -117
  80. package/src/__tests__/job.test.ts +0 -98
  81. package/src/adapters.ts +0 -68
  82. package/src/health.ts +0 -23
  83. package/src/hike.ts +0 -77
  84. package/src/job.ts +0 -20
@@ -17,6 +17,7 @@ const noop = async () => Result.ok();
17
17
  const mockTrail = (
18
18
  id: string,
19
19
  overrides?: {
20
+ follow?: readonly string[];
20
21
  examples?: readonly {
21
22
  name: string;
22
23
  input: unknown;
@@ -26,21 +27,14 @@ const mockTrail = (
26
27
  output?: z.ZodType;
27
28
  }
28
29
  ) => ({
30
+ follow: Object.freeze([...(overrides?.follow ?? [])]),
29
31
  id,
30
- implementation: noop,
31
32
  input: z.object({ name: z.string() }),
32
33
  kind: 'trail' as const,
34
+ run: noop,
33
35
  ...overrides,
34
36
  });
35
37
 
36
- const mockHike = (id: string, follows: readonly string[]) => ({
37
- follows,
38
- id,
39
- implementation: noop,
40
- input: z.object({ q: z.string() }),
41
- kind: 'hike' as const,
42
- });
43
-
44
38
  const mockEvent = (id: string, from?: readonly string[]) => ({
45
39
  from,
46
40
  id,
@@ -65,7 +59,9 @@ describe('validateTopo', () => {
65
59
  test('valid topo passes', () => {
66
60
  const app = topo('app', {
67
61
  add: mockTrail('entity.add'),
68
- onboard: mockHike('entity.onboard', ['entity.add']),
62
+ onboard: mockTrail('entity.onboard', {
63
+ follow: ['entity.add'],
64
+ }),
69
65
  updated: mockEvent('entity.updated', ['entity.add']),
70
66
  });
71
67
 
@@ -73,10 +69,12 @@ describe('validateTopo', () => {
73
69
  expect(result.isOk()).toBe(true);
74
70
  });
75
71
 
76
- describe('hike follows', () => {
77
- test('hike following non-existent trail fails', () => {
72
+ describe('trail follow', () => {
73
+ test('trail following non-existent trail fails', () => {
78
74
  const app = topo('app', {
79
- onboard: mockHike('entity.onboard', ['entity.missing']),
75
+ onboard: mockTrail('entity.onboard', {
76
+ follow: ['entity.missing'],
77
+ }),
80
78
  });
81
79
 
82
80
  const result = validateTopo(app);
@@ -84,21 +82,62 @@ describe('validateTopo', () => {
84
82
 
85
83
  const issues = extractIssues(result);
86
84
  expect(issues).toHaveLength(1);
87
- expect(issues[0]?.rule).toBe('follows-exist');
85
+ expect(issues[0]?.rule).toBe('follow-exists');
88
86
  expect(issues[0]?.message).toContain('entity.missing');
89
87
  });
90
88
 
91
- test('hike following itself fails', () => {
89
+ test('trail following itself fails', () => {
92
90
  const app = topo('app', {
93
- loop: mockHike('entity.loop', ['entity.loop']),
91
+ loop: mockTrail('entity.loop', { follow: ['entity.loop'] }),
94
92
  });
95
93
 
96
94
  const result = validateTopo(app);
97
95
  expect(result.isErr()).toBe(true);
98
96
 
99
97
  const issues = extractIssues(result);
100
- expect(issues).toHaveLength(1);
101
- expect(issues[0]?.rule).toBe('no-self-follow');
98
+ expect(issues.some((i) => i.rule === 'no-self-follow')).toBe(true);
99
+ });
100
+
101
+ test('two-node cycle (a→b→a) is detected', () => {
102
+ const app = topo('app', {
103
+ a: mockTrail('a', { follow: ['b'] }),
104
+ b: mockTrail('b', { follow: ['a'] }),
105
+ });
106
+
107
+ const result = validateTopo(app);
108
+ expect(result.isErr()).toBe(true);
109
+
110
+ const issues = extractIssues(result);
111
+ const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
112
+ expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
113
+ expect(cycleIssues[0]?.message).toContain('Cycle detected');
114
+ });
115
+
116
+ test('three-node cycle (a→b→c→a) is detected', () => {
117
+ const app = topo('app', {
118
+ a: mockTrail('a', { follow: ['b'] }),
119
+ b: mockTrail('b', { follow: ['c'] }),
120
+ c: mockTrail('c', { follow: ['a'] }),
121
+ });
122
+
123
+ const result = validateTopo(app);
124
+ expect(result.isErr()).toBe(true);
125
+
126
+ const issues = extractIssues(result);
127
+ const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
128
+ expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
129
+ expect(cycleIssues[0]?.message).toContain('Cycle detected');
130
+ });
131
+
132
+ test('valid DAG with shared targets is not flagged', () => {
133
+ const app = topo('app', {
134
+ a: mockTrail('a', { follow: ['c'] }),
135
+ b: mockTrail('b', { follow: ['c'] }),
136
+ c: mockTrail('c'),
137
+ });
138
+
139
+ const result = validateTopo(app);
140
+ expect(result.isOk()).toBe(true);
102
141
  });
103
142
  });
104
143
 
@@ -140,7 +179,7 @@ describe('validateTopo', () => {
140
179
  expect(issues[0]?.rule).toBe('output-schema-present');
141
180
  });
142
181
 
143
- test('error example with invalid input is allowed', () => {
182
+ test('ValidationError example with invalid input is allowed', () => {
144
183
  const app = topo('app', {
145
184
  show: mockTrail('entity.show', {
146
185
  examples: [
@@ -156,6 +195,44 @@ describe('validateTopo', () => {
156
195
  const result = validateTopo(app);
157
196
  expect(result.isOk()).toBe(true);
158
197
  });
198
+
199
+ test('NotFoundError example with invalid input fails', () => {
200
+ const app = topo('app', {
201
+ show: mockTrail('entity.show', {
202
+ examples: [
203
+ {
204
+ error: 'NotFoundError',
205
+ input: { name: 123 },
206
+ name: 'Not found case',
207
+ },
208
+ ],
209
+ }),
210
+ });
211
+
212
+ const result = validateTopo(app);
213
+ expect(result.isErr()).toBe(true);
214
+
215
+ const issues = extractIssues(result);
216
+ expect(issues).toHaveLength(1);
217
+ expect(issues[0]?.rule).toBe('example-input-valid');
218
+ });
219
+
220
+ test('NotFoundError example with valid input passes', () => {
221
+ const app = topo('app', {
222
+ show: mockTrail('entity.show', {
223
+ examples: [
224
+ {
225
+ error: 'NotFoundError',
226
+ input: { name: 'test' },
227
+ name: 'Not found case',
228
+ },
229
+ ],
230
+ }),
231
+ });
232
+
233
+ const result = validateTopo(app);
234
+ expect(result.isOk()).toBe(true);
235
+ });
159
236
  });
160
237
 
161
238
  describe('event origins', () => {
@@ -185,7 +262,7 @@ describe('validateTopo', () => {
185
262
 
186
263
  test('collects multiple issues', () => {
187
264
  const app = topo('app', {
188
- broken: mockHike('entity.broken', ['entity.missing']),
265
+ broken: mockTrail('entity.broken', { follow: ['entity.missing'] }),
189
266
  show: mockTrail('entity.show', {
190
267
  examples: [{ input: { name: 123 }, name: 'Bad' }],
191
268
  }),
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) => {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Headless trail execution — the "no-surface" surface.
3
+ *
4
+ * Looks up a trail by ID in a topo, then delegates to `executeTrail`.
5
+ * Returns a `Result` and never throws.
6
+ */
7
+
8
+ import type { Topo } from './topo.js';
9
+ import { executeTrail } from './execute.js';
10
+ import type { ExecuteTrailOptions } from './execute.js';
11
+ import { NotFoundError } from './errors.js';
12
+ import { Result } from './result.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Options
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Options forwarded to `executeTrail` from `dispatch`. */
19
+ export type DispatchOptions = ExecuteTrailOptions;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // dispatch()
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Execute a trail by ID from a topo without mounting a surface.
27
+ *
28
+ * Resolves the trail from the topo, then runs it through the standard
29
+ * `executeTrail` pipeline. Returns `Result.err(NotFoundError)` if the
30
+ * trail ID is not registered. Never throws — unexpected exceptions are
31
+ * returned as `Result.err(InternalError)`.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const result = await dispatch(myTopo, 'greet', { name: 'Alice' });
36
+ * if (result.isOk()) console.log(result.value);
37
+ * ```
38
+ */
39
+ export const dispatch = (
40
+ topo: Topo,
41
+ id: string,
42
+ input: unknown,
43
+ options?: DispatchOptions
44
+ ): Promise<Result<unknown, Error>> => {
45
+ const trail = topo.get(id);
46
+ if (trail === undefined) {
47
+ return Promise.resolve(
48
+ Result.err(
49
+ new NotFoundError(`Trail "${id}" not found in topo "${topo.name}"`)
50
+ )
51
+ );
52
+ }
53
+ return executeTrail(trail, input, options);
54
+ };
package/src/event.ts CHANGED
@@ -11,7 +11,7 @@ import type { z } from 'zod';
11
11
  export interface EventSpec<T> {
12
12
  readonly payload: z.ZodType<T>;
13
13
  readonly description?: string | undefined;
14
- readonly markers?: Readonly<Record<string, unknown>> | undefined;
14
+ readonly metadata?: Readonly<Record<string, unknown>> | undefined;
15
15
  /** Trail IDs that produce this event (e.g. the trails it originates from). */
16
16
  readonly from?: readonly string[] | undefined;
17
17
  }
@@ -25,7 +25,7 @@ export interface Event<T> {
25
25
  readonly kind: 'event';
26
26
  readonly payload: z.ZodType<T>;
27
27
  readonly description?: string | undefined;
28
- readonly markers?: Readonly<Record<string, unknown>> | undefined;
28
+ readonly metadata?: Readonly<Record<string, unknown>> | undefined;
29
29
  /** Trail IDs that produce this event (e.g. the trails it originates from). */
30
30
  readonly from?: readonly string[] | undefined;
31
31
  }
@@ -68,7 +68,7 @@ export function event<T>(
68
68
  from: resolvedSpec.from ? Object.freeze([...resolvedSpec.from]) : undefined,
69
69
  id: resolvedId,
70
70
  kind: 'event' as const,
71
- markers: resolvedSpec.markers,
71
+ metadata: resolvedSpec.metadata,
72
72
  payload: resolvedSpec.payload,
73
73
  });
74
74
  }
package/src/execute.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Centralized trail execution pipeline.
3
+ *
4
+ * Validates input, builds context, composes layers, and runs the
5
+ * implementation. Surfaces (CLI, MCP, HTTP) delegate here instead
6
+ * of reimplementing the pipeline.
7
+ */
8
+
9
+ import type { AnyTrail } from './trail.js';
10
+ import type { Layer } from './layer.js';
11
+ import type { TrailContext } from './types.js';
12
+
13
+ import { composeLayers } from './layer.js';
14
+ import { createTrailContext } from './context.js';
15
+ import { InternalError } from './errors.js';
16
+ import { Result } from './result.js';
17
+ import { validateInput } from './validation.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Options
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Options for executeTrail. */
24
+ export interface ExecuteTrailOptions {
25
+ /** Partial context overrides merged on top of the base context. */
26
+ readonly ctx?: Partial<TrailContext> | undefined;
27
+ /** AbortSignal override (takes final precedence over ctx and factory). */
28
+ readonly signal?: AbortSignal | undefined;
29
+ /** Layers to compose around the implementation. */
30
+ readonly layers?: readonly Layer[] | undefined;
31
+ /** Factory that produces a base TrailContext (takes precedence over defaults). */
32
+ readonly createContext?:
33
+ | (() => TrailContext | Promise<TrailContext>)
34
+ | undefined;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Context resolution
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Build a TrailContext from options.
43
+ *
44
+ * Resolution order:
45
+ * 1. Factory (`createContext`) or `createTrailContext()` defaults.
46
+ * 2. Partial `ctx` overrides merged on top.
47
+ * 3. `signal` override takes final precedence.
48
+ */
49
+ const resolveContext = async (
50
+ options?: ExecuteTrailOptions
51
+ ): Promise<TrailContext> => {
52
+ const base = options?.createContext
53
+ ? await options.createContext()
54
+ : createTrailContext();
55
+ const withOverrides = options?.ctx
56
+ ? {
57
+ ...base,
58
+ ...options.ctx,
59
+ extensions: { ...base.extensions, ...options.ctx.extensions },
60
+ }
61
+ : base;
62
+ return options?.signal
63
+ ? { ...withOverrides, signal: options.signal }
64
+ : withOverrides;
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Pipeline
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Execute a trail through the standard validate-context-layers-run pipeline.
73
+ *
74
+ * The function never throws -- unexpected exceptions are caught and
75
+ * returned as `Result.err(InternalError)`.
76
+ */
77
+ export const executeTrail = async (
78
+ trail: AnyTrail,
79
+ rawInput: unknown,
80
+ options?: ExecuteTrailOptions
81
+ ): Promise<Result<unknown, Error>> => {
82
+ try {
83
+ const validated = validateInput(trail.input, rawInput);
84
+ if (validated.isErr()) {
85
+ return validated;
86
+ }
87
+
88
+ const ctx = await resolveContext(options);
89
+ const layers = options?.layers ?? [];
90
+ const impl = composeLayers([...layers], trail, trail.run);
91
+ return await impl(validated.value, ctx);
92
+ } catch (error: unknown) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ return Result.err(new InternalError(message));
95
+ }
96
+ };
package/src/index.ts CHANGED
@@ -34,7 +34,6 @@ export type {
34
34
  ProgressCallback,
35
35
  ProgressEvent,
36
36
  Logger,
37
- Surface,
38
37
  } from './types.js';
39
38
 
40
39
  // Context factory
@@ -42,11 +41,17 @@ export { createTrailContext } from './context.js';
42
41
 
43
42
  // Trail
44
43
  export { trail } from './trail.js';
45
- export type { AnyTrail, Trail, TrailSpec, TrailExample } from './trail.js';
44
+ export type {
45
+ AnyTrail,
46
+ Intent,
47
+ Trail,
48
+ TrailSpec,
49
+ TrailExample,
50
+ } from './trail.js';
46
51
 
47
- // Hike
48
- export { hike } from './hike.js';
49
- export type { AnyHike, Hike, HikeSpec } from './hike.js';
52
+ // Type utilities
53
+ export type { TrailInput, TrailOutput, TrailResult } from './type-utils.js';
54
+ export { inputOf, outputOf } from './type-utils.js';
50
55
 
51
56
  // Event
52
57
  export { event } from './event.js';
@@ -64,23 +69,18 @@ export type { TopoIssue } from './validate-topo.js';
64
69
  export { composeLayers } from './layer.js';
65
70
  export type { Layer } from './layer.js';
66
71
 
67
- // Health
68
- export type { HealthStatus, HealthResult } from './health.js';
69
-
70
- // Adapters
71
- export type {
72
- IndexAdapter,
73
- StorageAdapter,
74
- CacheAdapter,
75
- SearchOptions,
76
- SearchResult,
77
- StorageOptions,
78
- } from './adapters.js';
79
-
80
72
  // Derive
81
73
  export { deriveFields } from './derive.js';
82
74
  export type { Field, FieldOverride } from './derive.js';
83
75
 
76
+ // Execute
77
+ export { executeTrail } from './execute.js';
78
+ export type { ExecuteTrailOptions } from './execute.js';
79
+
80
+ // Dispatch
81
+ export { dispatch } from './dispatch.js';
82
+ export type { DispatchOptions } from './dispatch.js';
83
+
84
84
  // Validation
85
85
  export {
86
86
  validateInput,
@@ -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
@@ -17,7 +17,7 @@ class Ok<T, E> {
17
17
  }
18
18
 
19
19
  // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
20
- isErr(): this is Err<T, E> {
20
+ isErr(): this is Err<E> {
21
21
  return false;
22
22
  }
23
23
 
@@ -47,7 +47,7 @@ class Ok<T, E> {
47
47
  }
48
48
 
49
49
  // oxlint-disable-next-line max-classes-per-file -- Result monad requires paired Ok/Err classes
50
- class Err<T, E> {
50
+ class Err<E> {
51
51
  readonly error: E;
52
52
 
53
53
  constructor(error: E) {
@@ -55,28 +55,28 @@ class Err<T, E> {
55
55
  }
56
56
 
57
57
  // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
58
- isOk(): this is Ok<T, E> {
58
+ isOk(): this is Ok<never, E> {
59
59
  return false;
60
60
  }
61
61
 
62
62
  // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
63
- isErr(): this is Err<T, E> {
63
+ isErr(): this is Err<E> {
64
64
  return true;
65
65
  }
66
66
 
67
- map<U>(_fn: (value: T) => U): Result<U, E> {
67
+ map<U>(_fn: (value: never) => U): Result<U, E> {
68
68
  return new Err(this.error);
69
69
  }
70
70
 
71
- flatMap<U, F = E>(_fn: (value: T) => Result<U, F>): Result<U, E | F> {
71
+ flatMap<U, F = E>(_fn: (value: never) => Result<U, F>): Result<U, E | F> {
72
72
  return new Err(this.error);
73
73
  }
74
74
 
75
- mapErr<F>(fn: (error: E) => F): Result<T, F> {
75
+ mapErr<F>(fn: (error: E) => F): Result<never, F> {
76
76
  return new Err(fn(this.error));
77
77
  }
78
78
 
79
- match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
79
+ match<U>(handlers: { ok: (value: never) => U; err: (error: E) => U }): U {
80
80
  return handlers.err(this.error);
81
81
  }
82
82
 
@@ -87,12 +87,12 @@ class Err<T, E> {
87
87
  }
88
88
 
89
89
  // oxlint-disable-next-line class-methods-use-this -- symmetric API with Ok.unwrapOr
90
- unwrapOr(fallback: T): T {
90
+ unwrapOr<T>(fallback: T): T {
91
91
  return fallback;
92
92
  }
93
93
  }
94
94
 
95
- export type Result<T, E = Error> = Ok<T, E> | Err<T, E>;
95
+ export type Result<T, E = Error> = Ok<T, E> | Err<E>;
96
96
 
97
97
  // eslint-disable-next-line @typescript-eslint/no-namespace
98
98
  export const Result = {
@@ -107,7 +107,7 @@ export const Result = {
107
107
  return new Ok(values);
108
108
  },
109
109
 
110
- err<E>(error: E): Result<never, E> {
110
+ err<E>(error: E): Err<E> {
111
111
  return new Err(error);
112
112
  },
113
113
 
@@ -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
  });