@ontrails/core 1.0.0-beta.0
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-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +179 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +2 -0
- package/dist/adapters.js.map +1 -0
- package/dist/blob-ref.d.ts +20 -0
- package/dist/blob-ref.d.ts.map +1 -0
- package/dist/blob-ref.js +22 -0
- package/dist/blob-ref.js.map +1 -0
- package/dist/branded.d.ts +36 -0
- package/dist/branded.d.ts.map +1 -0
- package/dist/branded.js +89 -0
- package/dist/branded.js.map +1 -0
- package/dist/collections.d.ts +31 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +60 -0
- package/dist/collections.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/context.js.map +1 -0
- package/dist/derive.d.ts +33 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +122 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +45 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +17 -0
- package/dist/event.js.map +1 -0
- package/dist/fetch.d.ts +15 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +102 -0
- package/dist/fetch.js.map +1 -0
- package/dist/guards.d.ts +17 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +25 -0
- package/dist/guards.js.map +1 -0
- package/dist/health.d.ts +18 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +5 -0
- package/dist/health.js.map +1 -0
- package/dist/hike.d.ts +36 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +20 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +24 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +17 -0
- package/dist/job.js.map +1 -0
- package/dist/layer.d.ts +17 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +21 -0
- package/dist/layer.js.map +1 -0
- package/dist/path-security.d.ts +28 -0
- package/dist/path-security.d.ts.map +1 -0
- package/dist/path-security.js +63 -0
- package/dist/path-security.js.map +1 -0
- package/dist/patterns/bulk.d.ts +15 -0
- package/dist/patterns/bulk.d.ts.map +1 -0
- package/dist/patterns/bulk.js +14 -0
- package/dist/patterns/bulk.js.map +1 -0
- package/dist/patterns/change.d.ts +10 -0
- package/dist/patterns/change.d.ts.map +1 -0
- package/dist/patterns/change.js +10 -0
- package/dist/patterns/change.js.map +1 -0
- package/dist/patterns/date-range.d.ts +10 -0
- package/dist/patterns/date-range.d.ts.map +1 -0
- package/dist/patterns/date-range.js +10 -0
- package/dist/patterns/date-range.js.map +1 -0
- package/dist/patterns/index.d.ts +9 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/pagination.d.ts +18 -0
- package/dist/patterns/pagination.d.ts.map +1 -0
- package/dist/patterns/pagination.js +18 -0
- package/dist/patterns/pagination.js.map +1 -0
- package/dist/patterns/progress.d.ts +11 -0
- package/dist/patterns/progress.d.ts.map +1 -0
- package/dist/patterns/progress.js +11 -0
- package/dist/patterns/progress.js.map +1 -0
- package/dist/patterns/sorting.d.ts +13 -0
- package/dist/patterns/sorting.d.ts.map +1 -0
- package/dist/patterns/sorting.js +10 -0
- package/dist/patterns/sorting.js.map +1 -0
- package/dist/patterns/status.d.ts +15 -0
- package/dist/patterns/status.d.ts.map +1 -0
- package/dist/patterns/status.js +9 -0
- package/dist/patterns/status.js.map +1 -0
- package/dist/patterns/timestamps.d.ts +10 -0
- package/dist/patterns/timestamps.d.ts.map +1 -0
- package/dist/patterns/timestamps.js +10 -0
- package/dist/patterns/timestamps.js.map +1 -0
- package/dist/redaction/index.d.ts +4 -0
- package/dist/redaction/index.d.ts.map +1 -0
- package/dist/redaction/index.js +3 -0
- package/dist/redaction/index.js.map +1 -0
- package/dist/redaction/patterns.d.ts +9 -0
- package/dist/redaction/patterns.d.ts.map +1 -0
- package/dist/redaction/patterns.js +39 -0
- package/dist/redaction/patterns.js.map +1 -0
- package/dist/redaction/redactor.d.ts +27 -0
- package/dist/redaction/redactor.d.ts.map +1 -0
- package/dist/redaction/redactor.js +89 -0
- package/dist/redaction/redactor.js.map +1 -0
- package/dist/resilience.d.ts +34 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +164 -0
- package/dist/resilience.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +145 -0
- package/dist/result.js.map +1 -0
- package/dist/serialization.d.ts +27 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +115 -0
- package/dist/serialization.js.map +1 -0
- package/dist/topo.d.ts +18 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +74 -0
- package/dist/topo.js.map +1 -0
- package/dist/trail.d.ts +83 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +16 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-topo.d.ts +24 -0
- package/dist/validate-topo.d.ts.map +1 -0
- package/dist/validate-topo.js +108 -0
- package/dist/validate-topo.js.map +1 -0
- package/dist/validation.d.ts +27 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +57 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/blob-ref.test.ts +103 -0
- package/src/__tests__/branded.test.ts +148 -0
- package/src/__tests__/collections.test.ts +126 -0
- package/src/__tests__/context.test.ts +66 -0
- package/src/__tests__/derive.test.ts +159 -0
- package/src/__tests__/errors.test.ts +309 -0
- package/src/__tests__/event.test.ts +82 -0
- package/src/__tests__/fetch.test.ts +217 -0
- package/src/__tests__/guards.test.ts +102 -0
- package/src/__tests__/hike.test.ts +117 -0
- package/src/__tests__/job.test.ts +98 -0
- package/src/__tests__/layer.test.ts +224 -0
- package/src/__tests__/path-security.test.ts +114 -0
- package/src/__tests__/patterns.test.ts +273 -0
- package/src/__tests__/redaction.test.ts +244 -0
- package/src/__tests__/resilience.test.ts +246 -0
- package/src/__tests__/result.test.ts +155 -0
- package/src/__tests__/serialization.test.ts +236 -0
- package/src/__tests__/topo.test.ts +184 -0
- package/src/__tests__/trail.test.ts +179 -0
- package/src/__tests__/validate-topo.test.ts +201 -0
- package/src/__tests__/validation.test.ts +283 -0
- package/src/__tests__/workspace.test.ts +183 -0
- package/src/adapters.ts +68 -0
- package/src/blob-ref.ts +39 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/context.ts +18 -0
- package/src/derive.ts +223 -0
- package/src/errors.ts +196 -0
- package/src/event.ts +77 -0
- package/src/fetch.ts +138 -0
- package/src/guards.ts +37 -0
- package/src/health.ts +23 -0
- package/src/hike.ts +77 -0
- package/src/index.ts +158 -0
- package/src/job.ts +20 -0
- package/src/layer.ts +44 -0
- package/src/path-security.ts +90 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +47 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/result.ts +180 -0
- package/src/serialization.ts +183 -0
- package/src/topo.ts +123 -0
- package/src/trail.ts +130 -0
- package/src/types.ts +58 -0
- package/src/validate-topo.ts +151 -0
- package/src/validation.ts +182 -0
- package/src/workspace.ts +77 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_SENSITIVE_KEYS, createRedactor } from '../redaction/index.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// DEFAULT_PATTERNS
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('DEFAULT_PATTERNS', () => {
|
|
10
|
+
test('matches credit card numbers', () => {
|
|
11
|
+
const input = 'card: 4111-1111-1111-1111 end';
|
|
12
|
+
const redactor = createRedactor();
|
|
13
|
+
expect(redactor.redact(input)).toBe('card: [REDACTED] end');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('matches credit card numbers with spaces', () => {
|
|
17
|
+
const input = 'card: 4111 1111 1111 1111 end';
|
|
18
|
+
const redactor = createRedactor();
|
|
19
|
+
expect(redactor.redact(input)).toBe('card: [REDACTED] end');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('matches SSNs', () => {
|
|
23
|
+
const input = 'ssn is 123-45-6789';
|
|
24
|
+
const redactor = createRedactor();
|
|
25
|
+
expect(redactor.redact(input)).toBe('ssn is [REDACTED]');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('matches bearer tokens', () => {
|
|
29
|
+
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9';
|
|
30
|
+
const redactor = createRedactor();
|
|
31
|
+
expect(redactor.redact(input)).toBe('Authorization: [REDACTED]');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('matches basic auth', () => {
|
|
35
|
+
const input = 'Authorization: Basic dXNlcjpwYXNz';
|
|
36
|
+
const redactor = createRedactor();
|
|
37
|
+
expect(redactor.redact(input)).toBe('Authorization: [REDACTED]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('matches API keys with sk- prefix', () => {
|
|
41
|
+
const input = 'key: sk-abc123def456ghi';
|
|
42
|
+
const redactor = createRedactor();
|
|
43
|
+
expect(redactor.redact(input)).toBe('key: [REDACTED]');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('matches API keys with pk_ prefix', () => {
|
|
47
|
+
const input = 'key: pk_live_abc123def456';
|
|
48
|
+
const redactor = createRedactor();
|
|
49
|
+
expect(redactor.redact(input)).toBe('key: [REDACTED]');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('matches API keys with sk_ prefix', () => {
|
|
53
|
+
const input = 'key: sk_test_abc123def456';
|
|
54
|
+
const redactor = createRedactor();
|
|
55
|
+
expect(redactor.redact(input)).toBe('key: [REDACTED]');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('matches JWT tokens', () => {
|
|
59
|
+
const input =
|
|
60
|
+
'jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123def456';
|
|
61
|
+
const redactor = createRedactor();
|
|
62
|
+
expect(redactor.redact(input)).toBe('jwt: [REDACTED]');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// DEFAULT_SENSITIVE_KEYS
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('DEFAULT_SENSITIVE_KEYS', () => {
|
|
71
|
+
test('includes expected keys', () => {
|
|
72
|
+
const expected = [
|
|
73
|
+
'password',
|
|
74
|
+
'secret',
|
|
75
|
+
'token',
|
|
76
|
+
'apiKey',
|
|
77
|
+
'api_key',
|
|
78
|
+
'authorization',
|
|
79
|
+
'cookie',
|
|
80
|
+
'ssn',
|
|
81
|
+
'creditCard',
|
|
82
|
+
'credit_card',
|
|
83
|
+
];
|
|
84
|
+
for (const key of expected) {
|
|
85
|
+
expect(DEFAULT_SENSITIVE_KEYS).toContain(key);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// createRedactor().redact()
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('createRedactor().redact()', () => {
|
|
95
|
+
test('replaces all matching patterns in a string', () => {
|
|
96
|
+
const redactor = createRedactor();
|
|
97
|
+
const input = 'cc: 4111-1111-1111-1111 ssn: 123-45-6789';
|
|
98
|
+
const result = redactor.redact(input);
|
|
99
|
+
expect(result).toBe('cc: [REDACTED] ssn: [REDACTED]');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('returns string unchanged when no patterns match', () => {
|
|
103
|
+
const redactor = createRedactor();
|
|
104
|
+
const input = 'just a normal string';
|
|
105
|
+
expect(redactor.redact(input)).toBe('just a normal string');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('works correctly when called multiple times (regex lastIndex reset)', () => {
|
|
109
|
+
const redactor = createRedactor();
|
|
110
|
+
const input = 'key: sk-abc123def456ghi';
|
|
111
|
+
expect(redactor.redact(input)).toBe('key: [REDACTED]');
|
|
112
|
+
expect(redactor.redact(input)).toBe('key: [REDACTED]');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// createRedactor().redactObject()
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe('createRedactor().redactObject()', () => {
|
|
121
|
+
test('redacts values for sensitive keys', () => {
|
|
122
|
+
const redactor = createRedactor();
|
|
123
|
+
const obj = { password: 's3cret', username: 'alice' };
|
|
124
|
+
const result = redactor.redactObject(obj);
|
|
125
|
+
expect(result.password).toBe('[REDACTED]');
|
|
126
|
+
expect(result.username).toBe('alice');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('matches sensitive keys case-insensitively', () => {
|
|
130
|
+
const redactor = createRedactor();
|
|
131
|
+
const obj = { Authorization: 'Bearer xyz', PASSWORD: 's3cret' };
|
|
132
|
+
const result = redactor.redactObject(obj);
|
|
133
|
+
expect(result.PASSWORD).toBe('[REDACTED]');
|
|
134
|
+
expect(result.Authorization).toBe('[REDACTED]');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('applies pattern matching to non-sensitive string values', () => {
|
|
138
|
+
const redactor = createRedactor();
|
|
139
|
+
const obj = { message: 'card 4111-1111-1111-1111 found' };
|
|
140
|
+
const result = redactor.redactObject(obj);
|
|
141
|
+
expect(result.message).toBe('card [REDACTED] found');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('does not modify the original object', () => {
|
|
145
|
+
const redactor = createRedactor();
|
|
146
|
+
const obj = { password: 's3cret' };
|
|
147
|
+
redactor.redactObject(obj);
|
|
148
|
+
expect(obj.password).toBe('s3cret');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('preserves non-string values', () => {
|
|
152
|
+
const redactor = createRedactor();
|
|
153
|
+
const obj = { count: 42, enabled: true, tags: null as unknown };
|
|
154
|
+
const result = redactor.redactObject(obj);
|
|
155
|
+
expect(result.count).toBe(42);
|
|
156
|
+
expect(result.enabled).toBe(true);
|
|
157
|
+
expect(result.tags).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Nested objects
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('nested objects', () => {
|
|
166
|
+
test('redacts deeply nested sensitive keys', () => {
|
|
167
|
+
const redactor = createRedactor();
|
|
168
|
+
const obj = {
|
|
169
|
+
user: {
|
|
170
|
+
credentials: {
|
|
171
|
+
password: 's3cret',
|
|
172
|
+
token: 'tok_abc123',
|
|
173
|
+
},
|
|
174
|
+
name: 'alice',
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const result = redactor.redactObject(obj);
|
|
178
|
+
expect(result.user.name).toBe('alice');
|
|
179
|
+
expect(result.user.credentials.password).toBe('[REDACTED]');
|
|
180
|
+
expect(result.user.credentials.token).toBe('[REDACTED]');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('redacts values inside arrays', () => {
|
|
184
|
+
const redactor = createRedactor();
|
|
185
|
+
const obj = {
|
|
186
|
+
headers: [
|
|
187
|
+
{ key: 'Authorization', value: 'Bearer abc123' },
|
|
188
|
+
{ key: 'Content-Type', value: 'application/json' },
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
const result = redactor.redactObject(obj);
|
|
192
|
+
expect(result.headers).toHaveLength(2);
|
|
193
|
+
expect(result.headers[0]?.value).toBe('[REDACTED]');
|
|
194
|
+
expect(result.headers[1]?.value).toBe('application/json');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('handles deeply nested pattern matches', () => {
|
|
198
|
+
const redactor = createRedactor();
|
|
199
|
+
const obj = {
|
|
200
|
+
data: {
|
|
201
|
+
nested: {
|
|
202
|
+
info: 'ssn: 123-45-6789',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const result = redactor.redactObject(obj);
|
|
207
|
+
expect(result.data.nested.info).toBe('ssn: [REDACTED]');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Custom config
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
describe('custom config', () => {
|
|
216
|
+
test('custom patterns work', () => {
|
|
217
|
+
const redactor = createRedactor({
|
|
218
|
+
patterns: [/secret-\w+/g],
|
|
219
|
+
});
|
|
220
|
+
expect(redactor.redact('found secret-banana here')).toBe(
|
|
221
|
+
'found [REDACTED] here'
|
|
222
|
+
);
|
|
223
|
+
// Default patterns should not apply
|
|
224
|
+
expect(redactor.redact('4111-1111-1111-1111')).toBe('4111-1111-1111-1111');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('custom replacement string works', () => {
|
|
228
|
+
const redactor = createRedactor({ replacement: '***' });
|
|
229
|
+
const obj = { password: 's3cret' };
|
|
230
|
+
const result = redactor.redactObject(obj);
|
|
231
|
+
expect(result.password).toBe('***');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('custom sensitive keys work', () => {
|
|
235
|
+
const redactor = createRedactor({
|
|
236
|
+
sensitiveKeys: ['myCustomKey'],
|
|
237
|
+
});
|
|
238
|
+
const obj = { myCustomKey: 'hidden', password: 'visible' };
|
|
239
|
+
const result = redactor.redactObject(obj);
|
|
240
|
+
expect(result.myCustomKey).toBe('[REDACTED]');
|
|
241
|
+
// "password" is not in custom keys, so not redacted by key match
|
|
242
|
+
expect(result.password).toBe('visible');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NetworkError,
|
|
5
|
+
ValidationError,
|
|
6
|
+
TimeoutError,
|
|
7
|
+
CancelledError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
} from '../errors.js';
|
|
10
|
+
import {
|
|
11
|
+
retry,
|
|
12
|
+
withTimeout,
|
|
13
|
+
shouldRetry,
|
|
14
|
+
getBackoffDelay,
|
|
15
|
+
} from '../resilience.js';
|
|
16
|
+
import { Result } from '../result.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// shouldRetry
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
describe('shouldRetry', () => {
|
|
23
|
+
test('returns true for retryable TrailsErrors', () => {
|
|
24
|
+
expect(shouldRetry(new NetworkError('down'))).toBe(true);
|
|
25
|
+
expect(shouldRetry(new TimeoutError('slow'))).toBe(true);
|
|
26
|
+
expect(shouldRetry(new RateLimitError('throttled'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns false for non-retryable TrailsErrors', () => {
|
|
30
|
+
expect(shouldRetry(new ValidationError('bad'))).toBe(false);
|
|
31
|
+
expect(shouldRetry(new CancelledError('stop'))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('returns false for plain Error', () => {
|
|
35
|
+
expect(shouldRetry(new Error('generic'))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// getBackoffDelay
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe('getBackoffDelay', () => {
|
|
44
|
+
test('returns a number >= 0', () => {
|
|
45
|
+
for (let i = 0; i < 20; i += 1) {
|
|
46
|
+
const delay = getBackoffDelay(0);
|
|
47
|
+
expect(delay).toBeGreaterThanOrEqual(0);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('respects maxDelay cap', () => {
|
|
52
|
+
for (let i = 0; i < 20; i += 1) {
|
|
53
|
+
const delay = getBackoffDelay(10, { maxDelay: 100 });
|
|
54
|
+
expect(delay).toBeLessThanOrEqual(100);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('uses provided baseDelay and backoffFactor', () => {
|
|
59
|
+
// With attempt=0, exponential = base * factor^0 = base
|
|
60
|
+
// Jitter is [0, base], so delay <= base
|
|
61
|
+
for (let i = 0; i < 20; i += 1) {
|
|
62
|
+
const delay = getBackoffDelay(0, { backoffFactor: 3, baseDelay: 500 });
|
|
63
|
+
expect(delay).toBeLessThanOrEqual(500);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('scales with attempt number', () => {
|
|
68
|
+
// Higher attempts should have higher caps (on average)
|
|
69
|
+
const samples0 = Array.from({ length: 100 }, () =>
|
|
70
|
+
getBackoffDelay(0, { baseDelay: 100 })
|
|
71
|
+
);
|
|
72
|
+
const samples3 = Array.from({ length: 100 }, () =>
|
|
73
|
+
getBackoffDelay(3, { baseDelay: 100 })
|
|
74
|
+
);
|
|
75
|
+
const avg0 = samples0.reduce((a, b) => a + b, 0) / samples0.length;
|
|
76
|
+
const avg3 = samples3.reduce((a, b) => a + b, 0) / samples3.length;
|
|
77
|
+
// Attempt 3 should have a noticeably higher average
|
|
78
|
+
expect(avg3).toBeGreaterThan(avg0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// retry
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
describe('retry', () => {
|
|
87
|
+
test('returns Ok on first success', async () => {
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const result = await retry(() => {
|
|
90
|
+
calls += 1;
|
|
91
|
+
return Promise.resolve(Result.ok('done'));
|
|
92
|
+
});
|
|
93
|
+
expect(result.isOk()).toBe(true);
|
|
94
|
+
expect(result.unwrap()).toBe('done');
|
|
95
|
+
expect(calls).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('retries on retryable error', async () => {
|
|
99
|
+
const responses: Result<string, Error>[] = [
|
|
100
|
+
Result.err(new NetworkError('fail')),
|
|
101
|
+
Result.err(new NetworkError('fail')),
|
|
102
|
+
Result.ok('recovered'),
|
|
103
|
+
];
|
|
104
|
+
let calls = 0;
|
|
105
|
+
const result = await retry(
|
|
106
|
+
() => {
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index is bounded by maxAttempts
|
|
108
|
+
const r = responses[calls]!;
|
|
109
|
+
calls += 1;
|
|
110
|
+
return Promise.resolve(r);
|
|
111
|
+
},
|
|
112
|
+
{ baseDelay: 1, maxAttempts: 3 }
|
|
113
|
+
);
|
|
114
|
+
expect(result.isOk()).toBe(true);
|
|
115
|
+
expect(result.unwrap()).toBe('recovered');
|
|
116
|
+
expect(calls).toBe(3);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('does not retry non-retryable errors', async () => {
|
|
120
|
+
let calls = 0;
|
|
121
|
+
const result = await retry(
|
|
122
|
+
() => {
|
|
123
|
+
calls += 1;
|
|
124
|
+
return Promise.resolve(Result.err(new ValidationError('bad')));
|
|
125
|
+
},
|
|
126
|
+
{ baseDelay: 1, maxAttempts: 3 }
|
|
127
|
+
);
|
|
128
|
+
expect(result.isErr()).toBe(true);
|
|
129
|
+
expect(calls).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('gives up after maxAttempts', async () => {
|
|
133
|
+
let calls = 0;
|
|
134
|
+
const result = await retry(
|
|
135
|
+
() => {
|
|
136
|
+
calls += 1;
|
|
137
|
+
return Promise.resolve(Result.err(new NetworkError('fail')));
|
|
138
|
+
},
|
|
139
|
+
{ baseDelay: 1, maxAttempts: 2 }
|
|
140
|
+
);
|
|
141
|
+
expect(result.isErr()).toBe(true);
|
|
142
|
+
expect(calls).toBe(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('respects custom shouldRetry predicate', async () => {
|
|
146
|
+
let calls = 0;
|
|
147
|
+
const result = await retry(
|
|
148
|
+
() => {
|
|
149
|
+
calls += 1;
|
|
150
|
+
return Promise.resolve(Result.err(new ValidationError('bad')));
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
baseDelay: 1,
|
|
154
|
+
maxAttempts: 3,
|
|
155
|
+
// always retry
|
|
156
|
+
shouldRetry: () => true,
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
expect(result.isErr()).toBe(true);
|
|
160
|
+
expect(calls).toBe(3);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('cancels on abort signal', async () => {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
controller.abort();
|
|
166
|
+
|
|
167
|
+
const result = await retry(
|
|
168
|
+
() => Promise.resolve(Result.ok('should not run')),
|
|
169
|
+
{
|
|
170
|
+
signal: controller.signal,
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
expect(result.isErr()).toBe(true);
|
|
174
|
+
const err = result as unknown as { error: Error };
|
|
175
|
+
expect(err.error).toBeInstanceOf(CancelledError);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// withTimeout
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe('withTimeout', () => {
|
|
184
|
+
test('returns result when function completes in time', async () => {
|
|
185
|
+
const result = await withTimeout(
|
|
186
|
+
() => Promise.resolve(Result.ok('fast')),
|
|
187
|
+
1000
|
|
188
|
+
);
|
|
189
|
+
expect(result.isOk()).toBe(true);
|
|
190
|
+
expect(result.unwrap()).toBe('fast');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('returns TimeoutError when function exceeds timeout', async () => {
|
|
194
|
+
const result = await withTimeout(
|
|
195
|
+
() =>
|
|
196
|
+
// oxlint-disable-next-line avoid-new -- Promise constructor needed for setTimeout-based delay
|
|
197
|
+
new Promise<Result<string, Error>>((resolve) => {
|
|
198
|
+
setTimeout(() => resolve(Result.ok('slow')), 500);
|
|
199
|
+
}),
|
|
200
|
+
10
|
|
201
|
+
);
|
|
202
|
+
expect(result.isErr()).toBe(true);
|
|
203
|
+
const err1 = result as unknown as { error: TimeoutError };
|
|
204
|
+
expect(err1.error).toBeInstanceOf(TimeoutError);
|
|
205
|
+
expect(err1.error.message).toContain('timed out');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('returns CancelledError when signal is already aborted', async () => {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
controller.abort();
|
|
211
|
+
|
|
212
|
+
const result = await withTimeout(
|
|
213
|
+
() => Promise.resolve(Result.ok('value')),
|
|
214
|
+
1000,
|
|
215
|
+
controller.signal
|
|
216
|
+
);
|
|
217
|
+
expect(result.isErr()).toBe(true);
|
|
218
|
+
const err2 = result as unknown as { error: Error };
|
|
219
|
+
expect(err2.error).toBeInstanceOf(CancelledError);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('handles function that throws', async () => {
|
|
223
|
+
const result = await withTimeout(
|
|
224
|
+
() => Promise.reject(new Error('unexpected')),
|
|
225
|
+
1000
|
|
226
|
+
);
|
|
227
|
+
expect(result.isErr()).toBe(true);
|
|
228
|
+
const err3 = result as unknown as { error: Error };
|
|
229
|
+
expect(err3.error.message).toBe('unexpected');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('includes timeout ms in error context', async () => {
|
|
233
|
+
const result = await withTimeout(
|
|
234
|
+
() =>
|
|
235
|
+
// oxlint-disable-next-line avoid-new -- Promise constructor needed for setTimeout-based delay
|
|
236
|
+
new Promise<Result<string, Error>>((resolve) => {
|
|
237
|
+
setTimeout(() => resolve(Result.ok('slow')), 500);
|
|
238
|
+
}),
|
|
239
|
+
5
|
|
240
|
+
);
|
|
241
|
+
expect(result.isErr()).toBe(true);
|
|
242
|
+
const err4 = result as unknown as { error: TimeoutError };
|
|
243
|
+
expect(err4.error).toBeInstanceOf(TimeoutError);
|
|
244
|
+
expect(err4.error.context?.['timeoutMs']).toBe(5);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Result } from '../result';
|
|
4
|
+
|
|
5
|
+
describe('Result', () => {
|
|
6
|
+
describe('construction', () => {
|
|
7
|
+
test('Result.ok() creates a success result', () => {
|
|
8
|
+
const result = Result.ok(42);
|
|
9
|
+
expect(result.isOk()).toBe(true);
|
|
10
|
+
expect(result.isErr()).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('Result.err() creates a failure result', () => {
|
|
14
|
+
const result = Result.err(new Error('fail'));
|
|
15
|
+
expect(result.isOk()).toBe(false);
|
|
16
|
+
expect(result.isErr()).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('type narrowing', () => {
|
|
21
|
+
test('isOk() narrows to Ok with value access', () => {
|
|
22
|
+
const result: Result<number, Error> = Result.ok(42);
|
|
23
|
+
expect(result.isOk()).toBe(true);
|
|
24
|
+
expect(result.unwrap()).toBe(42);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('isErr() narrows to Err with error access', () => {
|
|
28
|
+
const error = new Error('boom');
|
|
29
|
+
const result: Result<number, Error> = Result.err(error);
|
|
30
|
+
expect(result.isErr()).toBe(true);
|
|
31
|
+
expect((result as { error: Error }).error).toBe(error);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('map()', () => {
|
|
36
|
+
test('transforms the value of an Ok', () => {
|
|
37
|
+
const result = Result.ok(10).map((n) => n * 2);
|
|
38
|
+
expect(result.unwrap()).toBe(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('passes through on Err', () => {
|
|
42
|
+
const error = new Error('fail');
|
|
43
|
+
const result = Result.err<Error>(error).map((_n: number) => 999);
|
|
44
|
+
expect(result.isErr()).toBe(true);
|
|
45
|
+
expect((result as { error: Error }).error).toBe(error);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('flatMap()', () => {
|
|
50
|
+
test('chains successful results', () => {
|
|
51
|
+
const result = Result.ok(10).flatMap((n) => Result.ok(n + 5));
|
|
52
|
+
expect(result.unwrap()).toBe(15);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('short-circuits on error', () => {
|
|
56
|
+
const error = new Error('first');
|
|
57
|
+
const result = Result.err<Error>(error).flatMap((_n: number) =>
|
|
58
|
+
Result.ok(999)
|
|
59
|
+
);
|
|
60
|
+
expect(result.isErr()).toBe(true);
|
|
61
|
+
expect((result as { error: Error }).error).toBe(error);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('chains into an error', () => {
|
|
65
|
+
const result = Result.ok(10).flatMap(() =>
|
|
66
|
+
Result.err(new Error('second'))
|
|
67
|
+
);
|
|
68
|
+
expect(result.isErr()).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('mapErr()', () => {
|
|
73
|
+
test('transforms the error of an Err', () => {
|
|
74
|
+
const result = Result.err('bad').mapErr((e) => new Error(e));
|
|
75
|
+
expect(result.isErr()).toBe(true);
|
|
76
|
+
expect((result as { error: Error }).error).toBeInstanceOf(Error);
|
|
77
|
+
expect((result as { error: Error }).error.message).toBe('bad');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('passes through on Ok', () => {
|
|
81
|
+
const result = Result.ok(42).mapErr(() => 'transformed');
|
|
82
|
+
expect(result.unwrap()).toBe(42);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('match()', () => {
|
|
87
|
+
test('dispatches to ok handler on success', () => {
|
|
88
|
+
const output = Result.ok(5).match({
|
|
89
|
+
err: (e) => `error:${e}`,
|
|
90
|
+
ok: (v) => `value:${v}`,
|
|
91
|
+
});
|
|
92
|
+
expect(output).toBe('value:5');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('dispatches to err handler on failure', () => {
|
|
96
|
+
const output = Result.err('oops').match({
|
|
97
|
+
err: (e) => `error:${e}`,
|
|
98
|
+
ok: (v) => `value:${v}`,
|
|
99
|
+
});
|
|
100
|
+
expect(output).toBe('error:oops');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('unwrap()', () => {
|
|
105
|
+
test('returns value on Ok', () => {
|
|
106
|
+
expect(Result.ok('hello').unwrap()).toBe('hello');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('throws on Err with Error instance', () => {
|
|
110
|
+
const error = new Error('boom');
|
|
111
|
+
expect(() => Result.err(error).unwrap()).toThrow('boom');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('throws wrapped error on Err with non-Error value', () => {
|
|
115
|
+
expect(() => Result.err('string error').unwrap()).toThrow('string error');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('unwrapOr()', () => {
|
|
120
|
+
test('returns value on Ok', () => {
|
|
121
|
+
expect(Result.ok(42).unwrapOr(0)).toBe(42);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('returns fallback on Err', () => {
|
|
125
|
+
const result: Result<number, Error> = Result.err(new Error('fail'));
|
|
126
|
+
expect(result.unwrapOr(0)).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('Result.combine()', () => {
|
|
131
|
+
test('collects all Ok values into an array', () => {
|
|
132
|
+
const results = [Result.ok(1), Result.ok(2), Result.ok(3)];
|
|
133
|
+
const combined = Result.combine(results);
|
|
134
|
+
expect(combined.unwrap()).toEqual([1, 2, 3]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('returns first error encountered', () => {
|
|
138
|
+
const err1 = new Error('first');
|
|
139
|
+
const err2 = new Error('second');
|
|
140
|
+
const results: Result<number, Error>[] = [
|
|
141
|
+
Result.ok(1),
|
|
142
|
+
Result.err(err1),
|
|
143
|
+
Result.err(err2),
|
|
144
|
+
];
|
|
145
|
+
const combined = Result.combine(results);
|
|
146
|
+
expect(combined.isErr()).toBe(true);
|
|
147
|
+
expect((combined as { error: Error }).error).toBe(err1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('returns Ok with empty array for empty input', () => {
|
|
151
|
+
const combined = Result.combine([]);
|
|
152
|
+
expect(combined.unwrap()).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|