@ontrails/core 0.2.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/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -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/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { ValidationError } from './errors.js';
|
|
2
|
+
import type {
|
|
3
|
+
ActivationSource,
|
|
4
|
+
ActivationSourceMeta,
|
|
5
|
+
} from './activation-source.js';
|
|
6
|
+
|
|
7
|
+
type ScheduleInputDefault = Record<string, never>;
|
|
8
|
+
|
|
9
|
+
export interface ScheduleSpec<TInput = unknown> {
|
|
10
|
+
readonly cron: string;
|
|
11
|
+
readonly input?: TInput | undefined;
|
|
12
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
13
|
+
readonly timezone?: string | undefined;
|
|
14
|
+
/** Reserved for future schedule-specific design; trail versioning is trail-only. */
|
|
15
|
+
readonly version?: never;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ScheduleSource<TInput = unknown> extends ActivationSource {
|
|
19
|
+
readonly cron: string;
|
|
20
|
+
readonly input: TInput;
|
|
21
|
+
readonly kind: 'schedule';
|
|
22
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
23
|
+
readonly timezone?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScheduleValidationIssue {
|
|
27
|
+
readonly field: 'cron' | 'input' | 'timezone';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CRON_FIELD_BOUNDS = Object.freeze([
|
|
32
|
+
{ max: 59, min: 0, name: 'minute' },
|
|
33
|
+
{ max: 23, min: 0, name: 'hour' },
|
|
34
|
+
{ max: 31, min: 1, name: 'day of month' },
|
|
35
|
+
{ max: 12, min: 1, name: 'month' },
|
|
36
|
+
{ max: 7, min: 0, name: 'day of week' },
|
|
37
|
+
] as const);
|
|
38
|
+
|
|
39
|
+
const EMPTY_INPUT = Object.freeze({}) as ScheduleInputDefault;
|
|
40
|
+
|
|
41
|
+
const normalizeCron = (cron: string): string =>
|
|
42
|
+
cron.trim().replaceAll(/\s+/g, ' ');
|
|
43
|
+
|
|
44
|
+
const isIntegerToken = (value: string): boolean => /^\d+$/.test(value);
|
|
45
|
+
|
|
46
|
+
const parseIntegerToken = (value: string): number | undefined =>
|
|
47
|
+
isIntegerToken(value) ? Number.parseInt(value, 10) : undefined;
|
|
48
|
+
|
|
49
|
+
const isNumberInRange = (
|
|
50
|
+
value: string,
|
|
51
|
+
bounds: (typeof CRON_FIELD_BOUNDS)[number]
|
|
52
|
+
): boolean => {
|
|
53
|
+
const parsed = parseIntegerToken(value);
|
|
54
|
+
return parsed !== undefined && parsed >= bounds.min && parsed <= bounds.max;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const isCronAtomValid = (
|
|
58
|
+
atom: string,
|
|
59
|
+
bounds: (typeof CRON_FIELD_BOUNDS)[number]
|
|
60
|
+
): boolean => {
|
|
61
|
+
if (atom === '*') {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (isNumberInRange(atom, bounds)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const range = atom.split('-');
|
|
69
|
+
if (range.length !== 2) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const [rangeStart, rangeEnd] = range;
|
|
73
|
+
if (rangeStart === undefined || rangeEnd === undefined) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
!isNumberInRange(rangeStart, bounds) ||
|
|
78
|
+
!isNumberInRange(rangeEnd, bounds)
|
|
79
|
+
) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const start = Number.parseInt(rangeStart, 10);
|
|
83
|
+
const end = Number.parseInt(rangeEnd, 10);
|
|
84
|
+
return start <= end;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isCronPartValid = (
|
|
88
|
+
part: string,
|
|
89
|
+
bounds: (typeof CRON_FIELD_BOUNDS)[number]
|
|
90
|
+
): boolean => {
|
|
91
|
+
const stepped = part.split('/');
|
|
92
|
+
if (stepped.length > 2) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const [atom, step] = stepped;
|
|
96
|
+
if (!atom || !isCronAtomValid(atom, bounds)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (step === undefined) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
const parsedStep = parseIntegerToken(step);
|
|
103
|
+
return parsedStep !== undefined && parsedStep > 0;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const validateCronField = (
|
|
107
|
+
field: string,
|
|
108
|
+
bounds: (typeof CRON_FIELD_BOUNDS)[number]
|
|
109
|
+
): string | undefined => {
|
|
110
|
+
if (field.length === 0) {
|
|
111
|
+
return `${bounds.name} field is empty`;
|
|
112
|
+
}
|
|
113
|
+
const parts = field.split(',');
|
|
114
|
+
return parts.every((part) => isCronPartValid(part, bounds))
|
|
115
|
+
? undefined
|
|
116
|
+
: `${bounds.name} field is not a supported cron expression`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const validateCron = (cron: unknown): ScheduleValidationIssue[] => {
|
|
120
|
+
if (typeof cron !== 'string' || cron.trim().length === 0) {
|
|
121
|
+
return [
|
|
122
|
+
{ field: 'cron', message: 'Cron expression must be a non-empty string' },
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const normalized = normalizeCron(cron);
|
|
127
|
+
const fields = normalized.split(' ');
|
|
128
|
+
if (fields.length !== CRON_FIELD_BOUNDS.length) {
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
field: 'cron',
|
|
132
|
+
message: 'Cron expression must contain exactly five fields',
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return fields.flatMap((field, index) => {
|
|
138
|
+
const bounds = CRON_FIELD_BOUNDS[index];
|
|
139
|
+
if (!bounds) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const issue = validateCronField(field, bounds);
|
|
143
|
+
return issue === undefined
|
|
144
|
+
? []
|
|
145
|
+
: [{ field: 'cron' as const, message: issue }];
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const isTimezoneValid = (timezone: string): boolean => {
|
|
150
|
+
try {
|
|
151
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const validateTimezone = (
|
|
159
|
+
timezone: unknown
|
|
160
|
+
): readonly ScheduleValidationIssue[] => {
|
|
161
|
+
if (timezone === undefined) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
if (typeof timezone !== 'string' || timezone.trim().length === 0) {
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
field: 'timezone',
|
|
168
|
+
message: 'Timezone must be a non-empty IANA timezone string',
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
return isTimezoneValid(timezone.trim())
|
|
173
|
+
? []
|
|
174
|
+
: [
|
|
175
|
+
{
|
|
176
|
+
field: 'timezone',
|
|
177
|
+
message: `Timezone "${timezone}" is not supported by Intl.DateTimeFormat`,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const isNonJsonLeaf = (value: unknown): boolean => {
|
|
183
|
+
if (value === undefined) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const kind = typeof value;
|
|
187
|
+
if (kind === 'function' || kind === 'symbol' || kind === 'bigint') {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return (
|
|
191
|
+
value instanceof Date ||
|
|
192
|
+
value instanceof RegExp ||
|
|
193
|
+
value instanceof Map ||
|
|
194
|
+
value instanceof Set
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const containsNonSerializableLeaf = (
|
|
199
|
+
value: unknown,
|
|
200
|
+
seen: WeakSet<object>
|
|
201
|
+
): boolean => {
|
|
202
|
+
if (isNonJsonLeaf(value)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (value === null || typeof value !== 'object') {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (seen.has(value)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
seen.add(value);
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
return value.some((entry) => containsNonSerializableLeaf(entry, seen));
|
|
214
|
+
}
|
|
215
|
+
return Object.values(value).some((entry) =>
|
|
216
|
+
containsNonSerializableLeaf(entry, seen)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const validateInput = (input: unknown): readonly ScheduleValidationIssue[] => {
|
|
221
|
+
if (containsNonSerializableLeaf(input, new WeakSet<object>())) {
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
field: 'input',
|
|
225
|
+
message: 'Schedule input must be JSON-serializable data',
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
JSON.stringify(input);
|
|
231
|
+
return [];
|
|
232
|
+
} catch {
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
field: 'input',
|
|
236
|
+
message: 'Schedule input must be JSON-serializable data',
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const scheduleIssuesMessage = (
|
|
243
|
+
id: string,
|
|
244
|
+
issues: readonly ScheduleValidationIssue[]
|
|
245
|
+
): string =>
|
|
246
|
+
`schedule("${id}") is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join('; ')}`;
|
|
247
|
+
|
|
248
|
+
const assertScheduleSpec = (
|
|
249
|
+
id: string,
|
|
250
|
+
spec: ScheduleSpec
|
|
251
|
+
): {
|
|
252
|
+
readonly cron: string;
|
|
253
|
+
readonly input: unknown;
|
|
254
|
+
readonly timezone?: string | undefined;
|
|
255
|
+
} => {
|
|
256
|
+
const input = spec.input === undefined ? EMPTY_INPUT : spec.input;
|
|
257
|
+
const timezone = spec.timezone?.trim();
|
|
258
|
+
const issues = [
|
|
259
|
+
...validateCron(spec.cron),
|
|
260
|
+
...validateTimezone(timezone),
|
|
261
|
+
...validateInput(input),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
if (issues.length > 0) {
|
|
265
|
+
throw new ValidationError(scheduleIssuesMessage(id, issues), {
|
|
266
|
+
context: { issues },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
cron: normalizeCron(spec.cron),
|
|
272
|
+
input,
|
|
273
|
+
...(timezone === undefined ? {} : { timezone }),
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const validateScheduleSource = (
|
|
278
|
+
source: ActivationSource
|
|
279
|
+
): readonly ScheduleValidationIssue[] => {
|
|
280
|
+
if (source.kind !== 'schedule') {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const input = Object.hasOwn(source, 'input') ? source.input : EMPTY_INPUT;
|
|
284
|
+
return [
|
|
285
|
+
...validateCron(source.cron),
|
|
286
|
+
...validateTimezone(source.timezone),
|
|
287
|
+
...validateInput(input),
|
|
288
|
+
];
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export function schedule<TInput>(
|
|
292
|
+
id: string,
|
|
293
|
+
spec: ScheduleSpec<TInput> & { readonly input: TInput }
|
|
294
|
+
): ScheduleSource<TInput>;
|
|
295
|
+
export function schedule(
|
|
296
|
+
id: string,
|
|
297
|
+
spec: ScheduleSpec
|
|
298
|
+
): ScheduleSource<ScheduleInputDefault>;
|
|
299
|
+
export function schedule<TInput>(
|
|
300
|
+
spec: ScheduleSpec<TInput> & { readonly id: string; readonly input: TInput }
|
|
301
|
+
): ScheduleSource<TInput>;
|
|
302
|
+
export function schedule(
|
|
303
|
+
spec: ScheduleSpec & { readonly id: string }
|
|
304
|
+
): ScheduleSource<ScheduleInputDefault>;
|
|
305
|
+
export function schedule<TInput>(
|
|
306
|
+
idOrSpec: string | (ScheduleSpec<TInput> & { readonly id: string }),
|
|
307
|
+
maybeSpec?: ScheduleSpec<TInput>
|
|
308
|
+
): ScheduleSource<TInput | ScheduleInputDefault> {
|
|
309
|
+
const id = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
|
|
310
|
+
// oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
|
|
311
|
+
const spec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
|
|
312
|
+
const normalized = assertScheduleSpec(id, spec);
|
|
313
|
+
|
|
314
|
+
return Object.freeze({
|
|
315
|
+
cron: normalized.cron,
|
|
316
|
+
id,
|
|
317
|
+
input: normalized.input as TInput | ScheduleInputDefault,
|
|
318
|
+
kind: 'schedule' as const,
|
|
319
|
+
...(spec.meta === undefined
|
|
320
|
+
? {}
|
|
321
|
+
: { meta: Object.freeze({ ...spec.meta }) }),
|
|
322
|
+
...(normalized.timezone === undefined
|
|
323
|
+
? {}
|
|
324
|
+
: { timezone: normalized.timezone }),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization utilities for @ontrails/core
|
|
3
|
+
*
|
|
4
|
+
* Safe JSON parsing/stringifying and error serialization/deserialization
|
|
5
|
+
* for transport across process boundaries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ErrorCategory, TrailsError } from './errors.js';
|
|
9
|
+
import {
|
|
10
|
+
AuthError,
|
|
11
|
+
CancelledError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
InternalError,
|
|
15
|
+
NetworkError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PermissionError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
RetryExhaustedError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
WorkspaceShiftError,
|
|
22
|
+
errorClasses,
|
|
23
|
+
isTrailsError,
|
|
24
|
+
} from './errors.js';
|
|
25
|
+
import {
|
|
26
|
+
redactErrorContext,
|
|
27
|
+
redactErrorStack,
|
|
28
|
+
redactErrorString,
|
|
29
|
+
} from './error-rendering.js';
|
|
30
|
+
import { Result } from './result.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// SerializedError interface
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface SerializedError {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly message: string;
|
|
39
|
+
readonly attempts?: number | undefined;
|
|
40
|
+
readonly category?: ErrorCategory | undefined;
|
|
41
|
+
readonly cause?: SerializedError | undefined;
|
|
42
|
+
readonly detour?: string | undefined;
|
|
43
|
+
readonly retryable?: boolean | undefined;
|
|
44
|
+
readonly retryAfter?: number | undefined;
|
|
45
|
+
readonly context?: Record<string, unknown> | undefined;
|
|
46
|
+
readonly stack?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Internal helpers (defined before usage)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Build options object without including undefined context. */
|
|
54
|
+
const buildOpts = (
|
|
55
|
+
context: Record<string, unknown> | undefined
|
|
56
|
+
): {
|
|
57
|
+
context?: Record<string, unknown>;
|
|
58
|
+
} => {
|
|
59
|
+
if (context !== undefined) {
|
|
60
|
+
return { context };
|
|
61
|
+
}
|
|
62
|
+
return {};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type ErrorFactory = (
|
|
66
|
+
message: string,
|
|
67
|
+
opts: { cause?: Error; context?: Record<string, unknown> },
|
|
68
|
+
retryAfter: number | undefined
|
|
69
|
+
) => TrailsError;
|
|
70
|
+
|
|
71
|
+
type FixedErrorConstructor = new (
|
|
72
|
+
message: string,
|
|
73
|
+
options?: { cause?: Error; context?: Record<string, unknown> }
|
|
74
|
+
) => TrailsError;
|
|
75
|
+
|
|
76
|
+
const errorFactories: Record<ErrorCategory, ErrorFactory> = {
|
|
77
|
+
auth: (msg, opts) => new AuthError(msg, opts),
|
|
78
|
+
cancelled: (msg, opts) => new CancelledError(msg, opts),
|
|
79
|
+
conflict: (msg, opts) => new ConflictError(msg, opts),
|
|
80
|
+
internal: (msg, opts) => new InternalError(msg, opts),
|
|
81
|
+
network: (msg, opts) => new NetworkError(msg, opts),
|
|
82
|
+
not_found: (msg, opts) => new NotFoundError(msg, opts),
|
|
83
|
+
permission: (msg, opts) => new PermissionError(msg, opts),
|
|
84
|
+
rate_limit: (msg, opts, retryAfter) => {
|
|
85
|
+
const rlOpts: { context?: Record<string, unknown>; retryAfter?: number } = {
|
|
86
|
+
...opts,
|
|
87
|
+
};
|
|
88
|
+
if (retryAfter !== undefined) {
|
|
89
|
+
rlOpts.retryAfter = retryAfter;
|
|
90
|
+
}
|
|
91
|
+
return new RateLimitError(msg, rlOpts);
|
|
92
|
+
},
|
|
93
|
+
shift: (msg, opts) => new WorkspaceShiftError(msg, opts),
|
|
94
|
+
timeout: (msg, opts) => new TimeoutError(msg, opts),
|
|
95
|
+
validation: (msg, opts) => new ValidationError(msg, opts),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const createErrorByCategory = (
|
|
99
|
+
category: ErrorCategory,
|
|
100
|
+
message: string,
|
|
101
|
+
context: Record<string, unknown> | undefined,
|
|
102
|
+
retryAfter: number | undefined
|
|
103
|
+
): TrailsError => {
|
|
104
|
+
const opts = buildOpts(context);
|
|
105
|
+
const factory = errorFactories[category] ?? errorFactories.internal;
|
|
106
|
+
return factory(message, opts, retryAfter);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Map fixed error class names to constructors for precise round-tripping. */
|
|
110
|
+
const errorConstructorsByName: Readonly<Record<string, ErrorFactory>> =
|
|
111
|
+
Object.fromEntries(
|
|
112
|
+
errorClasses.flatMap((entry): [string, ErrorFactory][] => {
|
|
113
|
+
if (entry.category === 'dynamic') {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const ctor = entry.ctor as FixedErrorConstructor;
|
|
117
|
+
return [
|
|
118
|
+
[
|
|
119
|
+
entry.name,
|
|
120
|
+
(message, opts, retryAfter) => {
|
|
121
|
+
if (ctor === RateLimitError) {
|
|
122
|
+
const rateLimitOptions:
|
|
123
|
+
| {
|
|
124
|
+
cause?: Error;
|
|
125
|
+
context?: Record<string, unknown>;
|
|
126
|
+
retryAfter?: number;
|
|
127
|
+
}
|
|
128
|
+
| undefined =
|
|
129
|
+
opts.context === undefined &&
|
|
130
|
+
opts.cause === undefined &&
|
|
131
|
+
retryAfter === undefined
|
|
132
|
+
? undefined
|
|
133
|
+
: {
|
|
134
|
+
...opts,
|
|
135
|
+
...(retryAfter === undefined ? {} : { retryAfter }),
|
|
136
|
+
};
|
|
137
|
+
return new RateLimitError(message, rateLimitOptions);
|
|
138
|
+
}
|
|
139
|
+
return new ctor(message, opts);
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
];
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Error serialization
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Extract structured data from an Error for transport. */
|
|
151
|
+
export const serializeError = (error: Error): SerializedError => {
|
|
152
|
+
const result: SerializedError = {
|
|
153
|
+
message: redactErrorString(error.message),
|
|
154
|
+
name: error.name,
|
|
155
|
+
stack: redactErrorStack(error.stack),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (isTrailsError(error)) {
|
|
159
|
+
return {
|
|
160
|
+
...result,
|
|
161
|
+
category: error.category,
|
|
162
|
+
context: redactErrorContext(error.context),
|
|
163
|
+
...(error instanceof RetryExhaustedError
|
|
164
|
+
? {
|
|
165
|
+
attempts: error.attempts,
|
|
166
|
+
cause: serializeError(error.cause),
|
|
167
|
+
detour: error.detour,
|
|
168
|
+
}
|
|
169
|
+
: {}),
|
|
170
|
+
retryAfter:
|
|
171
|
+
error instanceof RateLimitError ? error.retryAfter : undefined,
|
|
172
|
+
retryable: error.retryable,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Reconstruct a TrailsError from serialized data. */
|
|
180
|
+
export const deserializeError = (data: SerializedError): TrailsError => {
|
|
181
|
+
const opts = buildOpts(data.context);
|
|
182
|
+
if (data.name === 'RetryExhaustedError') {
|
|
183
|
+
const wrapped =
|
|
184
|
+
data.cause === undefined
|
|
185
|
+
? createErrorByCategory(
|
|
186
|
+
data.category ?? 'internal',
|
|
187
|
+
data.message,
|
|
188
|
+
data.context,
|
|
189
|
+
data.retryAfter
|
|
190
|
+
)
|
|
191
|
+
: deserializeError(data.cause);
|
|
192
|
+
const error = new RetryExhaustedError(wrapped, {
|
|
193
|
+
attempts: data.attempts ?? 0,
|
|
194
|
+
detour: data.detour ?? 'unknown',
|
|
195
|
+
});
|
|
196
|
+
if (data.message !== error.message) {
|
|
197
|
+
error.message = data.message;
|
|
198
|
+
}
|
|
199
|
+
if (data.stack) {
|
|
200
|
+
error.stack = data.stack;
|
|
201
|
+
}
|
|
202
|
+
return error;
|
|
203
|
+
}
|
|
204
|
+
const nameFactory = errorConstructorsByName[data.name];
|
|
205
|
+
|
|
206
|
+
const error = nameFactory
|
|
207
|
+
? nameFactory(data.message, opts, data.retryAfter)
|
|
208
|
+
: createErrorByCategory(
|
|
209
|
+
data.category ?? 'internal',
|
|
210
|
+
data.message,
|
|
211
|
+
data.context,
|
|
212
|
+
data.retryAfter
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (data.stack) {
|
|
216
|
+
error.stack = data.stack;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return error;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Stringify a value, returning a Result. Handles circular references. */
|
|
223
|
+
export const safeStringify = (
|
|
224
|
+
value: unknown
|
|
225
|
+
): Result<string, InternalError> => {
|
|
226
|
+
try {
|
|
227
|
+
// Track the current ancestor chain, not every object ever visited.
|
|
228
|
+
// This allows shared references in a DAG while still detecting cycles.
|
|
229
|
+
const stack: unknown[] = [];
|
|
230
|
+
const keys: string[] = [];
|
|
231
|
+
|
|
232
|
+
const json = JSON.stringify(value, function json(key, val: unknown) {
|
|
233
|
+
if (stack.length > 0) {
|
|
234
|
+
// `this` is the object that contains `key`. Trim the stack back
|
|
235
|
+
// to `this` so we only track the current ancestor path.
|
|
236
|
+
const thisIndex = stack.lastIndexOf(this as unknown);
|
|
237
|
+
stack.splice(thisIndex + 1);
|
|
238
|
+
keys.splice(thisIndex);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof val === 'object' && val !== null) {
|
|
242
|
+
if (stack.includes(val)) {
|
|
243
|
+
return '[Circular]';
|
|
244
|
+
}
|
|
245
|
+
stack.push(val);
|
|
246
|
+
keys.push(key);
|
|
247
|
+
}
|
|
248
|
+
return val;
|
|
249
|
+
});
|
|
250
|
+
if (json === undefined) {
|
|
251
|
+
return Result.err(
|
|
252
|
+
new InternalError('Value is not JSON-serializable', {
|
|
253
|
+
context: { type: typeof value },
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return Result.ok(json);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return Result.err(
|
|
260
|
+
new InternalError('Failed to stringify value', {
|
|
261
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
};
|