@distilled.cloud/core 0.0.0 → 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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Error Category System
3
+ *
4
+ * Provides a unified error classification system across all SDKs.
5
+ * Error classes are decorated with categories using `.pipe()` on Schema.TaggedError classes,
6
+ * enabling semantic error handling (e.g., catch all auth errors regardless of provider).
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import * as Category from "@distilled.cloud/sdk-core/category";
11
+ *
12
+ * export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
13
+ * "Unauthorized",
14
+ * { message: Schema.String },
15
+ * ).pipe(Category.withAuthError) {}
16
+ *
17
+ * // Catch by category
18
+ * effect.pipe(Category.catchAuthError((err) => Effect.succeed("handled")))
19
+ * ```
20
+ */
21
+ import * as Effect from "effect/Effect";
22
+ import * as Predicate from "effect/Predicate";
23
+
24
+ // ============================================================================
25
+ // Error Category Constants
26
+ // ============================================================================
27
+
28
+ export const AuthError = "AuthError";
29
+ export const BadRequestError = "BadRequestError";
30
+ export const ConflictError = "ConflictError";
31
+ export const NotFoundError = "NotFoundError";
32
+ export const QuotaError = "QuotaError";
33
+ export const ServerError = "ServerError";
34
+ export const ThrottlingError = "ThrottlingError";
35
+ export const NetworkError = "NetworkError";
36
+ export const ParseError = "ParseError";
37
+ export const ConfigurationError = "ConfigurationError";
38
+ export const TimeoutError = "TimeoutError";
39
+ export const RetryableError = "RetryableError";
40
+ export const LockedError = "LockedError";
41
+ export const AbortedError = "AbortedError";
42
+ export const AlreadyExistsError = "AlreadyExistsError";
43
+ export const DependencyViolationError = "DependencyViolationError";
44
+
45
+ export type Category =
46
+ | typeof AuthError
47
+ | typeof BadRequestError
48
+ | typeof ConflictError
49
+ | typeof NotFoundError
50
+ | typeof QuotaError
51
+ | typeof ServerError
52
+ | typeof ThrottlingError
53
+ | typeof NetworkError
54
+ | typeof ParseError
55
+ | typeof ConfigurationError
56
+ | typeof TimeoutError
57
+ | typeof RetryableError
58
+ | typeof LockedError
59
+ | typeof AbortedError
60
+ | typeof AlreadyExistsError
61
+ | typeof DependencyViolationError;
62
+
63
+ // ============================================================================
64
+ // Category Storage Key
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Key for storing categories on error prototypes.
69
+ * Shared across all SDKs so category checking works uniformly.
70
+ */
71
+ export const categoriesKey = "@distilled.cloud/error/categories";
72
+
73
+ /**
74
+ * Key for storing retryable trait on error prototypes.
75
+ * Separate from categories - indicates this error should be retried.
76
+ */
77
+ export const retryableKey = "@distilled.cloud/error/retryable";
78
+
79
+ export interface RetryableInfo {
80
+ /** If true, this is a throttling error (use longer backoff) */
81
+ throttling?: boolean;
82
+ }
83
+
84
+ // ============================================================================
85
+ // Category Decorator
86
+ // ============================================================================
87
+
88
+ /**
89
+ * Add one or more categories to an error class.
90
+ * Use with .pipe() on Schema.TaggedError classes.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * export class MyError extends Schema.TaggedErrorClass<MyError>()(
95
+ * "MyError",
96
+ * { message: Schema.String },
97
+ * ).pipe(Category.withCategory(Category.AuthError)) {}
98
+ * ```
99
+ */
100
+ export const withCategory =
101
+ <Categories extends Array<Category>>(...categories: Categories) =>
102
+ <Args extends Array<any>, Ret, C extends { new (...args: Args): Ret }>(
103
+ C: C,
104
+ ): C & {
105
+ new (
106
+ ...args: Args
107
+ ): Ret & { [categoriesKey]: { [Cat in Categories[number]]: true } };
108
+ } => {
109
+ for (const category of categories) {
110
+ if (!(categoriesKey in C.prototype)) {
111
+ C.prototype[categoriesKey] = {};
112
+ }
113
+ C.prototype[categoriesKey][category] = true;
114
+ }
115
+ return C as any;
116
+ };
117
+
118
+ /**
119
+ * Mark an error class as retryable.
120
+ * Use with .pipe() on Schema.TaggedError classes.
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * // Standard retryable error
125
+ * export class TransientError extends Schema.TaggedErrorClass<TransientError>()(
126
+ * "TransientError",
127
+ * { message: Schema.String },
128
+ * ).pipe(Category.withRetryable()) {}
129
+ *
130
+ * // Throttling error (uses longer backoff)
131
+ * export class RateLimitError extends Schema.TaggedErrorClass<RateLimitError>()(
132
+ * "RateLimitError",
133
+ * { message: Schema.String },
134
+ * ).pipe(Category.withRetryable({ throttling: true })) {}
135
+ * ```
136
+ */
137
+ export const withRetryable =
138
+ (info: RetryableInfo = {}) =>
139
+ <Args extends Array<any>, Ret, C extends { new (...args: Args): Ret }>(
140
+ C: C,
141
+ ): C & {
142
+ new (...args: Args): Ret & { [retryableKey]: RetryableInfo };
143
+ } => {
144
+ C.prototype[retryableKey] = info;
145
+ return C as any;
146
+ };
147
+
148
+ // ============================================================================
149
+ // Category Decorators (convenience functions for common categories)
150
+ // ============================================================================
151
+
152
+ export const withAuthError = withCategory(AuthError);
153
+ export const withBadRequestError = withCategory(BadRequestError);
154
+ export const withConflictError = withCategory(ConflictError);
155
+ export const withNotFoundError = withCategory(NotFoundError);
156
+ export const withQuotaError = withCategory(QuotaError);
157
+ export const withServerError = withCategory(ServerError);
158
+ export const withThrottlingError = withCategory(ThrottlingError);
159
+ export const withNetworkError = withCategory(NetworkError);
160
+ export const withParseError = withCategory(ParseError);
161
+ export const withConfigurationError = withCategory(ConfigurationError);
162
+ export const withTimeoutError = withCategory(TimeoutError);
163
+ export const withRetryableError = withCategory(RetryableError);
164
+ export const withLockedError = withCategory(LockedError);
165
+ export const withAbortedError = withCategory(AbortedError);
166
+ export const withAlreadyExistsError = withCategory(AlreadyExistsError);
167
+ export const withDependencyViolationError = withCategory(
168
+ DependencyViolationError,
169
+ );
170
+
171
+ // ============================================================================
172
+ // Category Predicates
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Check if an error has a specific category.
177
+ */
178
+ export const hasCategory = (error: unknown, category: Category): boolean => {
179
+ if (
180
+ Predicate.isObject(error) &&
181
+ Predicate.hasProperty(categoriesKey)(error)
182
+ ) {
183
+ // @ts-expect-error - dynamic property access
184
+ return category in error[categoriesKey];
185
+ }
186
+ return false;
187
+ };
188
+
189
+ export const isAuthError = (error: unknown): boolean =>
190
+ hasCategory(error, AuthError);
191
+
192
+ export const isBadRequestError = (error: unknown): boolean =>
193
+ hasCategory(error, BadRequestError);
194
+
195
+ export const isConflictError = (error: unknown): boolean =>
196
+ hasCategory(error, ConflictError);
197
+
198
+ export const isNotFoundError = (error: unknown): boolean =>
199
+ hasCategory(error, NotFoundError);
200
+
201
+ export const isQuotaError = (error: unknown): boolean =>
202
+ hasCategory(error, QuotaError);
203
+
204
+ export const isServerError = (error: unknown): boolean =>
205
+ hasCategory(error, ServerError);
206
+
207
+ export const isThrottlingError = (error: unknown): boolean =>
208
+ hasCategory(error, ThrottlingError);
209
+
210
+ export const isNetworkError = (error: unknown): boolean =>
211
+ hasCategory(error, NetworkError);
212
+
213
+ export const isParseError = (error: unknown): boolean =>
214
+ hasCategory(error, ParseError);
215
+
216
+ export const isConfigurationError = (error: unknown): boolean =>
217
+ hasCategory(error, ConfigurationError);
218
+
219
+ export const isTimeoutError = (error: unknown): boolean =>
220
+ hasCategory(error, TimeoutError);
221
+
222
+ export const isRetryableError = (error: unknown): boolean =>
223
+ hasCategory(error, RetryableError);
224
+
225
+ export const isLockedError = (error: unknown): boolean =>
226
+ hasCategory(error, LockedError);
227
+
228
+ export const isAbortedError = (error: unknown): boolean =>
229
+ hasCategory(error, AbortedError);
230
+
231
+ export const isAlreadyExistsError = (error: unknown): boolean =>
232
+ hasCategory(error, AlreadyExistsError);
233
+
234
+ export const isDependencyViolationError = (error: unknown): boolean =>
235
+ hasCategory(error, DependencyViolationError);
236
+
237
+ // ============================================================================
238
+ // Transient Error Detection (for retry logic)
239
+ // ============================================================================
240
+
241
+ /**
242
+ * Check if an error has the retryable trait (set via withRetryable).
243
+ */
244
+ export const isRetryable = (error: unknown): boolean => {
245
+ if (Predicate.isObject(error) && Predicate.hasProperty(retryableKey)(error)) {
246
+ return true;
247
+ }
248
+ return false;
249
+ };
250
+
251
+ /**
252
+ * Check if an error is a throttling error.
253
+ * Either has ThrottlingError category or retryable trait with throttling: true.
254
+ */
255
+ export const isThrottling = (error: unknown): boolean => {
256
+ if (Predicate.isObject(error) && Predicate.hasProperty(retryableKey)(error)) {
257
+ // @ts-expect-error - dynamic property access
258
+ return error[retryableKey]?.throttling === true;
259
+ }
260
+ return hasCategory(error, ThrottlingError);
261
+ };
262
+
263
+ /**
264
+ * Check if an error is a transient error that should be automatically retried.
265
+ * Transient errors include:
266
+ * - Errors marked with withRetryable()
267
+ * - RetryableError category
268
+ * - ThrottlingError (rate limiting)
269
+ * - ServerError (5xx responses)
270
+ * - NetworkError (connection issues)
271
+ * - TimeoutError (request timed out)
272
+ * - LockedError (423 - resource temporarily locked)
273
+ */
274
+ export const isTransientError = (error: unknown): boolean => {
275
+ // Check for retryable trait first
276
+ if (isRetryable(error)) {
277
+ return true;
278
+ }
279
+ // Fall back to category-based checking
280
+ return (
281
+ hasCategory(error, RetryableError) ||
282
+ hasCategory(error, ThrottlingError) ||
283
+ hasCategory(error, ServerError) ||
284
+ hasCategory(error, NetworkError) ||
285
+ hasCategory(error, TimeoutError) ||
286
+ hasCategory(error, LockedError)
287
+ );
288
+ };
289
+
290
+ // ============================================================================
291
+ // Category Type Utilities
292
+ // ============================================================================
293
+
294
+ export type AllKeys<E> = E extends { [categoriesKey]: infer Q }
295
+ ? keyof Q
296
+ : never;
297
+
298
+ export type ExtractAll<E, Cats extends PropertyKey> = Cats extends any
299
+ ? Extract<E, { [categoriesKey]: { [K in Cats]: any } }>
300
+ : never;
301
+
302
+ // ============================================================================
303
+ // Category Catchers
304
+ // ============================================================================
305
+
306
+ const makeCatcher =
307
+ (category: Category) =>
308
+ <A2, E2, R2>(f: (err: any) => Effect.Effect<A2, E2, R2>) =>
309
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
310
+ Effect.catchIf(effect, (e) => hasCategory(e, category), f) as Effect.Effect<
311
+ A | A2,
312
+ E | E2,
313
+ R | R2
314
+ >;
315
+
316
+ /**
317
+ * Catch errors matching any of the specified categories.
318
+ *
319
+ * @example
320
+ * ```ts
321
+ * effect.pipe(
322
+ * Category.catchErrors(Category.AuthError, Category.NotFoundError, (err) =>
323
+ * Effect.succeed("handled")
324
+ * )
325
+ * )
326
+ * ```
327
+ */
328
+ export const catchErrors =
329
+ <Categories extends Category[], A2, E2, R2>(
330
+ ...args: [...Categories, (err: any) => Effect.Effect<A2, E2, R2>]
331
+ ) =>
332
+ <A, E, R>(effect: Effect.Effect<A, E, R>) => {
333
+ const handler = args.pop() as (err: any) => Effect.Effect<A2, E2, R2>;
334
+ const categories = args as unknown as Categories;
335
+ return Effect.catchIf(
336
+ effect,
337
+ (e) => categories.some((cat) => hasCategory(e, cat)),
338
+ handler,
339
+ ) as Effect.Effect<A | A2, E | E2, R | R2>;
340
+ };
341
+
342
+ // Alias for convenience
343
+ export { catchErrors as catch };
344
+
345
+ export const catchAuthError = makeCatcher(AuthError);
346
+ export const catchBadRequestError = makeCatcher(BadRequestError);
347
+ export const catchConflictError = makeCatcher(ConflictError);
348
+ export const catchNotFoundError = makeCatcher(NotFoundError);
349
+ export const catchQuotaError = makeCatcher(QuotaError);
350
+ export const catchServerError = makeCatcher(ServerError);
351
+ export const catchThrottlingError = makeCatcher(ThrottlingError);
352
+ export const catchNetworkError = makeCatcher(NetworkError);
353
+ export const catchParseError = makeCatcher(ParseError);
354
+ export const catchConfigurationError = makeCatcher(ConfigurationError);
355
+ export const catchTimeoutError = makeCatcher(TimeoutError);
356
+ export const catchRetryableError = makeCatcher(RetryableError);
357
+ export const catchLockedError = makeCatcher(LockedError);
358
+ export const catchAbortedError = makeCatcher(AbortedError);
359
+ export const catchAlreadyExistsError = makeCatcher(AlreadyExistsError);
360
+ export const catchDependencyViolationError = makeCatcher(
361
+ DependencyViolationError,
362
+ );
363
+
364
+ /**
365
+ * Catch errors with specified categories with full type narrowing.
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * effect.pipe(
370
+ * Category.catchCategory(Category.AuthError, (err) => Effect.succeed("handled"))
371
+ * )
372
+ * ```
373
+ */
374
+ export const catchCategory =
375
+ <E, const Categories extends Array<AllKeys<E>>, A2, E2, R2>(
376
+ ...args: [
377
+ ...Categories,
378
+ f: (err: ExtractAll<E, Categories[number]>) => Effect.Effect<A2, E2, R2>,
379
+ ]
380
+ ) =>
381
+ <A, R>(
382
+ effect: Effect.Effect<A, E, R>,
383
+ ): Effect.Effect<
384
+ A | A2,
385
+ E2 | Exclude<E, ExtractAll<E, Categories[number]>>,
386
+ R | R2
387
+ > => {
388
+ const f = args.pop()!;
389
+ const categories = args;
390
+ return Effect.catchIf(
391
+ effect,
392
+ (e) => {
393
+ if (Predicate.isObject(e) && Predicate.hasProperty(categoriesKey)(e)) {
394
+ for (const cat of categories) {
395
+ // @ts-expect-error - dynamic property access
396
+ if (cat in e[categoriesKey]) {
397
+ return true;
398
+ }
399
+ }
400
+ }
401
+ return false;
402
+ },
403
+ // @ts-expect-error - type narrowing limitation
404
+ (e) => f(e),
405
+ ) as any;
406
+ };