@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.
Files changed (216) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +179 -0
  6. package/dist/adapters.d.ts +39 -0
  7. package/dist/adapters.d.ts.map +1 -0
  8. package/dist/adapters.js +2 -0
  9. package/dist/adapters.js.map +1 -0
  10. package/dist/blob-ref.d.ts +20 -0
  11. package/dist/blob-ref.d.ts.map +1 -0
  12. package/dist/blob-ref.js +22 -0
  13. package/dist/blob-ref.js.map +1 -0
  14. package/dist/branded.d.ts +36 -0
  15. package/dist/branded.d.ts.map +1 -0
  16. package/dist/branded.js +89 -0
  17. package/dist/branded.js.map +1 -0
  18. package/dist/collections.d.ts +31 -0
  19. package/dist/collections.d.ts.map +1 -0
  20. package/dist/collections.js +60 -0
  21. package/dist/collections.js.map +1 -0
  22. package/dist/context.d.ts +10 -0
  23. package/dist/context.d.ts.map +1 -0
  24. package/dist/context.js +15 -0
  25. package/dist/context.js.map +1 -0
  26. package/dist/derive.d.ts +33 -0
  27. package/dist/derive.d.ts.map +1 -0
  28. package/dist/derive.js +122 -0
  29. package/dist/derive.js.map +1 -0
  30. package/dist/errors.d.ts +83 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +142 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/event.d.ts +45 -0
  35. package/dist/event.d.ts.map +1 -0
  36. package/dist/event.js +17 -0
  37. package/dist/event.js.map +1 -0
  38. package/dist/fetch.d.ts +15 -0
  39. package/dist/fetch.d.ts.map +1 -0
  40. package/dist/fetch.js +102 -0
  41. package/dist/fetch.js.map +1 -0
  42. package/dist/guards.d.ts +17 -0
  43. package/dist/guards.d.ts.map +1 -0
  44. package/dist/guards.js +25 -0
  45. package/dist/guards.js.map +1 -0
  46. package/dist/health.d.ts +18 -0
  47. package/dist/health.d.ts.map +1 -0
  48. package/dist/health.js +5 -0
  49. package/dist/health.js.map +1 -0
  50. package/dist/hike.d.ts +36 -0
  51. package/dist/hike.d.ts.map +1 -0
  52. package/dist/hike.js +20 -0
  53. package/dist/hike.js.map +1 -0
  54. package/dist/index.d.ts +34 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +38 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/job.d.ts +24 -0
  59. package/dist/job.d.ts.map +1 -0
  60. package/dist/job.js +17 -0
  61. package/dist/job.js.map +1 -0
  62. package/dist/layer.d.ts +17 -0
  63. package/dist/layer.d.ts.map +1 -0
  64. package/dist/layer.js +21 -0
  65. package/dist/layer.js.map +1 -0
  66. package/dist/path-security.d.ts +28 -0
  67. package/dist/path-security.d.ts.map +1 -0
  68. package/dist/path-security.js +63 -0
  69. package/dist/path-security.js.map +1 -0
  70. package/dist/patterns/bulk.d.ts +15 -0
  71. package/dist/patterns/bulk.d.ts.map +1 -0
  72. package/dist/patterns/bulk.js +14 -0
  73. package/dist/patterns/bulk.js.map +1 -0
  74. package/dist/patterns/change.d.ts +10 -0
  75. package/dist/patterns/change.d.ts.map +1 -0
  76. package/dist/patterns/change.js +10 -0
  77. package/dist/patterns/change.js.map +1 -0
  78. package/dist/patterns/date-range.d.ts +10 -0
  79. package/dist/patterns/date-range.d.ts.map +1 -0
  80. package/dist/patterns/date-range.js +10 -0
  81. package/dist/patterns/date-range.js.map +1 -0
  82. package/dist/patterns/index.d.ts +9 -0
  83. package/dist/patterns/index.d.ts.map +1 -0
  84. package/dist/patterns/index.js +9 -0
  85. package/dist/patterns/index.js.map +1 -0
  86. package/dist/patterns/pagination.d.ts +18 -0
  87. package/dist/patterns/pagination.d.ts.map +1 -0
  88. package/dist/patterns/pagination.js +18 -0
  89. package/dist/patterns/pagination.js.map +1 -0
  90. package/dist/patterns/progress.d.ts +11 -0
  91. package/dist/patterns/progress.d.ts.map +1 -0
  92. package/dist/patterns/progress.js +11 -0
  93. package/dist/patterns/progress.js.map +1 -0
  94. package/dist/patterns/sorting.d.ts +13 -0
  95. package/dist/patterns/sorting.d.ts.map +1 -0
  96. package/dist/patterns/sorting.js +10 -0
  97. package/dist/patterns/sorting.js.map +1 -0
  98. package/dist/patterns/status.d.ts +15 -0
  99. package/dist/patterns/status.d.ts.map +1 -0
  100. package/dist/patterns/status.js +9 -0
  101. package/dist/patterns/status.js.map +1 -0
  102. package/dist/patterns/timestamps.d.ts +10 -0
  103. package/dist/patterns/timestamps.d.ts.map +1 -0
  104. package/dist/patterns/timestamps.js +10 -0
  105. package/dist/patterns/timestamps.js.map +1 -0
  106. package/dist/redaction/index.d.ts +4 -0
  107. package/dist/redaction/index.d.ts.map +1 -0
  108. package/dist/redaction/index.js +3 -0
  109. package/dist/redaction/index.js.map +1 -0
  110. package/dist/redaction/patterns.d.ts +9 -0
  111. package/dist/redaction/patterns.d.ts.map +1 -0
  112. package/dist/redaction/patterns.js +39 -0
  113. package/dist/redaction/patterns.js.map +1 -0
  114. package/dist/redaction/redactor.d.ts +27 -0
  115. package/dist/redaction/redactor.d.ts.map +1 -0
  116. package/dist/redaction/redactor.js +89 -0
  117. package/dist/redaction/redactor.js.map +1 -0
  118. package/dist/resilience.d.ts +34 -0
  119. package/dist/resilience.d.ts.map +1 -0
  120. package/dist/resilience.js +164 -0
  121. package/dist/resilience.js.map +1 -0
  122. package/dist/result.d.ts +57 -0
  123. package/dist/result.d.ts.map +1 -0
  124. package/dist/result.js +145 -0
  125. package/dist/result.js.map +1 -0
  126. package/dist/serialization.d.ts +27 -0
  127. package/dist/serialization.d.ts.map +1 -0
  128. package/dist/serialization.js +115 -0
  129. package/dist/serialization.js.map +1 -0
  130. package/dist/topo.d.ts +18 -0
  131. package/dist/topo.d.ts.map +1 -0
  132. package/dist/topo.js +74 -0
  133. package/dist/topo.js.map +1 -0
  134. package/dist/trail.d.ts +83 -0
  135. package/dist/trail.d.ts.map +1 -0
  136. package/dist/trail.js +16 -0
  137. package/dist/trail.js.map +1 -0
  138. package/dist/types.d.ts +46 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +2 -0
  141. package/dist/types.js.map +1 -0
  142. package/dist/validate-topo.d.ts +24 -0
  143. package/dist/validate-topo.d.ts.map +1 -0
  144. package/dist/validate-topo.js +108 -0
  145. package/dist/validate-topo.js.map +1 -0
  146. package/dist/validation.d.ts +27 -0
  147. package/dist/validation.d.ts.map +1 -0
  148. package/dist/validation.js +134 -0
  149. package/dist/validation.js.map +1 -0
  150. package/dist/workspace.d.ts +25 -0
  151. package/dist/workspace.d.ts.map +1 -0
  152. package/dist/workspace.js +57 -0
  153. package/dist/workspace.js.map +1 -0
  154. package/package.json +21 -0
  155. package/src/__tests__/blob-ref.test.ts +103 -0
  156. package/src/__tests__/branded.test.ts +148 -0
  157. package/src/__tests__/collections.test.ts +126 -0
  158. package/src/__tests__/context.test.ts +66 -0
  159. package/src/__tests__/derive.test.ts +159 -0
  160. package/src/__tests__/errors.test.ts +309 -0
  161. package/src/__tests__/event.test.ts +82 -0
  162. package/src/__tests__/fetch.test.ts +217 -0
  163. package/src/__tests__/guards.test.ts +102 -0
  164. package/src/__tests__/hike.test.ts +117 -0
  165. package/src/__tests__/job.test.ts +98 -0
  166. package/src/__tests__/layer.test.ts +224 -0
  167. package/src/__tests__/path-security.test.ts +114 -0
  168. package/src/__tests__/patterns.test.ts +273 -0
  169. package/src/__tests__/redaction.test.ts +244 -0
  170. package/src/__tests__/resilience.test.ts +246 -0
  171. package/src/__tests__/result.test.ts +155 -0
  172. package/src/__tests__/serialization.test.ts +236 -0
  173. package/src/__tests__/topo.test.ts +184 -0
  174. package/src/__tests__/trail.test.ts +179 -0
  175. package/src/__tests__/validate-topo.test.ts +201 -0
  176. package/src/__tests__/validation.test.ts +283 -0
  177. package/src/__tests__/workspace.test.ts +183 -0
  178. package/src/adapters.ts +68 -0
  179. package/src/blob-ref.ts +39 -0
  180. package/src/branded.ts +135 -0
  181. package/src/collections.ts +99 -0
  182. package/src/context.ts +18 -0
  183. package/src/derive.ts +223 -0
  184. package/src/errors.ts +196 -0
  185. package/src/event.ts +77 -0
  186. package/src/fetch.ts +138 -0
  187. package/src/guards.ts +37 -0
  188. package/src/health.ts +23 -0
  189. package/src/hike.ts +77 -0
  190. package/src/index.ts +158 -0
  191. package/src/job.ts +20 -0
  192. package/src/layer.ts +44 -0
  193. package/src/path-security.ts +90 -0
  194. package/src/patterns/bulk.ts +16 -0
  195. package/src/patterns/change.ts +12 -0
  196. package/src/patterns/date-range.ts +12 -0
  197. package/src/patterns/index.ts +8 -0
  198. package/src/patterns/pagination.ts +22 -0
  199. package/src/patterns/progress.ts +13 -0
  200. package/src/patterns/sorting.ts +14 -0
  201. package/src/patterns/status.ts +11 -0
  202. package/src/patterns/timestamps.ts +12 -0
  203. package/src/redaction/index.ts +3 -0
  204. package/src/redaction/patterns.ts +47 -0
  205. package/src/redaction/redactor.ts +178 -0
  206. package/src/resilience.ts +234 -0
  207. package/src/result.ts +180 -0
  208. package/src/serialization.ts +183 -0
  209. package/src/topo.ts +123 -0
  210. package/src/trail.ts +130 -0
  211. package/src/types.ts +58 -0
  212. package/src/validate-topo.ts +151 -0
  213. package/src/validation.ts +182 -0
  214. package/src/workspace.ts +77 -0
  215. package/tsconfig.json +9 -0
  216. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Resilience utilities for @ontrails/core
3
+ *
4
+ * Retry with exponential backoff and timeout wrappers,
5
+ * all returning Result types.
6
+ */
7
+
8
+ import { CancelledError, TimeoutError, isRetryable } from './errors.js';
9
+ import { Result } from './result.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // RetryOptions
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface RetryOptions {
16
+ /** Maximum number of attempts (default: 3) */
17
+ readonly maxAttempts?: number | undefined;
18
+ /** Base delay in ms before first retry (default: 1000) */
19
+ readonly baseDelay?: number | undefined;
20
+ /** Maximum delay in ms (default: 30000) */
21
+ readonly maxDelay?: number | undefined;
22
+ /** Exponential backoff factor (default: 2) */
23
+ readonly backoffFactor?: number | undefined;
24
+ /** Custom predicate — defaults to isRetryable from error taxonomy */
25
+ readonly shouldRetry?: ((error: Error) => boolean) | undefined;
26
+ /** AbortSignal for cancellation */
27
+ readonly signal?: AbortSignal | undefined;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers (defined before usage)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
35
+ // oxlint-disable-next-line avoid-new -- Promise constructor needed for setTimeout-based sleep
36
+ new Promise((resolve) => {
37
+ if (signal?.aborted) {
38
+ resolve();
39
+ return;
40
+ }
41
+ let settled = false;
42
+ const done = () => {
43
+ if (!settled) {
44
+ settled = true;
45
+ resolve();
46
+ }
47
+ };
48
+ const timer = setTimeout(done, ms);
49
+ const onAbort = () => {
50
+ clearTimeout(timer);
51
+ done();
52
+ };
53
+ signal?.addEventListener('abort', onAbort, { once: true });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // shouldRetry (default)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Default retry predicate using the error taxonomy. */
61
+ export const shouldRetry = (error: Error): boolean => isRetryable(error);
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // getBackoffDelay
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** Compute exponential backoff delay with full jitter. */
68
+ export const getBackoffDelay = (
69
+ attempt: number,
70
+ options?: Pick<RetryOptions, 'baseDelay' | 'maxDelay' | 'backoffFactor'>
71
+ ): number => {
72
+ const base = options?.baseDelay ?? 1000;
73
+ const max = options?.maxDelay ?? 30_000;
74
+ const factor = options?.backoffFactor ?? 2;
75
+ const exponential = base * factor ** attempt;
76
+ const capped = Math.min(exponential, max);
77
+ // Full jitter: random value in [0, capped]
78
+ return Math.floor(Math.random() * capped);
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // retry
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Retry an async function that returns a Result.
87
+ *
88
+ * On each failure, checks whether the error is retryable and whether the
89
+ * attempt budget remains. Applies exponential backoff with jitter between
90
+ * retries.
91
+ */
92
+ /** Attempt a single retry iteration. Returns the result to stop, or undefined to continue. */
93
+ const tryAttempt = async <T>(
94
+ fn: () => Promise<Result<T, Error>>,
95
+ attempt: number,
96
+ maxAttempts: number,
97
+ retryPredicate: (error: Error) => boolean,
98
+ options?: RetryOptions
99
+ ): Promise<
100
+ | { done: true; result: Result<T, Error> }
101
+ | { done: false; result: Result<T, Error> }
102
+ > => {
103
+ const result = await fn();
104
+ if (result.isOk()) {
105
+ return { done: true, result };
106
+ }
107
+ const isLast = attempt === maxAttempts - 1;
108
+ if (isLast || !retryPredicate(result.error)) {
109
+ return { done: true, result };
110
+ }
111
+ const delay = getBackoffDelay(attempt, options);
112
+ if (delay > 0) {
113
+ await sleep(delay, options?.signal);
114
+ }
115
+ return { done: false, result };
116
+ };
117
+
118
+ /** Resolve retry options to concrete values. */
119
+ const resolveRetryOptions = (options?: RetryOptions) => ({
120
+ maxAttempts: options?.maxAttempts ?? 3,
121
+ retryPredicate: options?.shouldRetry ?? shouldRetry,
122
+ signal: options?.signal,
123
+ });
124
+
125
+ export const retry = async <T>(
126
+ fn: () => Promise<Result<T, Error>>,
127
+ options?: RetryOptions
128
+ ): Promise<Result<T, Error>> => {
129
+ const { maxAttempts, retryPredicate, signal } = resolveRetryOptions(options);
130
+ let lastResult: Result<T, Error> | undefined;
131
+
132
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
133
+ if (signal?.aborted) {
134
+ return Result.err(new CancelledError('Retry cancelled'));
135
+ }
136
+ const outcome = await tryAttempt(
137
+ fn,
138
+ attempt,
139
+ maxAttempts,
140
+ retryPredicate,
141
+ options
142
+ );
143
+ lastResult = outcome.result;
144
+ if (outcome.done) {
145
+ return outcome.result;
146
+ }
147
+ }
148
+
149
+ return lastResult ?? Result.err(new CancelledError('Retry exhausted'));
150
+ };
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // withTimeout
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Run an async Result-returning function with a timeout.
158
+ *
159
+ * If the timeout fires first, returns a TimeoutError result.
160
+ * Also respects an external AbortSignal.
161
+ */
162
+ export const withTimeout = <T>(
163
+ fn: () => Promise<Result<T, Error>>,
164
+ ms: number,
165
+ signal?: AbortSignal
166
+ ): Promise<Result<T, Error>> => {
167
+ if (signal?.aborted) {
168
+ return Promise.resolve(Result.err(new CancelledError('Already cancelled')));
169
+ }
170
+
171
+ // oxlint-disable-next-line avoid-new, promise/no-multiple-resolved -- Promise constructor needed for timeout race; settled guard ensures single resolution
172
+ return new Promise<Result<T, Error>>((resolve) => {
173
+ let settled = false;
174
+ // oxlint-disable-next-line prefer-const -- assigned after declaration
175
+ let timer: ReturnType<typeof setTimeout>;
176
+
177
+ const onAbort = () => {
178
+ if (settled) {
179
+ return;
180
+ }
181
+ settled = true;
182
+ clearTimeout(timer);
183
+ signal?.removeEventListener('abort', onAbort);
184
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
185
+ resolve(Result.err(new CancelledError('Operation cancelled')));
186
+ };
187
+
188
+ signal?.addEventListener('abort', onAbort, { once: true });
189
+
190
+ timer = setTimeout(() => {
191
+ if (settled) {
192
+ return;
193
+ }
194
+ settled = true;
195
+ signal?.removeEventListener('abort', onAbort);
196
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
197
+ resolve(
198
+ Result.err(
199
+ new TimeoutError(`Operation timed out after ${ms}ms`, {
200
+ context: { timeoutMs: ms },
201
+ })
202
+ )
203
+ );
204
+ }, ms);
205
+
206
+ // oxlint-disable-next-line prefer-await-to-then, no-void -- .then() needed inside Promise constructor; void discards unhandled rejection
207
+ void fn().then(
208
+ // oxlint-disable-next-line prefer-await-to-callbacks -- callback required inside .then()
209
+ (result) => {
210
+ if (settled) {
211
+ return;
212
+ }
213
+ settled = true;
214
+ clearTimeout(timer);
215
+ signal?.removeEventListener('abort', onAbort);
216
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
217
+ resolve(result);
218
+ },
219
+ // oxlint-disable-next-line prefer-await-to-callbacks -- rejection handler required inside .then()
220
+ (error: unknown) => {
221
+ if (settled) {
222
+ return;
223
+ }
224
+ settled = true;
225
+ clearTimeout(timer);
226
+ signal?.removeEventListener('abort', onAbort);
227
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
228
+ resolve(
229
+ Result.err(error instanceof Error ? error : new Error(String(error)))
230
+ );
231
+ }
232
+ );
233
+ });
234
+ };
package/src/result.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * A type-safe Result monad for representing success/failure without exceptions.
3
+ */
4
+
5
+ import { InternalError, ValidationError } from './errors.js';
6
+
7
+ class Ok<T, E> {
8
+ readonly value: T;
9
+
10
+ constructor(value: T) {
11
+ this.value = value;
12
+ }
13
+
14
+ // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
15
+ isOk(): this is Ok<T, E> {
16
+ return true;
17
+ }
18
+
19
+ // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
20
+ isErr(): this is Err<T, E> {
21
+ return false;
22
+ }
23
+
24
+ map<U>(fn: (value: T) => U): Result<U, E> {
25
+ return new Ok(fn(this.value));
26
+ }
27
+
28
+ flatMap<U, F = E>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
29
+ return fn(this.value);
30
+ }
31
+
32
+ mapErr<F>(_fn: (error: E) => F): Result<T, F> {
33
+ return new Ok(this.value);
34
+ }
35
+
36
+ match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
37
+ return handlers.ok(this.value);
38
+ }
39
+
40
+ unwrap(): T {
41
+ return this.value;
42
+ }
43
+
44
+ unwrapOr(_fallback: T): T {
45
+ return this.value;
46
+ }
47
+ }
48
+
49
+ // oxlint-disable-next-line max-classes-per-file -- Result monad requires paired Ok/Err classes
50
+ class Err<T, E> {
51
+ readonly error: E;
52
+
53
+ constructor(error: E) {
54
+ this.error = error;
55
+ }
56
+
57
+ // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
58
+ isOk(): this is Ok<T, E> {
59
+ return false;
60
+ }
61
+
62
+ // oxlint-disable-next-line class-methods-use-this -- type guard for Result discriminated union
63
+ isErr(): this is Err<T, E> {
64
+ return true;
65
+ }
66
+
67
+ map<U>(_fn: (value: T) => U): Result<U, E> {
68
+ return new Err(this.error);
69
+ }
70
+
71
+ flatMap<U, F = E>(_fn: (value: T) => Result<U, F>): Result<U, E | F> {
72
+ return new Err(this.error);
73
+ }
74
+
75
+ mapErr<F>(fn: (error: E) => F): Result<T, F> {
76
+ return new Err(fn(this.error));
77
+ }
78
+
79
+ match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
80
+ return handlers.err(this.error);
81
+ }
82
+
83
+ unwrap(): never {
84
+ throw this.error instanceof Error
85
+ ? this.error
86
+ : new Error(String(this.error));
87
+ }
88
+
89
+ // oxlint-disable-next-line class-methods-use-this -- symmetric API with Ok.unwrapOr
90
+ unwrapOr(fallback: T): T {
91
+ return fallback;
92
+ }
93
+ }
94
+
95
+ export type Result<T, E = Error> = Ok<T, E> | Err<T, E>;
96
+
97
+ // eslint-disable-next-line @typescript-eslint/no-namespace
98
+ export const Result = {
99
+ combine<T, E>(results: readonly Result<T, E>[]): Result<T[], E> {
100
+ const values: T[] = [];
101
+ for (const result of results) {
102
+ if (result.isErr()) {
103
+ return new Err(result.error);
104
+ }
105
+ values.push(result.value);
106
+ }
107
+ return new Ok(values);
108
+ },
109
+
110
+ err<E>(error: E): Result<never, E> {
111
+ return new Err(error);
112
+ },
113
+
114
+ /**
115
+ * Wrap a fetch call in a Result, mapping failures to TrailsError subclasses.
116
+ *
117
+ * Network errors become NetworkError. Abort signals become CancelledError.
118
+ * HTTP error status codes map to the appropriate error category.
119
+ */
120
+ async fromFetch(
121
+ input: string | URL | Request,
122
+ init?: RequestInit
123
+ ): Promise<Result<Response, Error>> {
124
+ // Lazy import avoids a circular dependency (fetch.ts imports Result)
125
+ const { fromFetch: fetchImpl } = await import('./fetch.js');
126
+ return fetchImpl(input, init);
127
+ },
128
+
129
+ /**
130
+ * Parse a JSON string, returning a Result instead of throwing.
131
+ */
132
+ fromJson(json: string): Result<unknown, ValidationError> {
133
+ try {
134
+ return new Ok(JSON.parse(json) as unknown);
135
+ } catch (error) {
136
+ return new Err(
137
+ new ValidationError('Invalid JSON', {
138
+ cause: error instanceof Error ? error : new Error(String(error)),
139
+ context: { input: json.slice(0, 200) },
140
+ })
141
+ );
142
+ }
143
+ },
144
+
145
+ ok<T = void>(value?: T): Result<T, never> {
146
+ return new Ok(value as T);
147
+ },
148
+
149
+ /**
150
+ * Stringify a value to JSON, returning a Result. Handles circular references.
151
+ */
152
+ toJson(value: unknown): Result<string, InternalError> {
153
+ try {
154
+ const seen = new WeakSet();
155
+ const json = JSON.stringify(value, (_key, val: unknown) => {
156
+ if (typeof val === 'object' && val !== null) {
157
+ if (seen.has(val)) {
158
+ return '[Circular]';
159
+ }
160
+ seen.add(val);
161
+ }
162
+ return val;
163
+ });
164
+ if (json === undefined) {
165
+ return new Err(
166
+ new InternalError('Value is not JSON-serializable', {
167
+ context: { type: typeof value },
168
+ })
169
+ );
170
+ }
171
+ return new Ok(json);
172
+ } catch (error) {
173
+ return new Err(
174
+ new InternalError('Failed to stringify value', {
175
+ cause: error instanceof Error ? error : new Error(String(error)),
176
+ })
177
+ );
178
+ }
179
+ },
180
+ } as const;
@@ -0,0 +1,183 @@
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
+ ValidationError,
11
+ NotFoundError,
12
+ ConflictError,
13
+ PermissionError,
14
+ TimeoutError,
15
+ RateLimitError,
16
+ NetworkError,
17
+ InternalError,
18
+ AuthError,
19
+ CancelledError,
20
+ isTrailsError,
21
+ } from './errors.js';
22
+ import { Result } from './result.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // SerializedError interface
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface SerializedError {
29
+ readonly name: string;
30
+ readonly message: string;
31
+ readonly category?: ErrorCategory | undefined;
32
+ readonly retryable?: boolean | undefined;
33
+ readonly retryAfter?: number | undefined;
34
+ readonly context?: Record<string, unknown> | undefined;
35
+ readonly stack?: string | undefined;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Internal helpers (defined before usage)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Build options object without including undefined context. */
43
+ const buildOpts = (
44
+ context: Record<string, unknown> | undefined
45
+ ): {
46
+ context?: Record<string, unknown>;
47
+ } => {
48
+ if (context !== undefined) {
49
+ return { context };
50
+ }
51
+ return {};
52
+ };
53
+
54
+ type ErrorFactory = (
55
+ message: string,
56
+ opts: { context?: Record<string, unknown> },
57
+ retryAfter: number | undefined
58
+ ) => TrailsError;
59
+
60
+ const errorFactories: Record<ErrorCategory, ErrorFactory> = {
61
+ auth: (msg, opts) => new AuthError(msg, opts),
62
+ cancelled: (msg, opts) => new CancelledError(msg, opts),
63
+ conflict: (msg, opts) => new ConflictError(msg, opts),
64
+ internal: (msg, opts) => new InternalError(msg, opts),
65
+ network: (msg, opts) => new NetworkError(msg, opts),
66
+ not_found: (msg, opts) => new NotFoundError(msg, opts),
67
+ permission: (msg, opts) => new PermissionError(msg, opts),
68
+ rate_limit: (msg, opts, retryAfter) => {
69
+ const rlOpts: { context?: Record<string, unknown>; retryAfter?: number } = {
70
+ ...opts,
71
+ };
72
+ if (retryAfter !== undefined) {
73
+ rlOpts.retryAfter = retryAfter;
74
+ }
75
+ return new RateLimitError(msg, rlOpts);
76
+ },
77
+ timeout: (msg, opts) => new TimeoutError(msg, opts),
78
+ validation: (msg, opts) => new ValidationError(msg, opts),
79
+ };
80
+
81
+ const createErrorByCategory = (
82
+ category: ErrorCategory,
83
+ message: string,
84
+ context: Record<string, unknown> | undefined,
85
+ retryAfter: number | undefined
86
+ ): TrailsError => {
87
+ const opts = buildOpts(context);
88
+ const factory = errorFactories[category] ?? errorFactories.internal;
89
+ return factory(message, opts, retryAfter);
90
+ };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Error serialization
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /** Extract structured data from an Error for transport. */
97
+ export const serializeError = (error: Error): SerializedError => {
98
+ const result: SerializedError = {
99
+ message: error.message,
100
+ name: error.name,
101
+ stack: error.stack,
102
+ };
103
+
104
+ if (isTrailsError(error)) {
105
+ return {
106
+ ...result,
107
+ category: error.category,
108
+ context: error.context,
109
+ retryAfter:
110
+ error instanceof RateLimitError ? error.retryAfter : undefined,
111
+ retryable: error.retryable,
112
+ };
113
+ }
114
+
115
+ return result;
116
+ };
117
+
118
+ /** Reconstruct a TrailsError from serialized data. */
119
+ 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
+ );
127
+
128
+ if (data.stack) {
129
+ error.stack = data.stack;
130
+ }
131
+
132
+ return error;
133
+ };
134
+
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
+ /** Stringify a value, returning a Result. Handles circular references. */
154
+ export const safeStringify = (
155
+ value: unknown
156
+ ): Result<string, InternalError> => {
157
+ try {
158
+ const seen = new WeakSet();
159
+ const json = JSON.stringify(value, (_key, val: unknown) => {
160
+ if (typeof val === 'object' && val !== null) {
161
+ if (seen.has(val)) {
162
+ return '[Circular]';
163
+ }
164
+ seen.add(val);
165
+ }
166
+ return val;
167
+ });
168
+ if (json === undefined) {
169
+ return Result.err(
170
+ new InternalError('Value is not JSON-serializable', {
171
+ context: { type: typeof value },
172
+ })
173
+ );
174
+ }
175
+ return Result.ok(json);
176
+ } catch (error) {
177
+ return Result.err(
178
+ new InternalError('Failed to stringify value', {
179
+ cause: error instanceof Error ? error : new Error(String(error)),
180
+ })
181
+ );
182
+ }
183
+ };