@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +109 -0
- package/README.md +101 -131
- package/dist/derive.d.ts +1 -1
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +4 -1
- package/dist/derive.js.map +1 -1
- package/dist/dispatch.d.ts +27 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +34 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/event.d.ts +2 -2
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +1 -1
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +30 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +64 -0
- package/dist/execute.js.map +1 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/patterns/status.d.ts +1 -1
- package/dist/result.d.ts +11 -11
- package/dist/result.d.ts.map +1 -1
- package/dist/result.js +15 -4
- package/dist/result.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +45 -7
- package/dist/serialization.js.map +1 -1
- package/dist/topo.d.ts +4 -4
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +12 -16
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +16 -10
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +4 -2
- package/dist/trail.js.map +1 -1
- package/dist/type-utils.d.ts +24 -0
- package/dist/type-utils.d.ts.map +1 -0
- package/dist/type-utils.js +12 -0
- package/dist/type-utils.js.map +1 -0
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-topo.d.ts +2 -2
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +59 -9
- package/dist/validate-topo.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/context.test.ts +4 -5
- package/src/__tests__/derive.test.ts +44 -0
- package/src/__tests__/dispatch.test.ts +154 -0
- package/src/__tests__/event.test.ts +5 -5
- package/src/__tests__/execute.test.ts +208 -0
- package/src/__tests__/layer.test.ts +11 -111
- package/src/__tests__/serialization.test.ts +166 -1
- package/src/__tests__/topo.test.ts +101 -79
- package/src/__tests__/trail.test.ts +73 -35
- package/src/__tests__/type-utils.test.ts +90 -0
- package/src/__tests__/validate-topo.test.ts +97 -20
- package/src/derive.ts +12 -2
- package/src/dispatch.ts +54 -0
- package/src/event.ts +3 -3
- package/src/execute.ts +96 -0
- package/src/index.ts +22 -18
- package/src/result.ts +29 -15
- package/src/serialization.ts +56 -11
- package/src/topo.ts +18 -23
- package/src/trail.ts +24 -13
- package/src/type-utils.ts +45 -0
- package/src/types.ts +1 -3
- package/src/validate-topo.ts +70 -10
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -36
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -20
- package/dist/hike.js.map +0 -1
- package/src/__tests__/hike.test.ts +0 -117
- package/src/__tests__/job.test.ts +0 -98
- package/src/adapters.ts +0 -68
- package/src/health.ts +0 -23
- package/src/hike.ts +0 -77
- 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 --
|
|
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
|
-
|
|
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
|
|
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.
|
|
110
|
+
capturedDomain = t.metadata?.['domain'];
|
|
132
111
|
return impl;
|
|
133
112
|
},
|
|
134
113
|
};
|
|
135
114
|
|
|
136
|
-
composeLayers([inspectLayer], echoTrail, echoTrail.
|
|
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.
|
|
143
|
-
expect(wrapped).toBe(echoTrail.
|
|
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 {
|
|
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
|
});
|