@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
@@ -0,0 +1,208 @@
1
+ /* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
2
+ import { describe, test, expect } from 'bun:test';
3
+
4
+ import { z } from 'zod';
5
+
6
+ import { InternalError, ValidationError } from '../errors';
7
+ import { executeTrail } from '../execute';
8
+ import { createTrailContext } from '../context';
9
+ import type { Layer } from '../layer';
10
+ import { Result } from '../result';
11
+ import { trail } from '../trail';
12
+ import type { TrailContext } from '../types';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Fixtures
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const echoTrail = trail('echo', {
19
+ input: z.object({ value: z.string() }),
20
+ output: z.object({ value: z.string() }),
21
+ run: (input) => Result.ok({ value: input.value }),
22
+ });
23
+
24
+ const failingTrail = trail('fails', {
25
+ input: z.object({}),
26
+ run: () => Result.err(new ValidationError('bad input')),
27
+ });
28
+
29
+ const throwingTrail = trail('throws', {
30
+ input: z.object({}),
31
+ run: () => {
32
+ throw new Error('kaboom');
33
+ },
34
+ });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Tests
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe('executeTrail', () => {
41
+ describe('happy path', () => {
42
+ test('validates input and executes trail', async () => {
43
+ const result = await executeTrail(echoTrail, { value: 'hello' });
44
+
45
+ expect(result.isOk()).toBe(true);
46
+ expect(result.unwrap()).toEqual({ value: 'hello' });
47
+ });
48
+ });
49
+
50
+ describe('validation', () => {
51
+ test('returns validation error for invalid input', async () => {
52
+ const result = await executeTrail(echoTrail, { value: 42 });
53
+
54
+ expect(result.isErr()).toBe(true);
55
+ expect(result.error).toBeInstanceOf(ValidationError);
56
+ });
57
+ });
58
+
59
+ describe('layers', () => {
60
+ test('composes layers around execution', async () => {
61
+ const log: string[] = [];
62
+ const layer: Layer = {
63
+ name: 'test-layer',
64
+ wrap(_trail, impl) {
65
+ return async (input, ctx) => {
66
+ log.push('before');
67
+ const r = await impl(input, ctx);
68
+ log.push('after');
69
+ return r;
70
+ };
71
+ },
72
+ };
73
+
74
+ const result = await executeTrail(
75
+ echoTrail,
76
+ { value: 'x' },
77
+ { layers: [layer] }
78
+ );
79
+
80
+ expect(result.isOk()).toBe(true);
81
+ expect(log).toEqual(['before', 'after']);
82
+ });
83
+ });
84
+
85
+ describe('context', () => {
86
+ test('accepts context overrides', async () => {
87
+ let capturedCtx: TrailContext | undefined;
88
+ const ctxTrail = trail('ctx-test', {
89
+ input: z.object({}),
90
+ run: (_input, ctx) => {
91
+ capturedCtx = ctx;
92
+ return Result.ok(null);
93
+ },
94
+ });
95
+
96
+ await executeTrail(ctxTrail, {}, { ctx: { requestId: 'override-id' } });
97
+
98
+ expect(capturedCtx?.requestId).toBe('override-id');
99
+ });
100
+
101
+ test('accepts signal override', async () => {
102
+ let capturedSignal: AbortSignal | undefined;
103
+ const sigTrail = trail('sig-test', {
104
+ input: z.object({}),
105
+ run: (_input, ctx) => {
106
+ capturedSignal = ctx.signal;
107
+ return Result.ok(null);
108
+ },
109
+ });
110
+
111
+ const signal = AbortSignal.timeout(9999);
112
+ await executeTrail(sigTrail, {}, { signal });
113
+
114
+ expect(capturedSignal).toBe(signal);
115
+ });
116
+
117
+ test('accepts context factory', async () => {
118
+ let capturedCtx: TrailContext | undefined;
119
+ const ctxTrail = trail('factory-test', {
120
+ input: z.object({}),
121
+ run: (_input, ctx) => {
122
+ capturedCtx = ctx;
123
+ return Result.ok(null);
124
+ },
125
+ });
126
+
127
+ const customCtx: TrailContext = {
128
+ cwd: '/custom',
129
+ requestId: 'factory-id',
130
+ signal: new AbortController().signal,
131
+ };
132
+
133
+ await executeTrail(ctxTrail, {}, { createContext: () => customCtx });
134
+
135
+ expect(capturedCtx?.requestId).toBe('factory-id');
136
+ expect(capturedCtx?.cwd).toBe('/custom');
137
+ });
138
+
139
+ test('context factory + ctx overrides merge correctly', async () => {
140
+ let capturedCtx: TrailContext | undefined;
141
+ const ctxTrail = trail('merge-test', {
142
+ input: z.object({}),
143
+ run: (_input, ctx) => {
144
+ capturedCtx = ctx;
145
+ return Result.ok(null);
146
+ },
147
+ });
148
+
149
+ const baseCtx: TrailContext = {
150
+ cwd: '/factory',
151
+ requestId: 'factory-id',
152
+ signal: new AbortController().signal,
153
+ };
154
+
155
+ await executeTrail(
156
+ ctxTrail,
157
+ {},
158
+ {
159
+ createContext: () => baseCtx,
160
+ ctx: { requestId: 'overridden-id' },
161
+ }
162
+ );
163
+
164
+ expect(capturedCtx?.requestId).toBe('overridden-id');
165
+ expect(capturedCtx?.cwd).toBe('/factory');
166
+ });
167
+
168
+ test('deep-merges extensions from factory and overrides', async () => {
169
+ let captured: TrailContext | undefined;
170
+ const t = trail('ext.test', {
171
+ input: z.object({}),
172
+ output: z.object({}),
173
+ run: (_input, ctx) => {
174
+ captured = ctx;
175
+ return Result.ok({});
176
+ },
177
+ });
178
+ await executeTrail(
179
+ t,
180
+ {},
181
+ {
182
+ createContext: () =>
183
+ createTrailContext({ extensions: { store: 'db' } }),
184
+ ctx: { extensions: { userId: '123' } },
185
+ }
186
+ );
187
+ expect(captured?.extensions).toEqual({ store: 'db', userId: '123' });
188
+ });
189
+ });
190
+
191
+ describe('error handling', () => {
192
+ test('propagates Result.err from run function', async () => {
193
+ const result = await executeTrail(failingTrail, {});
194
+
195
+ expect(result.isErr()).toBe(true);
196
+ expect(result.error).toBeInstanceOf(ValidationError);
197
+ expect(result.error.message).toBe('bad input');
198
+ });
199
+
200
+ test('catches thrown exceptions and returns InternalError', async () => {
201
+ const result = await executeTrail(throwingTrail, {});
202
+
203
+ expect(result.isErr()).toBe(true);
204
+ expect(result.error).toBeInstanceOf(InternalError);
205
+ expect(result.error.message).toBe('kaboom');
206
+ });
207
+ });
208
+ });
@@ -1,17 +1,8 @@
1
- /* oxlint-disable require-await -- adapter mocks and layer wrappers satisfy async interfaces without awaiting */
1
+ /* oxlint-disable require-await -- layer wrappers satisfy async interfaces without awaiting */
2
2
  import { describe, test, expect } from 'bun:test';
3
3
 
4
4
  import { z } from 'zod';
5
5
 
6
- import type {
7
- IndexAdapter,
8
- StorageAdapter,
9
- CacheAdapter,
10
- SearchOptions,
11
- SearchResult,
12
- StorageOptions,
13
- } from '../adapters';
14
- import type { HealthStatus, HealthResult, HealthCheck } from '../health';
15
6
  import { composeLayers } from '../layer';
16
7
  import type { Layer } from '../layer';
17
8
  import { Result } from '../result';
@@ -24,10 +15,10 @@ const stubCtx: TrailContext = {
24
15
  };
25
16
 
26
17
  const echoTrail = trail('echo', {
27
- implementation: (input) => Result.ok({ value: input.value }),
28
18
  input: z.object({ value: z.string() }),
29
- markers: { domain: 'test' },
19
+ metadata: { domain: 'test' },
30
20
  output: z.object({ value: z.string() }),
21
+ run: (input) => Result.ok({ value: input.value }),
31
22
  });
32
23
 
33
24
  // ---------------------------------------------------------------------------
@@ -49,11 +40,7 @@ describe('Layer', () => {
49
40
  },
50
41
  };
51
42
 
52
- const wrapped = composeLayers(
53
- [prefixLayer],
54
- echoTrail,
55
- echoTrail.implementation
56
- );
43
+ const wrapped = composeLayers([prefixLayer], echoTrail, echoTrail.run);
57
44
  const result = await wrapped({ value: 'hello' }, stubCtx);
58
45
 
59
46
  expect(result.isOk()).toBe(true);
@@ -87,11 +74,7 @@ describe('Layer', () => {
87
74
  },
88
75
  };
89
76
 
90
- const wrapped = composeLayers(
91
- [outer, inner],
92
- echoTrail,
93
- echoTrail.implementation
94
- );
77
+ const wrapped = composeLayers([outer, inner], echoTrail, echoTrail.run);
95
78
  await wrapped({ value: 'x' }, stubCtx);
96
79
 
97
80
  expect(log).toEqual([
@@ -110,11 +93,7 @@ describe('Layer', () => {
110
93
  },
111
94
  };
112
95
 
113
- const wrapped = composeLayers(
114
- [shortCircuit],
115
- echoTrail,
116
- echoTrail.implementation
117
- );
96
+ const wrapped = composeLayers([shortCircuit], echoTrail, echoTrail.run);
118
97
  const result = await wrapped({ value: 'hello' }, stubCtx);
119
98
 
120
99
  expect(result.isErr()).toBe(true);
@@ -122,103 +101,24 @@ describe('Layer', () => {
122
101
  expect(err.error.message).toBe('blocked');
123
102
  });
124
103
 
125
- test('layer can inspect trail markers', () => {
104
+ test('layer can inspect trail metadata', () => {
126
105
  let capturedDomain: unknown;
127
106
 
128
107
  const inspectLayer: Layer = {
129
108
  name: 'inspect',
130
109
  wrap(t, impl) {
131
- capturedDomain = t.markers?.['domain'];
110
+ capturedDomain = t.metadata?.['domain'];
132
111
  return impl;
133
112
  },
134
113
  };
135
114
 
136
- composeLayers([inspectLayer], echoTrail, echoTrail.implementation);
115
+ composeLayers([inspectLayer], echoTrail, echoTrail.run);
137
116
 
138
117
  expect(capturedDomain).toBe('test');
139
118
  });
140
119
 
141
120
  test('empty layers array returns implementation unchanged', () => {
142
- const wrapped = composeLayers([], echoTrail, echoTrail.implementation);
143
- expect(wrapped).toBe(echoTrail.implementation);
144
- });
145
- });
146
-
147
- // ---------------------------------------------------------------------------
148
- // Health types (compile-time verification)
149
- // ---------------------------------------------------------------------------
150
-
151
- describe('health types', () => {
152
- test('HealthResult satisfies the interface', () => {
153
- const check: HealthCheck = {
154
- latency: 12,
155
- message: 'ok',
156
- status: 'healthy',
157
- };
158
- const result: HealthResult = {
159
- checks: { db: check },
160
- status: 'healthy',
161
- uptime: 3600,
162
- version: '1.0.0',
163
- };
164
-
165
- expect(result.status).toBe('healthy');
166
- expect(result.checks['db']?.status).toBe('healthy');
167
- });
168
-
169
- test('HealthStatus union is exhaustive', () => {
170
- const statuses: HealthStatus[] = ['healthy', 'degraded', 'unhealthy'];
171
- expect(statuses).toHaveLength(3);
172
- });
173
- });
174
-
175
- // ---------------------------------------------------------------------------
176
- // Adapter types (compile-time verification)
177
- // ---------------------------------------------------------------------------
178
-
179
- describe('adapter types', () => {
180
- test('IndexAdapter mock satisfies the interface', () => {
181
- const mock: IndexAdapter = {
182
- index: async () => Result.ok(),
183
- remove: async () => Result.ok(),
184
- search: async () => Result.ok([]),
185
- };
186
- expect(mock.index).toBeDefined();
187
- expect(mock.search).toBeDefined();
188
- expect(mock.remove).toBeDefined();
189
- });
190
-
191
- test('StorageAdapter mock satisfies the interface', () => {
192
- const mock: StorageAdapter = {
193
- delete: async () => Result.ok(),
194
- get: async () => Result.ok('value'),
195
- has: async () => Result.ok(true),
196
- set: async () => Result.ok(),
197
- };
198
- expect(mock.has).toBeDefined();
199
- });
200
-
201
- test('CacheAdapter mock satisfies the interface', () => {
202
- const mock: CacheAdapter = {
203
- clear: async () => Result.ok(),
204
- delete: async () => Result.ok(),
205
- get: async () => Result.ok(),
206
- set: async () => Result.ok(),
207
- };
208
- expect(mock.clear).toBeDefined();
209
- });
210
-
211
- test('SearchOptions and SearchResult satisfy their shapes', () => {
212
- const opts: SearchOptions = { filters: { tag: 'a' }, limit: 10, offset: 0 };
213
- const hit: SearchResult = {
214
- document: { title: 'x' },
215
- id: '1',
216
- score: 0.95,
217
- };
218
- const sopts: StorageOptions = { ttl: 5000 };
219
-
220
- expect(opts.limit).toBe(10);
221
- expect(hit.score).toBe(0.95);
222
- expect(sopts.ttl).toBe(5000);
121
+ const wrapped = composeLayers([], echoTrail, echoTrail.run);
122
+ expect(wrapped).toBe(echoTrail.run);
223
123
  });
224
124
  });
@@ -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
  });