@ontrails/core 1.0.0-beta.2 → 1.0.0-beta.21

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 (245) hide show
  1. package/CHANGELOG.md +283 -5
  2. package/README.md +113 -33
  3. package/package.json +11 -1
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/branded.ts +1 -1
  10. package/src/compose-batch.ts +69 -0
  11. package/src/compose-schema.ts +36 -0
  12. package/src/context.ts +44 -10
  13. package/src/contour.ts +346 -0
  14. package/src/derive.ts +59 -18
  15. package/src/detours.ts +8 -0
  16. package/src/draft.ts +342 -0
  17. package/src/error-projection.ts +51 -0
  18. package/src/errors.ts +309 -48
  19. package/src/execute.ts +1577 -0
  20. package/src/fire.ts +1169 -0
  21. package/src/index.ts +477 -32
  22. package/src/internal/fork-ctx.ts +69 -0
  23. package/src/layer-projection.ts +193 -0
  24. package/src/layer.ts +43 -6
  25. package/src/observe.ts +361 -0
  26. package/src/path-security.ts +1 -1
  27. package/src/permits.ts +12 -0
  28. package/src/redaction/patterns.ts +6 -3
  29. package/src/resilience.ts +11 -11
  30. package/src/resource-config.ts +792 -0
  31. package/src/resource.ts +194 -0
  32. package/src/result.ts +36 -4
  33. package/src/run.ts +76 -0
  34. package/src/schedule-runtime.ts +689 -0
  35. package/src/schedule.ts +326 -0
  36. package/src/serialization.ts +119 -39
  37. package/src/signal-diagnostics.ts +633 -0
  38. package/src/signal-ref.ts +87 -0
  39. package/src/signal.ts +104 -0
  40. package/src/store/accessor-protocol.ts +56 -0
  41. package/src/store/index.ts +4 -0
  42. package/src/structured-examples.ts +248 -0
  43. package/src/surface-derivation.ts +91 -0
  44. package/src/surface-filter.ts +176 -0
  45. package/src/surface-versioning.ts +42 -0
  46. package/src/topo.ts +769 -57
  47. package/src/tracing.ts +345 -0
  48. package/src/trail.ts +1161 -29
  49. package/src/trails/derive-trail.ts +842 -0
  50. package/src/trails/index.ts +9 -0
  51. package/src/trails/ingest.ts +139 -0
  52. package/src/trails-db.ts +208 -0
  53. package/src/transport-error-map.ts +174 -0
  54. package/src/type-utils.ts +87 -0
  55. package/src/types.ts +254 -12
  56. package/src/validate-established-topo.ts +69 -0
  57. package/src/validate-topo.ts +442 -35
  58. package/src/validation.ts +152 -4
  59. package/src/version-marker.ts +716 -0
  60. package/src/version-resolution.ts +308 -0
  61. package/src/version-runtime.ts +120 -0
  62. package/src/webhook.ts +278 -0
  63. package/src/workspace.ts +1 -1
  64. package/src/zod-wrappers.ts +72 -0
  65. package/.turbo/turbo-build.log +0 -1
  66. package/.turbo/turbo-lint.log +0 -3
  67. package/.turbo/turbo-typecheck.log +0 -1
  68. package/dist/adapters.d.ts +0 -39
  69. package/dist/adapters.d.ts.map +0 -1
  70. package/dist/adapters.js +0 -2
  71. package/dist/adapters.js.map +0 -1
  72. package/dist/blob-ref.d.ts +0 -20
  73. package/dist/blob-ref.d.ts.map +0 -1
  74. package/dist/blob-ref.js +0 -22
  75. package/dist/blob-ref.js.map +0 -1
  76. package/dist/branded.d.ts +0 -36
  77. package/dist/branded.d.ts.map +0 -1
  78. package/dist/branded.js +0 -89
  79. package/dist/branded.js.map +0 -1
  80. package/dist/collections.d.ts +0 -31
  81. package/dist/collections.d.ts.map +0 -1
  82. package/dist/collections.js +0 -60
  83. package/dist/collections.js.map +0 -1
  84. package/dist/context.d.ts +0 -10
  85. package/dist/context.d.ts.map +0 -1
  86. package/dist/context.js +0 -15
  87. package/dist/context.js.map +0 -1
  88. package/dist/derive.d.ts +0 -33
  89. package/dist/derive.d.ts.map +0 -1
  90. package/dist/derive.js +0 -122
  91. package/dist/derive.js.map +0 -1
  92. package/dist/errors.d.ts +0 -83
  93. package/dist/errors.d.ts.map +0 -1
  94. package/dist/errors.js +0 -142
  95. package/dist/errors.js.map +0 -1
  96. package/dist/event.d.ts +0 -45
  97. package/dist/event.d.ts.map +0 -1
  98. package/dist/event.js +0 -17
  99. package/dist/event.js.map +0 -1
  100. package/dist/fetch.d.ts +0 -15
  101. package/dist/fetch.d.ts.map +0 -1
  102. package/dist/fetch.js +0 -102
  103. package/dist/fetch.js.map +0 -1
  104. package/dist/guards.d.ts +0 -17
  105. package/dist/guards.d.ts.map +0 -1
  106. package/dist/guards.js +0 -25
  107. package/dist/guards.js.map +0 -1
  108. package/dist/health.d.ts +0 -18
  109. package/dist/health.d.ts.map +0 -1
  110. package/dist/health.js +0 -5
  111. package/dist/health.js.map +0 -1
  112. package/dist/hike.d.ts +0 -36
  113. package/dist/hike.d.ts.map +0 -1
  114. package/dist/hike.js +0 -20
  115. package/dist/hike.js.map +0 -1
  116. package/dist/index.d.ts +0 -34
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/index.js +0 -38
  119. package/dist/index.js.map +0 -1
  120. package/dist/job.d.ts +0 -24
  121. package/dist/job.d.ts.map +0 -1
  122. package/dist/job.js +0 -17
  123. package/dist/job.js.map +0 -1
  124. package/dist/layer.d.ts +0 -17
  125. package/dist/layer.d.ts.map +0 -1
  126. package/dist/layer.js +0 -21
  127. package/dist/layer.js.map +0 -1
  128. package/dist/path-security.d.ts +0 -28
  129. package/dist/path-security.d.ts.map +0 -1
  130. package/dist/path-security.js +0 -63
  131. package/dist/path-security.js.map +0 -1
  132. package/dist/patterns/bulk.d.ts +0 -15
  133. package/dist/patterns/bulk.d.ts.map +0 -1
  134. package/dist/patterns/bulk.js +0 -14
  135. package/dist/patterns/bulk.js.map +0 -1
  136. package/dist/patterns/change.d.ts +0 -10
  137. package/dist/patterns/change.d.ts.map +0 -1
  138. package/dist/patterns/change.js +0 -10
  139. package/dist/patterns/change.js.map +0 -1
  140. package/dist/patterns/date-range.d.ts +0 -10
  141. package/dist/patterns/date-range.d.ts.map +0 -1
  142. package/dist/patterns/date-range.js +0 -10
  143. package/dist/patterns/date-range.js.map +0 -1
  144. package/dist/patterns/index.d.ts +0 -9
  145. package/dist/patterns/index.d.ts.map +0 -1
  146. package/dist/patterns/index.js +0 -9
  147. package/dist/patterns/index.js.map +0 -1
  148. package/dist/patterns/pagination.d.ts +0 -18
  149. package/dist/patterns/pagination.d.ts.map +0 -1
  150. package/dist/patterns/pagination.js +0 -18
  151. package/dist/patterns/pagination.js.map +0 -1
  152. package/dist/patterns/progress.d.ts +0 -11
  153. package/dist/patterns/progress.d.ts.map +0 -1
  154. package/dist/patterns/progress.js +0 -11
  155. package/dist/patterns/progress.js.map +0 -1
  156. package/dist/patterns/sorting.d.ts +0 -13
  157. package/dist/patterns/sorting.d.ts.map +0 -1
  158. package/dist/patterns/sorting.js +0 -10
  159. package/dist/patterns/sorting.js.map +0 -1
  160. package/dist/patterns/status.d.ts +0 -15
  161. package/dist/patterns/status.d.ts.map +0 -1
  162. package/dist/patterns/status.js +0 -9
  163. package/dist/patterns/status.js.map +0 -1
  164. package/dist/patterns/timestamps.d.ts +0 -10
  165. package/dist/patterns/timestamps.d.ts.map +0 -1
  166. package/dist/patterns/timestamps.js +0 -10
  167. package/dist/patterns/timestamps.js.map +0 -1
  168. package/dist/redaction/index.d.ts +0 -4
  169. package/dist/redaction/index.d.ts.map +0 -1
  170. package/dist/redaction/index.js +0 -3
  171. package/dist/redaction/index.js.map +0 -1
  172. package/dist/redaction/patterns.d.ts +0 -9
  173. package/dist/redaction/patterns.d.ts.map +0 -1
  174. package/dist/redaction/patterns.js +0 -39
  175. package/dist/redaction/patterns.js.map +0 -1
  176. package/dist/redaction/redactor.d.ts +0 -27
  177. package/dist/redaction/redactor.d.ts.map +0 -1
  178. package/dist/redaction/redactor.js +0 -89
  179. package/dist/redaction/redactor.js.map +0 -1
  180. package/dist/resilience.d.ts +0 -34
  181. package/dist/resilience.d.ts.map +0 -1
  182. package/dist/resilience.js +0 -164
  183. package/dist/resilience.js.map +0 -1
  184. package/dist/result.d.ts +0 -57
  185. package/dist/result.d.ts.map +0 -1
  186. package/dist/result.js +0 -145
  187. package/dist/result.js.map +0 -1
  188. package/dist/serialization.d.ts +0 -27
  189. package/dist/serialization.d.ts.map +0 -1
  190. package/dist/serialization.js +0 -115
  191. package/dist/serialization.js.map +0 -1
  192. package/dist/topo.d.ts +0 -18
  193. package/dist/topo.d.ts.map +0 -1
  194. package/dist/topo.js +0 -74
  195. package/dist/topo.js.map +0 -1
  196. package/dist/trail.d.ts +0 -83
  197. package/dist/trail.d.ts.map +0 -1
  198. package/dist/trail.js +0 -16
  199. package/dist/trail.js.map +0 -1
  200. package/dist/types.d.ts +0 -46
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -2
  203. package/dist/types.js.map +0 -1
  204. package/dist/validate-topo.d.ts +0 -24
  205. package/dist/validate-topo.d.ts.map +0 -1
  206. package/dist/validate-topo.js +0 -108
  207. package/dist/validate-topo.js.map +0 -1
  208. package/dist/validation.d.ts +0 -27
  209. package/dist/validation.d.ts.map +0 -1
  210. package/dist/validation.js +0 -134
  211. package/dist/validation.js.map +0 -1
  212. package/dist/workspace.d.ts +0 -25
  213. package/dist/workspace.d.ts.map +0 -1
  214. package/dist/workspace.js +0 -57
  215. package/dist/workspace.js.map +0 -1
  216. package/src/__tests__/blob-ref.test.ts +0 -103
  217. package/src/__tests__/branded.test.ts +0 -148
  218. package/src/__tests__/collections.test.ts +0 -126
  219. package/src/__tests__/context.test.ts +0 -66
  220. package/src/__tests__/derive.test.ts +0 -159
  221. package/src/__tests__/errors.test.ts +0 -309
  222. package/src/__tests__/event.test.ts +0 -82
  223. package/src/__tests__/fetch.test.ts +0 -217
  224. package/src/__tests__/guards.test.ts +0 -102
  225. package/src/__tests__/hike.test.ts +0 -117
  226. package/src/__tests__/job.test.ts +0 -98
  227. package/src/__tests__/layer.test.ts +0 -224
  228. package/src/__tests__/path-security.test.ts +0 -114
  229. package/src/__tests__/patterns.test.ts +0 -273
  230. package/src/__tests__/redaction.test.ts +0 -244
  231. package/src/__tests__/resilience.test.ts +0 -246
  232. package/src/__tests__/result.test.ts +0 -155
  233. package/src/__tests__/serialization.test.ts +0 -236
  234. package/src/__tests__/topo.test.ts +0 -184
  235. package/src/__tests__/trail.test.ts +0 -179
  236. package/src/__tests__/validate-topo.test.ts +0 -201
  237. package/src/__tests__/validation.test.ts +0 -283
  238. package/src/__tests__/workspace.test.ts +0 -183
  239. package/src/adapters.ts +0 -68
  240. package/src/event.ts +0 -77
  241. package/src/health.ts +0 -23
  242. package/src/hike.ts +0 -77
  243. package/src/job.ts +0 -20
  244. package/tsconfig.json +0 -9
  245. package/tsconfig.tsbuildinfo +0 -1
@@ -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
+ }
@@ -7,18 +7,25 @@
7
7
 
8
8
  import type { ErrorCategory, TrailsError } from './errors.js';
9
9
  import {
10
+ AuthError,
11
+ CancelledError,
12
+ ConflictError,
10
13
  ValidationError,
14
+ InternalError,
15
+ NetworkError,
11
16
  NotFoundError,
12
- ConflictError,
13
17
  PermissionError,
14
- TimeoutError,
15
18
  RateLimitError,
16
- NetworkError,
17
- InternalError,
18
- AuthError,
19
- CancelledError,
19
+ RetryExhaustedError,
20
+ TimeoutError,
21
+ errorClasses,
20
22
  isTrailsError,
21
23
  } from './errors.js';
24
+ import {
25
+ redactErrorContext,
26
+ redactErrorStack,
27
+ redactErrorString,
28
+ } from './error-projection.js';
22
29
  import { Result } from './result.js';
23
30
 
24
31
  // ---------------------------------------------------------------------------
@@ -28,7 +35,10 @@ import { Result } from './result.js';
28
35
  export interface SerializedError {
29
36
  readonly name: string;
30
37
  readonly message: string;
38
+ readonly attempts?: number | undefined;
31
39
  readonly category?: ErrorCategory | undefined;
40
+ readonly cause?: SerializedError | undefined;
41
+ readonly detour?: string | undefined;
32
42
  readonly retryable?: boolean | undefined;
33
43
  readonly retryAfter?: number | undefined;
34
44
  readonly context?: Record<string, unknown> | undefined;
@@ -53,10 +63,15 @@ const buildOpts = (
53
63
 
54
64
  type ErrorFactory = (
55
65
  message: string,
56
- opts: { context?: Record<string, unknown> },
66
+ opts: { cause?: Error; context?: Record<string, unknown> },
57
67
  retryAfter: number | undefined
58
68
  ) => TrailsError;
59
69
 
70
+ type FixedErrorConstructor = new (
71
+ message: string,
72
+ options?: { cause?: Error; context?: Record<string, unknown> }
73
+ ) => TrailsError;
74
+
60
75
  const errorFactories: Record<ErrorCategory, ErrorFactory> = {
61
76
  auth: (msg, opts) => new AuthError(msg, opts),
62
77
  cancelled: (msg, opts) => new CancelledError(msg, opts),
@@ -89,6 +104,43 @@ const createErrorByCategory = (
89
104
  return factory(message, opts, retryAfter);
90
105
  };
91
106
 
107
+ /** Map fixed error class names to constructors for precise round-tripping. */
108
+ const errorConstructorsByName: Readonly<Record<string, ErrorFactory>> =
109
+ Object.fromEntries(
110
+ errorClasses.flatMap((entry): [string, ErrorFactory][] => {
111
+ if (entry.category === 'dynamic') {
112
+ return [];
113
+ }
114
+ const ctor = entry.ctor as FixedErrorConstructor;
115
+ return [
116
+ [
117
+ entry.name,
118
+ (message, opts, retryAfter) => {
119
+ if (ctor === RateLimitError) {
120
+ const rateLimitOptions:
121
+ | {
122
+ cause?: Error;
123
+ context?: Record<string, unknown>;
124
+ retryAfter?: number;
125
+ }
126
+ | undefined =
127
+ opts.context === undefined &&
128
+ opts.cause === undefined &&
129
+ retryAfter === undefined
130
+ ? undefined
131
+ : {
132
+ ...opts,
133
+ ...(retryAfter === undefined ? {} : { retryAfter }),
134
+ };
135
+ return new RateLimitError(message, rateLimitOptions);
136
+ }
137
+ return new ctor(message, opts);
138
+ },
139
+ ],
140
+ ];
141
+ })
142
+ );
143
+
92
144
  // ---------------------------------------------------------------------------
93
145
  // Error serialization
94
146
  // ---------------------------------------------------------------------------
@@ -96,16 +148,23 @@ const createErrorByCategory = (
96
148
  /** Extract structured data from an Error for transport. */
97
149
  export const serializeError = (error: Error): SerializedError => {
98
150
  const result: SerializedError = {
99
- message: error.message,
151
+ message: redactErrorString(error.message),
100
152
  name: error.name,
101
- stack: error.stack,
153
+ stack: redactErrorStack(error.stack),
102
154
  };
103
155
 
104
156
  if (isTrailsError(error)) {
105
157
  return {
106
158
  ...result,
107
159
  category: error.category,
108
- context: error.context,
160
+ context: redactErrorContext(error.context),
161
+ ...(error instanceof RetryExhaustedError
162
+ ? {
163
+ attempts: error.attempts,
164
+ cause: serializeError(error.cause),
165
+ detour: error.detour,
166
+ }
167
+ : {}),
109
168
  retryAfter:
110
169
  error instanceof RateLimitError ? error.retryAfter : undefined,
111
170
  retryable: error.retryable,
@@ -117,13 +176,39 @@ export const serializeError = (error: Error): SerializedError => {
117
176
 
118
177
  /** Reconstruct a TrailsError from serialized data. */
119
178
  export const deserializeError = (data: SerializedError): TrailsError => {
120
- const category = data.category ?? 'internal';
121
- const error = createErrorByCategory(
122
- category,
123
- data.message,
124
- data.context,
125
- data.retryAfter
126
- );
179
+ const opts = buildOpts(data.context);
180
+ if (data.name === 'RetryExhaustedError') {
181
+ const wrapped =
182
+ data.cause === undefined
183
+ ? createErrorByCategory(
184
+ data.category ?? 'internal',
185
+ data.message,
186
+ data.context,
187
+ data.retryAfter
188
+ )
189
+ : deserializeError(data.cause);
190
+ const error = new RetryExhaustedError(wrapped, {
191
+ attempts: data.attempts ?? 0,
192
+ detour: data.detour ?? 'unknown',
193
+ });
194
+ if (data.message !== error.message) {
195
+ error.message = data.message;
196
+ }
197
+ if (data.stack) {
198
+ error.stack = data.stack;
199
+ }
200
+ return error;
201
+ }
202
+ const nameFactory = errorConstructorsByName[data.name];
203
+
204
+ const error = nameFactory
205
+ ? nameFactory(data.message, opts, data.retryAfter)
206
+ : createErrorByCategory(
207
+ data.category ?? 'internal',
208
+ data.message,
209
+ data.context,
210
+ data.retryAfter
211
+ );
127
212
 
128
213
  if (data.stack) {
129
214
  error.stack = data.stack;
@@ -132,36 +217,31 @@ export const deserializeError = (data: SerializedError): TrailsError => {
132
217
  return error;
133
218
  };
134
219
 
135
- // ---------------------------------------------------------------------------
136
- // Safe JSON
137
- // ---------------------------------------------------------------------------
138
-
139
- /** Parse a JSON string, returning a Result instead of throwing. */
140
- export const safeParse = (json: string): Result<unknown, ValidationError> => {
141
- try {
142
- return Result.ok(JSON.parse(json) as unknown);
143
- } catch (error) {
144
- return Result.err(
145
- new ValidationError('Invalid JSON', {
146
- cause: error instanceof Error ? error : new Error(String(error)),
147
- context: { input: json.slice(0, 200) },
148
- })
149
- );
150
- }
151
- };
152
-
153
220
  /** Stringify a value, returning a Result. Handles circular references. */
154
221
  export const safeStringify = (
155
222
  value: unknown
156
223
  ): Result<string, InternalError> => {
157
224
  try {
158
- const seen = new WeakSet();
159
- const json = JSON.stringify(value, (_key, val: unknown) => {
225
+ // Track the current ancestor chain, not every object ever visited.
226
+ // This allows shared references in a DAG while still detecting cycles.
227
+ const stack: unknown[] = [];
228
+ const keys: string[] = [];
229
+
230
+ const json = JSON.stringify(value, function json(key, val: unknown) {
231
+ if (stack.length > 0) {
232
+ // `this` is the object that contains `key`. Trim the stack back
233
+ // to `this` so we only track the current ancestor path.
234
+ const thisIndex = stack.lastIndexOf(this as unknown);
235
+ stack.splice(thisIndex + 1);
236
+ keys.splice(thisIndex);
237
+ }
238
+
160
239
  if (typeof val === 'object' && val !== null) {
161
- if (seen.has(val)) {
240
+ if (stack.includes(val)) {
162
241
  return '[Circular]';
163
242
  }
164
- seen.add(val);
243
+ stack.push(val);
244
+ keys.push(key);
165
245
  }
166
246
  return val;
167
247
  });