@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
package/src/execute.ts ADDED
@@ -0,0 +1,345 @@
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 {
12
+ AnyService,
13
+ ServiceContext,
14
+ ServiceOverrideMap,
15
+ } from './service.js';
16
+ import type { TrailContext, TrailContextInit } from './types.js';
17
+
18
+ import { composeLayers } from './layer.js';
19
+ import { createTrailContext } from './context.js';
20
+ import { InternalError } from './errors.js';
21
+ import { Result } from './result.js';
22
+ import { createServiceLookup } from './service.js';
23
+ import { validateInput } from './validation.js';
24
+
25
+ type MutableTrailContext = {
26
+ -readonly [K in keyof TrailContext]: TrailContext[K];
27
+ };
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Options
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Options for executeTrail. */
34
+ export interface ExecuteTrailOptions {
35
+ /** Partial context overrides merged on top of the base context. */
36
+ readonly ctx?: Partial<TrailContextInit> | undefined;
37
+ /** AbortSignal override (takes final precedence over ctx and factory). */
38
+ readonly signal?: AbortSignal | undefined;
39
+ /** Layers to compose around the implementation. */
40
+ readonly layers?: readonly Layer[] | undefined;
41
+ /** Factory that produces a base TrailContext (takes precedence over defaults). */
42
+ readonly createContext?:
43
+ | (() => TrailContextInit | Promise<TrailContextInit>)
44
+ | undefined;
45
+ /** Explicit service instance overrides keyed by service ID. */
46
+ readonly services?: ServiceOverrideMap | undefined;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Context resolution
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Build a TrailContext from options.
55
+ *
56
+ * Resolution order:
57
+ * 1. Factory (`createContext`) or `createTrailContext()` defaults.
58
+ * 2. Partial `ctx` overrides merged on top.
59
+ * 3. `signal` override takes final precedence.
60
+ */
61
+ const resolveContext = async (
62
+ options?: ExecuteTrailOptions
63
+ ): Promise<TrailContext> => {
64
+ const seed = options?.createContext
65
+ ? await options.createContext()
66
+ : createTrailContext();
67
+ const base = seed.service ? seed : createTrailContext(seed);
68
+ const withOverrides = options?.ctx
69
+ ? {
70
+ ...base,
71
+ ...options.ctx,
72
+ extensions: { ...base.extensions, ...options.ctx.extensions },
73
+ }
74
+ : base;
75
+ const resolved = options?.signal
76
+ ? { ...withOverrides, signal: options.signal }
77
+ : withOverrides;
78
+ if (
79
+ options?.ctx?.extensions !== undefined ||
80
+ resolved.service === undefined
81
+ ) {
82
+ const bound = { ...resolved } as MutableTrailContext;
83
+ bound.service = createServiceLookup(() => bound);
84
+ return bound;
85
+ }
86
+
87
+ return resolved as TrailContext;
88
+ };
89
+
90
+ const singletonServices = new WeakMap<AnyService, Map<string, unknown>>();
91
+
92
+ /** In-flight service creation promises, keyed by service × context. */
93
+ const pendingCreations = new WeakMap<
94
+ AnyService,
95
+ Map<string, Promise<Result<unknown, Error>>>
96
+ >();
97
+
98
+ const hasOwnServiceOverride = (
99
+ overrides: ServiceOverrideMap | undefined,
100
+ id: string
101
+ ): overrides is ServiceOverrideMap =>
102
+ overrides !== undefined && Object.hasOwn(overrides, id);
103
+
104
+ const toServiceContext = (ctx: TrailContext): ServiceContext => ({
105
+ cwd: ctx.cwd,
106
+ env: ctx.env,
107
+ workspaceRoot: ctx.workspaceRoot,
108
+ });
109
+
110
+ const toServiceContextKey = (ctx: ServiceContext): string =>
111
+ JSON.stringify({
112
+ cwd: ctx.cwd,
113
+ env: Object.entries(ctx.env ?? {}).toSorted(([left], [right]) =>
114
+ left.localeCompare(right)
115
+ ),
116
+ workspaceRoot: ctx.workspaceRoot,
117
+ });
118
+
119
+ const toInternalServiceError = (id: string, error: unknown): InternalError => {
120
+ const cause = error instanceof Error ? error : undefined;
121
+ const message = cause?.message ?? String(error);
122
+ return new InternalError(`Service "${id}" failed to resolve: ${message}`, {
123
+ ...(cause ? { cause } : {}),
124
+ context: { serviceId: id },
125
+ });
126
+ };
127
+
128
+ const getCachedSingletonService = (
129
+ declaredService: AnyService,
130
+ serviceContext: ServiceContext
131
+ ): { readonly found: boolean; readonly value: unknown } => {
132
+ const scopedCache = singletonServices.get(declaredService);
133
+ if (scopedCache === undefined) {
134
+ return { found: false, value: undefined };
135
+ }
136
+
137
+ const key = toServiceContextKey(serviceContext);
138
+ if (!scopedCache.has(key)) {
139
+ return { found: false, value: undefined };
140
+ }
141
+
142
+ return {
143
+ found: true,
144
+ value: scopedCache.get(key),
145
+ };
146
+ };
147
+
148
+ const getProvidedService = (
149
+ ctx: TrailContext,
150
+ overrides: ServiceOverrideMap | undefined,
151
+ declaredService: AnyService,
152
+ serviceContext: ServiceContext
153
+ ): Result<unknown, Error> | undefined => {
154
+ const { id } = declaredService;
155
+ if (hasOwnServiceOverride(overrides, id)) {
156
+ return Result.ok(overrides[id]);
157
+ }
158
+
159
+ if (Object.hasOwn(ctx.extensions ?? {}, id)) {
160
+ return Result.ok(ctx.extensions?.[id]);
161
+ }
162
+
163
+ const cached = getCachedSingletonService(declaredService, serviceContext);
164
+ if (cached.found) {
165
+ return Result.ok(cached.value);
166
+ }
167
+
168
+ return undefined;
169
+ };
170
+
171
+ const getSingletonServiceCache = (
172
+ declaredService: AnyService
173
+ ): Map<string, unknown> => {
174
+ const existing = singletonServices.get(declaredService);
175
+ if (existing !== undefined) {
176
+ return existing;
177
+ }
178
+
179
+ const created = new Map<string, unknown>();
180
+ singletonServices.set(declaredService, created);
181
+ return created;
182
+ };
183
+
184
+ const doCreateServiceInstance = async (
185
+ declaredService: AnyService,
186
+ serviceContext: ServiceContext
187
+ ): Promise<Result<unknown, Error>> => {
188
+ try {
189
+ const created = await declaredService.create(serviceContext);
190
+ if (created.isErr()) {
191
+ return Result.err(created.error);
192
+ }
193
+
194
+ const instance = created.unwrap();
195
+ getSingletonServiceCache(declaredService).set(
196
+ toServiceContextKey(serviceContext),
197
+ instance
198
+ );
199
+ return Result.ok(instance);
200
+ } catch (error: unknown) {
201
+ return Result.err(toInternalServiceError(declaredService.id, error));
202
+ }
203
+ };
204
+
205
+ const trackPendingCreation = (
206
+ declaredService: AnyService,
207
+ key: string,
208
+ promise: Promise<Result<unknown, Error>>
209
+ ): void => {
210
+ const pending = pendingCreations.get(declaredService);
211
+ if (pending) {
212
+ pending.set(key, promise);
213
+ } else {
214
+ pendingCreations.set(declaredService, new Map([[key, promise]]));
215
+ }
216
+ };
217
+
218
+ /**
219
+ * Deduplicates concurrent creation of the same service singleton.
220
+ * If a creation is already in flight for this service × context key,
221
+ * returns the existing promise instead of spawning a second factory call.
222
+ */
223
+ const createServiceInstance = async (
224
+ declaredService: AnyService,
225
+ serviceContext: ServiceContext
226
+ ): Promise<Result<unknown, Error>> => {
227
+ const key = toServiceContextKey(serviceContext);
228
+ const inflight = pendingCreations.get(declaredService)?.get(key);
229
+ if (inflight) {
230
+ return inflight;
231
+ }
232
+
233
+ const promise = doCreateServiceInstance(declaredService, serviceContext);
234
+ trackPendingCreation(declaredService, key, promise);
235
+
236
+ try {
237
+ return await promise;
238
+ } finally {
239
+ pendingCreations.get(declaredService)?.delete(key);
240
+ }
241
+ };
242
+
243
+ const resolveServiceInstance = async (
244
+ declaredService: AnyService,
245
+ ctx: TrailContext,
246
+ serviceContext: ServiceContext,
247
+ overrides?: ServiceOverrideMap
248
+ ): Promise<Result<unknown, Error>> =>
249
+ getProvidedService(ctx, overrides, declaredService, serviceContext) ??
250
+ (await createServiceInstance(declaredService, serviceContext));
251
+
252
+ const withResolvedServices = (
253
+ ctx: TrailContext,
254
+ resolvedServices: Record<string, unknown>
255
+ ): TrailContext => {
256
+ const extensions = { ...ctx.extensions, ...resolvedServices };
257
+ const resolvedCtx = { ...ctx, extensions } as MutableTrailContext;
258
+ resolvedCtx.service = createServiceLookup(() => resolvedCtx);
259
+ return resolvedCtx;
260
+ };
261
+
262
+ const resolveServices = async (
263
+ trail: AnyTrail,
264
+ ctx: TrailContext,
265
+ overrides?: ServiceOverrideMap
266
+ ): Promise<Result<TrailContext, Error>> => {
267
+ if (trail.services.length === 0) {
268
+ return Result.ok(ctx);
269
+ }
270
+
271
+ const resolvedServices: Record<string, unknown> = {};
272
+ const serviceContext = toServiceContext(ctx);
273
+
274
+ for (const declaredService of trail.services) {
275
+ const resolved = await resolveServiceInstance(
276
+ declaredService,
277
+ ctx,
278
+ serviceContext,
279
+ overrides
280
+ );
281
+ if (resolved.isErr()) {
282
+ return resolved;
283
+ }
284
+
285
+ resolvedServices[declaredService.id] = resolved.unwrap();
286
+ }
287
+
288
+ return Result.ok(withResolvedServices(ctx, resolvedServices));
289
+ };
290
+
291
+ const prepareContext = async (
292
+ trail: AnyTrail,
293
+ options?: ExecuteTrailOptions
294
+ ): Promise<Result<TrailContext, Error>> => {
295
+ const baseCtx = await resolveContext(options);
296
+ return await resolveServices(trail, baseCtx, options?.services);
297
+ };
298
+
299
+ const runTrail = async (
300
+ trail: AnyTrail,
301
+ input: unknown,
302
+ ctx: TrailContext,
303
+ layers: readonly Layer[]
304
+ ): Promise<Result<unknown, Error>> => {
305
+ const impl = composeLayers([...layers], trail, trail.run);
306
+ return await impl(input, ctx);
307
+ };
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Pipeline
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Execute a trail through the standard validate-context-layers-run pipeline.
315
+ *
316
+ * The function never throws -- unexpected exceptions are caught and
317
+ * returned as `Result.err(InternalError)`.
318
+ */
319
+ export const executeTrail = async (
320
+ trail: AnyTrail,
321
+ rawInput: unknown,
322
+ options?: ExecuteTrailOptions
323
+ ): Promise<Result<unknown, Error>> => {
324
+ try {
325
+ const validated = validateInput(trail.input, rawInput);
326
+ if (validated.isErr()) {
327
+ return validated;
328
+ }
329
+
330
+ const resolvedCtx = await prepareContext(trail, options);
331
+ if (resolvedCtx.isErr()) {
332
+ return resolvedCtx;
333
+ }
334
+
335
+ return await runTrail(
336
+ trail,
337
+ validated.value,
338
+ resolvedCtx.value,
339
+ options?.layers ?? []
340
+ );
341
+ } catch (error: unknown) {
342
+ const message = error instanceof Error ? error.message : String(error);
343
+ return Result.err(new InternalError(message));
344
+ }
345
+ };
package/src/index.ts CHANGED
@@ -30,23 +30,45 @@ export type { ErrorCategory } from './errors.js';
30
30
  export type {
31
31
  Implementation,
32
32
  TrailContext,
33
+ TrailContextInit,
33
34
  FollowFn,
34
35
  ProgressCallback,
35
36
  ProgressEvent,
36
37
  Logger,
37
- Surface,
38
+ ServiceLookup,
38
39
  } from './types.js';
39
40
 
40
41
  // Context factory
41
42
  export { createTrailContext } from './context.js';
42
43
 
44
+ // Service
45
+ export {
46
+ createServiceLookup,
47
+ findDuplicateServiceId,
48
+ isService,
49
+ service,
50
+ } from './service.js';
51
+ export type {
52
+ AnyService,
53
+ Service,
54
+ ServiceContext,
55
+ ServiceOverrideMap,
56
+ ServiceSpec,
57
+ } from './service.js';
58
+
43
59
  // Trail
44
60
  export { trail } from './trail.js';
45
- export type { AnyTrail, Trail, TrailSpec, TrailExample } from './trail.js';
61
+ export type {
62
+ AnyTrail,
63
+ Intent,
64
+ Trail,
65
+ TrailSpec,
66
+ TrailExample,
67
+ } from './trail.js';
46
68
 
47
- // Hike
48
- export { hike } from './hike.js';
49
- export type { AnyHike, Hike, HikeSpec } from './hike.js';
69
+ // Type utilities
70
+ export type { TrailInput, TrailOutput, TrailResult } from './type-utils.js';
71
+ export { inputOf, outputOf } from './type-utils.js';
50
72
 
51
73
  // Event
52
74
  export { event } from './event.js';
@@ -64,23 +86,18 @@ export type { TopoIssue } from './validate-topo.js';
64
86
  export { composeLayers } from './layer.js';
65
87
  export type { Layer } from './layer.js';
66
88
 
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
89
  // Derive
81
90
  export { deriveFields } from './derive.js';
82
91
  export type { Field, FieldOverride } from './derive.js';
83
92
 
93
+ // Execute
94
+ export { executeTrail } from './execute.js';
95
+ export type { ExecuteTrailOptions } from './execute.js';
96
+
97
+ // Dispatch
98
+ export { dispatch } from './dispatch.js';
99
+ export type { DispatchOptions } from './dispatch.js';
100
+
84
101
  // Validation
85
102
  export {
86
103
  validateInput,
@@ -133,6 +150,10 @@ export {
133
150
  getRelativePath,
134
151
  } from './workspace.js';
135
152
 
153
+ // Blob
154
+ export { createBlobRef, isBlobRef } from './blob-ref.js';
155
+ export type { BlobRef } from './blob-ref.js';
156
+
136
157
  // Guards
137
158
  export {
138
159
  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
  });