@uploadista/core 0.0.17 → 0.0.18-beta.10

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 (117) hide show
  1. package/README.md +102 -0
  2. package/dist/{checksum-DaCqP8Qa.mjs → checksum-COoD-F1l.mjs} +2 -2
  3. package/dist/{checksum-DaCqP8Qa.mjs.map → checksum-COoD-F1l.mjs.map} +1 -1
  4. package/dist/{checksum-BIlVW8bD.cjs → checksum-YLW4hVY7.cjs} +1 -1
  5. package/dist/errors/index.cjs +1 -1
  6. package/dist/errors/index.d.cts +1 -1
  7. package/dist/errors/index.d.mts +1 -1
  8. package/dist/errors/index.mjs +1 -1
  9. package/dist/flow/index.cjs +1 -1
  10. package/dist/flow/index.d.cts +5 -5
  11. package/dist/flow/index.d.mts +5 -5
  12. package/dist/flow/index.mjs +1 -1
  13. package/dist/flow-BLGpxdEm.mjs +2 -0
  14. package/dist/flow-BLGpxdEm.mjs.map +1 -0
  15. package/dist/flow-DaBzRGmY.cjs +1 -0
  16. package/dist/{index-BGi1r_fi.d.mts → index-9gyMMEIB.d.cts} +2 -2
  17. package/dist/{index-BGi1r_fi.d.mts.map → index-9gyMMEIB.d.cts.map} +1 -1
  18. package/dist/{index-B_SvQ0MU.d.cts → index-B9V5SSxl.d.mts} +2 -2
  19. package/dist/{index-B_SvQ0MU.d.cts.map → index-B9V5SSxl.d.mts.map} +1 -1
  20. package/dist/{index-DIWuZlxd.d.mts → index-BFSHumky.d.mts} +2 -2
  21. package/dist/{index-DIWuZlxd.d.mts.map → index-BFSHumky.d.mts.map} +1 -1
  22. package/dist/{index-BQ5luyME.d.cts → index-D7i4bgl3.d.mts} +2747 -828
  23. package/dist/index-D7i4bgl3.d.mts.map +1 -0
  24. package/dist/{index-qIN6ULCb.d.cts → index-DFbu_-zn.d.cts} +2 -2
  25. package/dist/{index-qIN6ULCb.d.cts.map → index-DFbu_-zn.d.cts.map} +1 -1
  26. package/dist/{index-BtnCNLsH.d.mts → index-fF-j_WhY.d.cts} +2747 -828
  27. package/dist/index-fF-j_WhY.d.cts.map +1 -0
  28. package/dist/index.cjs +1 -1
  29. package/dist/index.d.cts +5 -5
  30. package/dist/index.d.mts +5 -5
  31. package/dist/index.mjs +1 -1
  32. package/dist/{stream-limiter-D2Y8Z_Kv.mjs → stream-limiter-B9nsn2gb.mjs} +2 -2
  33. package/dist/{stream-limiter-D2Y8Z_Kv.mjs.map → stream-limiter-B9nsn2gb.mjs.map} +1 -1
  34. package/dist/{stream-limiter-By0fxkAh.cjs → stream-limiter-DyWOdil4.cjs} +1 -1
  35. package/dist/streams/index.cjs +1 -1
  36. package/dist/streams/index.d.cts +2 -2
  37. package/dist/streams/index.d.mts +2 -2
  38. package/dist/streams/index.mjs +1 -1
  39. package/dist/testing/index.cjs +1 -1
  40. package/dist/testing/index.d.cts +4 -4
  41. package/dist/testing/index.d.mts +4 -4
  42. package/dist/testing/index.mjs +1 -1
  43. package/dist/types/index.cjs +1 -1
  44. package/dist/types/index.d.cts +5 -5
  45. package/dist/types/index.d.mts +5 -5
  46. package/dist/types/index.mjs +1 -1
  47. package/dist/types-CH0BgiJN.mjs +2 -0
  48. package/dist/types-CH0BgiJN.mjs.map +1 -0
  49. package/dist/types-DUYVoR13.cjs +1 -0
  50. package/dist/upload/index.cjs +1 -1
  51. package/dist/upload/index.d.cts +4 -4
  52. package/dist/upload/index.d.mts +4 -4
  53. package/dist/upload/index.mjs +1 -1
  54. package/dist/{upload-bBgM3QFI.cjs → upload-CFT-dWPB.cjs} +1 -1
  55. package/dist/{upload-Bq9h95w6.mjs → upload-ggK-0ZBM.mjs} +2 -2
  56. package/dist/{upload-Bq9h95w6.mjs.map → upload-ggK-0ZBM.mjs.map} +1 -1
  57. package/dist/{uploadista-error-DCRIscEv.cjs → uploadista-error-BxBLmQtX.cjs} +4 -1
  58. package/dist/{uploadista-error-Bb-qIIKM.d.cts → uploadista-error-CYCmAtkZ.d.cts} +2 -2
  59. package/dist/uploadista-error-CYCmAtkZ.d.cts.map +1 -0
  60. package/dist/{uploadista-error-djFxVTLh.mjs → uploadista-error-CkSxSyNo.mjs} +4 -1
  61. package/dist/uploadista-error-CkSxSyNo.mjs.map +1 -0
  62. package/dist/{uploadista-error-D7Gubrr1.d.mts → uploadista-error-DR0XimpE.d.mts} +2 -2
  63. package/dist/uploadista-error-DR0XimpE.d.mts.map +1 -0
  64. package/dist/utils/index.cjs +1 -1
  65. package/dist/utils/index.d.cts +2 -2
  66. package/dist/utils/index.d.mts +2 -2
  67. package/dist/utils/index.mjs +1 -1
  68. package/dist/{utils-MQUZyB9S.mjs → utils-B-ZhQ6b0.mjs} +2 -2
  69. package/dist/{utils-MQUZyB9S.mjs.map → utils-B-ZhQ6b0.mjs.map} +1 -1
  70. package/dist/{utils-DxLVhlLd.cjs → utils-Dhq3vPqp.cjs} +1 -1
  71. package/docs/CIRCUIT_BREAKER.md +381 -0
  72. package/docs/DEAD-LETTER-QUEUE.md +374 -0
  73. package/package.json +11 -6
  74. package/src/errors/uploadista-error.ts +16 -1
  75. package/src/flow/README.md +102 -0
  76. package/src/flow/circuit-breaker-store.ts +382 -0
  77. package/src/flow/circuit-breaker.ts +99 -0
  78. package/src/flow/dead-letter-queue.ts +573 -0
  79. package/src/flow/distributed-circuit-breaker.ts +437 -0
  80. package/src/flow/event.ts +105 -1
  81. package/src/flow/flow-server.ts +70 -0
  82. package/src/flow/flow.ts +141 -3
  83. package/src/flow/index.ts +14 -2
  84. package/src/flow/input-type-registry.ts +229 -0
  85. package/src/flow/node-types/index.ts +26 -20
  86. package/src/flow/node.ts +48 -26
  87. package/src/flow/nodes/input-node.ts +4 -2
  88. package/src/flow/nodes/transform-node.ts +64 -6
  89. package/src/flow/output-type-registry.ts +231 -0
  90. package/src/flow/type-guards.ts +38 -22
  91. package/src/flow/typed-flow.ts +26 -0
  92. package/src/flow/types/dead-letter-item.ts +258 -0
  93. package/src/flow/types/flow-types.ts +320 -2
  94. package/src/flow/types/retry-policy.ts +260 -0
  95. package/src/flow/utils/file-naming.ts +308 -0
  96. package/src/types/circuit-breaker-store.ts +222 -0
  97. package/src/types/health-check.ts +204 -0
  98. package/src/types/index.ts +2 -0
  99. package/src/types/kv-store.ts +82 -2
  100. package/tests/flow/dead-letter-item.test.ts +283 -0
  101. package/tests/flow/dead-letter-queue.test.ts +613 -0
  102. package/tests/flow/file-naming.test.ts +390 -0
  103. package/tests/flow/retry-policy.test.ts +284 -0
  104. package/tests/flow/type-registry.test.ts +1 -1
  105. package/tests/flow/type-system.test.ts +17 -14
  106. package/dist/flow-BiUCrFTv.cjs +0 -1
  107. package/dist/flow-vXXjtBBv.mjs +0 -2
  108. package/dist/flow-vXXjtBBv.mjs.map +0 -1
  109. package/dist/index-BQ5luyME.d.cts.map +0 -1
  110. package/dist/index-BtnCNLsH.d.mts.map +0 -1
  111. package/dist/types-B5I4BioZ.cjs +0 -1
  112. package/dist/types-f6w5J3UD.mjs +0 -2
  113. package/dist/types-f6w5J3UD.mjs.map +0 -1
  114. package/dist/uploadista-error-Bb-qIIKM.d.cts.map +0 -1
  115. package/dist/uploadista-error-D7Gubrr1.d.mts.map +0 -1
  116. package/dist/uploadista-error-djFxVTLh.mjs.map +0 -1
  117. package/src/flow/type-registry.ts +0 -379
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Distributed Circuit Breaker implementation.
3
+ *
4
+ * This module provides a circuit breaker that stores state in a distributed
5
+ * store, allowing multiple instances in a cluster to share circuit state.
6
+ *
7
+ * @module flow/distributed-circuit-breaker
8
+ */
9
+
10
+ import { Effect } from "effect";
11
+ import type { UploadistaError } from "../errors";
12
+ import {
13
+ type CircuitBreakerStateData,
14
+ type CircuitBreakerStateValue,
15
+ type CircuitBreakerStore,
16
+ createInitialCircuitBreakerState,
17
+ } from "../types/circuit-breaker-store";
18
+ import {
19
+ type CircuitBreakerConfig,
20
+ type CircuitBreakerEventHandler,
21
+ type CircuitBreakerFallback,
22
+ DEFAULT_CIRCUIT_BREAKER_CONFIG,
23
+ } from "./circuit-breaker";
24
+
25
+ // ============================================================================
26
+ // Distributed Circuit Breaker
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Result of checking if a request is allowed.
31
+ */
32
+ export interface AllowRequestResult {
33
+ allowed: boolean;
34
+ state: CircuitBreakerStateValue;
35
+ failureCount: number;
36
+ }
37
+
38
+ /**
39
+ * Distributed circuit breaker that uses a store for state persistence.
40
+ *
41
+ * Unlike the in-memory CircuitBreaker, this implementation stores all state
42
+ * in a CircuitBreakerStore, allowing multiple instances to share circuit state.
43
+ *
44
+ * All operations are Effect-based since they may involve I/O.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * const breaker = new DistributedCircuitBreaker(
49
+ * "describe-image",
50
+ * { enabled: true, failureThreshold: 5 },
51
+ * store
52
+ * );
53
+ *
54
+ * // Check if request is allowed
55
+ * const { allowed, state } = yield* breaker.allowRequest();
56
+ * if (!allowed) {
57
+ * // Handle circuit open
58
+ * }
59
+ *
60
+ * // Record result
61
+ * try {
62
+ * const result = yield* executeNode();
63
+ * yield* breaker.recordSuccess();
64
+ * return result;
65
+ * } catch (error) {
66
+ * yield* breaker.recordFailure(error.message);
67
+ * throw error;
68
+ * }
69
+ * ```
70
+ */
71
+ export class DistributedCircuitBreaker {
72
+ private eventHandler?: CircuitBreakerEventHandler;
73
+
74
+ readonly nodeType: string;
75
+ readonly config: Required<Omit<CircuitBreakerConfig, "fallback">> & {
76
+ fallback: CircuitBreakerFallback;
77
+ };
78
+ readonly store: CircuitBreakerStore;
79
+
80
+ constructor(
81
+ nodeType: string,
82
+ config: CircuitBreakerConfig,
83
+ store: CircuitBreakerStore,
84
+ ) {
85
+ this.nodeType = nodeType;
86
+ this.config = {
87
+ enabled: config.enabled ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.enabled,
88
+ failureThreshold:
89
+ config.failureThreshold ??
90
+ DEFAULT_CIRCUIT_BREAKER_CONFIG.failureThreshold,
91
+ resetTimeout:
92
+ config.resetTimeout ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.resetTimeout,
93
+ halfOpenRequests:
94
+ config.halfOpenRequests ??
95
+ DEFAULT_CIRCUIT_BREAKER_CONFIG.halfOpenRequests,
96
+ windowDuration:
97
+ config.windowDuration ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.windowDuration,
98
+ fallback: config.fallback ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.fallback,
99
+ };
100
+ this.store = store;
101
+ }
102
+
103
+ /**
104
+ * Sets the event handler for state change notifications.
105
+ */
106
+ setEventHandler(handler: CircuitBreakerEventHandler): void {
107
+ this.eventHandler = handler;
108
+ }
109
+
110
+ /**
111
+ * Checks if a request is allowed through the circuit.
112
+ *
113
+ * This method reads state from the store, checks for time-based transitions,
114
+ * and returns whether the request should proceed.
115
+ */
116
+ allowRequest(): Effect.Effect<AllowRequestResult, UploadistaError> {
117
+ const self = this;
118
+ return Effect.gen(function* () {
119
+ if (!self.config.enabled) {
120
+ return { allowed: true, state: "closed" as const, failureCount: 0 };
121
+ }
122
+
123
+ let state = yield* self.store.getState(self.nodeType);
124
+ const now = Date.now();
125
+
126
+ // Initialize state if not exists
127
+ if (state === null) {
128
+ state = createInitialCircuitBreakerState({
129
+ failureThreshold: self.config.failureThreshold,
130
+ resetTimeout: self.config.resetTimeout,
131
+ halfOpenRequests: self.config.halfOpenRequests,
132
+ windowDuration: self.config.windowDuration,
133
+ });
134
+ yield* self.store.setState(self.nodeType, state);
135
+ }
136
+
137
+ // Check for time-based transition: open -> half-open
138
+ if (state.state === "open") {
139
+ const timeSinceOpen = now - state.lastStateChange;
140
+ if (timeSinceOpen >= self.config.resetTimeout) {
141
+ // Transition to half-open
142
+ const previousState = state.state;
143
+ state = {
144
+ ...state,
145
+ state: "half-open",
146
+ halfOpenSuccesses: 0,
147
+ lastStateChange: now,
148
+ };
149
+ yield* self.store.setState(self.nodeType, state);
150
+ yield* self.emitEvent(previousState, "half-open", state.failureCount);
151
+ }
152
+ }
153
+
154
+ // Determine if request is allowed
155
+ const allowed = state.state !== "open";
156
+
157
+ return {
158
+ allowed,
159
+ state: state.state,
160
+ failureCount: state.failureCount,
161
+ };
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Gets the current circuit state from the store.
167
+ */
168
+ getState(): Effect.Effect<CircuitBreakerStateValue, UploadistaError> {
169
+ const self = this;
170
+ return Effect.gen(function* () {
171
+ const state = yield* self.store.getState(self.nodeType);
172
+ return state?.state ?? "closed";
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Gets the current failure count from the store.
178
+ */
179
+ getFailureCount(): Effect.Effect<number, UploadistaError> {
180
+ const self = this;
181
+ return Effect.gen(function* () {
182
+ const state = yield* self.store.getState(self.nodeType);
183
+ return state?.failureCount ?? 0;
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Records a successful execution.
189
+ *
190
+ * In half-open state, tracks successes toward closing the circuit.
191
+ * In closed state, resets the failure count.
192
+ */
193
+ recordSuccess(): Effect.Effect<void, UploadistaError> {
194
+ const self = this;
195
+ return Effect.gen(function* () {
196
+ if (!self.config.enabled) {
197
+ return;
198
+ }
199
+
200
+ const state = yield* self.store.getState(self.nodeType);
201
+ if (state === null) {
202
+ return;
203
+ }
204
+
205
+ if (state.state === "half-open") {
206
+ const newSuccessCount = yield* self.store.incrementHalfOpenSuccesses(
207
+ self.nodeType,
208
+ );
209
+ if (newSuccessCount >= self.config.halfOpenRequests) {
210
+ // Transition to closed
211
+ yield* self.transitionTo("closed", state.failureCount);
212
+ }
213
+ } else if (state.state === "closed") {
214
+ // Reset failure count on success
215
+ yield* self.store.resetFailures(self.nodeType);
216
+ }
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Records a failed execution.
222
+ *
223
+ * In closed state, increments failure count and may trip the circuit.
224
+ * In half-open state, immediately reopens the circuit.
225
+ */
226
+ recordFailure(_errorMessage: string): Effect.Effect<void, UploadistaError> {
227
+ const self = this;
228
+ return Effect.gen(function* () {
229
+ if (!self.config.enabled) {
230
+ return;
231
+ }
232
+
233
+ const state = yield* self.store.getState(self.nodeType);
234
+
235
+ if (state === null || state.state === "closed") {
236
+ // Increment failures and check threshold
237
+ const newFailureCount = yield* self.store.incrementFailures(
238
+ self.nodeType,
239
+ self.config.windowDuration,
240
+ );
241
+
242
+ if (newFailureCount >= self.config.failureThreshold) {
243
+ // Trip the circuit
244
+ yield* self.transitionTo("open", newFailureCount);
245
+ }
246
+ } else if (state.state === "half-open") {
247
+ // Any failure in half-open reopens the circuit
248
+ yield* self.transitionTo("open", state.failureCount);
249
+ }
250
+ // In open state, failures are ignored (requests shouldn't reach here)
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Gets the fallback configuration.
256
+ */
257
+ getFallback(): CircuitBreakerFallback {
258
+ return this.config.fallback;
259
+ }
260
+
261
+ /**
262
+ * Resets the circuit breaker to closed state.
263
+ */
264
+ reset(): Effect.Effect<void, UploadistaError> {
265
+ const self = this;
266
+ return Effect.gen(function* () {
267
+ const state = yield* self.store.getState(self.nodeType);
268
+ const previousState = state?.state ?? "closed";
269
+
270
+ yield* self.store.setState(
271
+ self.nodeType,
272
+ createInitialCircuitBreakerState({
273
+ failureThreshold: self.config.failureThreshold,
274
+ resetTimeout: self.config.resetTimeout,
275
+ halfOpenRequests: self.config.halfOpenRequests,
276
+ windowDuration: self.config.windowDuration,
277
+ }),
278
+ );
279
+
280
+ if (previousState !== "closed") {
281
+ yield* self.emitEvent(previousState, "closed", 0);
282
+ }
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Transitions to a new state.
288
+ */
289
+ private transitionTo(
290
+ newState: CircuitBreakerStateValue,
291
+ failureCount: number,
292
+ ): Effect.Effect<void, UploadistaError> {
293
+ const self = this;
294
+ return Effect.gen(function* () {
295
+ const currentState = yield* self.store.getState(self.nodeType);
296
+ const previousState = currentState?.state ?? "closed";
297
+
298
+ if (previousState === newState) {
299
+ return;
300
+ }
301
+
302
+ const now = Date.now();
303
+ const updatedState: CircuitBreakerStateData = {
304
+ state: newState,
305
+ failureCount: newState === "closed" ? 0 : failureCount,
306
+ lastStateChange: now,
307
+ halfOpenSuccesses: 0,
308
+ windowStart:
309
+ newState === "closed" ? now : (currentState?.windowStart ?? now),
310
+ config: {
311
+ failureThreshold: self.config.failureThreshold,
312
+ resetTimeout: self.config.resetTimeout,
313
+ halfOpenRequests: self.config.halfOpenRequests,
314
+ windowDuration: self.config.windowDuration,
315
+ },
316
+ };
317
+
318
+ yield* self.store.setState(self.nodeType, updatedState);
319
+ yield* self.emitEvent(previousState, newState, failureCount);
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Emits a state change event if handler is set.
325
+ */
326
+ private emitEvent(
327
+ previousState: CircuitBreakerStateValue,
328
+ newState: CircuitBreakerStateValue,
329
+ failureCount: number,
330
+ ): Effect.Effect<void, never, never> {
331
+ const self = this;
332
+ return Effect.gen(function* () {
333
+ if (self.eventHandler) {
334
+ yield* self.eventHandler({
335
+ nodeType: self.nodeType,
336
+ previousState,
337
+ newState,
338
+ timestamp: Date.now(),
339
+ failureCount,
340
+ });
341
+ }
342
+ });
343
+ }
344
+ }
345
+
346
+ // ============================================================================
347
+ // Distributed Circuit Breaker Registry
348
+ // ============================================================================
349
+
350
+ /**
351
+ * Registry for managing distributed circuit breakers.
352
+ *
353
+ * Unlike the in-memory CircuitBreakerRegistry, this registry creates
354
+ * DistributedCircuitBreaker instances that share state via a store.
355
+ *
356
+ * @example
357
+ * ```typescript
358
+ * const store = makeKvCircuitBreakerStore(baseKvStore);
359
+ * const registry = new DistributedCircuitBreakerRegistry(store);
360
+ *
361
+ * const breaker = registry.getOrCreate("describe-image", {
362
+ * enabled: true,
363
+ * failureThreshold: 5
364
+ * });
365
+ * ```
366
+ */
367
+ export class DistributedCircuitBreakerRegistry {
368
+ private breakers: Map<string, DistributedCircuitBreaker> = new Map();
369
+ private eventHandler?: CircuitBreakerEventHandler;
370
+
371
+ constructor(readonly store: CircuitBreakerStore) {}
372
+
373
+ /**
374
+ * Sets a global event handler for all circuit breakers.
375
+ */
376
+ setEventHandler(handler: CircuitBreakerEventHandler): void {
377
+ this.eventHandler = handler;
378
+ for (const breaker of this.breakers.values()) {
379
+ breaker.setEventHandler(handler);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Gets an existing circuit breaker or creates a new one.
385
+ */
386
+ getOrCreate(
387
+ nodeType: string,
388
+ config: CircuitBreakerConfig,
389
+ ): DistributedCircuitBreaker {
390
+ let breaker = this.breakers.get(nodeType);
391
+ if (!breaker) {
392
+ breaker = new DistributedCircuitBreaker(nodeType, config, this.store);
393
+ if (this.eventHandler) {
394
+ breaker.setEventHandler(this.eventHandler);
395
+ }
396
+ this.breakers.set(nodeType, breaker);
397
+ }
398
+ return breaker;
399
+ }
400
+
401
+ /**
402
+ * Gets an existing circuit breaker if it exists.
403
+ */
404
+ get(nodeType: string): DistributedCircuitBreaker | undefined {
405
+ return this.breakers.get(nodeType);
406
+ }
407
+
408
+ /**
409
+ * Gets statistics for all circuit breakers from the store.
410
+ */
411
+ getAllStats(): Effect.Effect<
412
+ Map<string, { state: CircuitBreakerStateValue; failureCount: number }>,
413
+ UploadistaError
414
+ > {
415
+ return this.store.getAllStats();
416
+ }
417
+
418
+ /**
419
+ * Resets all circuit breakers.
420
+ */
421
+ resetAll(): Effect.Effect<void, UploadistaError> {
422
+ const self = this;
423
+ return Effect.gen(function* () {
424
+ for (const breaker of self.breakers.values()) {
425
+ yield* breaker.reset();
426
+ }
427
+ });
428
+ }
429
+
430
+ /**
431
+ * Clears all circuit breakers from the local cache.
432
+ * Note: This does not clear state from the store.
433
+ */
434
+ clear(): void {
435
+ this.breakers.clear();
436
+ }
437
+ }
package/src/flow/event.ts CHANGED
@@ -57,6 +57,18 @@ export enum EventType {
57
57
  NodeStream = "node-stream",
58
58
  /** Emitted for node response data */
59
59
  NodeResponse = "node-response",
60
+ /** Emitted when a job is added to the Dead Letter Queue */
61
+ DlqItemAdded = "dlq-item-added",
62
+ /** Emitted when a DLQ retry attempt starts */
63
+ DlqRetryStart = "dlq-retry-start",
64
+ /** Emitted when a DLQ retry succeeds */
65
+ DlqRetrySuccess = "dlq-retry-success",
66
+ /** Emitted when a DLQ retry fails */
67
+ DlqRetryFailed = "dlq-retry-failed",
68
+ /** Emitted when a DLQ item is exhausted (max retries reached) */
69
+ DlqItemExhausted = "dlq-item-exhausted",
70
+ /** Emitted when a DLQ item is resolved */
71
+ DlqItemResolved = "dlq-item-resolved",
60
72
  }
61
73
 
62
74
  /**
@@ -246,6 +258,94 @@ export type FlowEventNodeResponse = {
246
258
  data: unknown;
247
259
  };
248
260
 
261
+ // ============================================================================
262
+ // Dead Letter Queue Events
263
+ // ============================================================================
264
+
265
+ /**
266
+ * Event emitted when a job is added to the Dead Letter Queue.
267
+ */
268
+ export type FlowEventDlqItemAdded = {
269
+ eventType: EventType.DlqItemAdded;
270
+ dlqItemId: string;
271
+ jobId: string;
272
+ flowId: string;
273
+ errorCode: string;
274
+ errorMessage: string;
275
+ retryCount: number;
276
+ maxRetries: number;
277
+ };
278
+
279
+ /**
280
+ * Event emitted when a DLQ retry attempt starts.
281
+ */
282
+ export type FlowEventDlqRetryStart = {
283
+ eventType: EventType.DlqRetryStart;
284
+ dlqItemId: string;
285
+ jobId: string;
286
+ flowId: string;
287
+ attemptNumber: number;
288
+ };
289
+
290
+ /**
291
+ * Event emitted when a DLQ retry succeeds.
292
+ */
293
+ export type FlowEventDlqRetrySuccess = {
294
+ eventType: EventType.DlqRetrySuccess;
295
+ dlqItemId: string;
296
+ jobId: string;
297
+ flowId: string;
298
+ attemptNumber: number;
299
+ durationMs: number;
300
+ };
301
+
302
+ /**
303
+ * Event emitted when a DLQ retry fails.
304
+ */
305
+ export type FlowEventDlqRetryFailed = {
306
+ eventType: EventType.DlqRetryFailed;
307
+ dlqItemId: string;
308
+ jobId: string;
309
+ flowId: string;
310
+ attemptNumber: number;
311
+ error: string;
312
+ durationMs: number;
313
+ nextRetryAt?: string; // ISO 8601 timestamp
314
+ };
315
+
316
+ /**
317
+ * Event emitted when a DLQ item is exhausted (max retries reached).
318
+ */
319
+ export type FlowEventDlqItemExhausted = {
320
+ eventType: EventType.DlqItemExhausted;
321
+ dlqItemId: string;
322
+ jobId: string;
323
+ flowId: string;
324
+ totalAttempts: number;
325
+ };
326
+
327
+ /**
328
+ * Event emitted when a DLQ item is resolved.
329
+ */
330
+ export type FlowEventDlqItemResolved = {
331
+ eventType: EventType.DlqItemResolved;
332
+ dlqItemId: string;
333
+ jobId: string;
334
+ flowId: string;
335
+ resolvedBy: "retry" | "manual";
336
+ };
337
+
338
+ /**
339
+ * Union of all DLQ-related events.
340
+ */
341
+ export type DlqEvent =
342
+ | FlowEventDlqItemAdded
343
+ | FlowEventDlqRetryStart
344
+ | FlowEventDlqRetrySuccess
345
+ | FlowEventDlqRetryFailed
346
+ | FlowEventDlqItemExhausted
347
+ | FlowEventDlqItemResolved;
348
+
249
349
  /**
250
350
  * Union of all possible flow execution events.
251
351
  *
@@ -267,6 +367,9 @@ export type FlowEventNodeResponse = {
267
367
  * case EventType.FlowCancel:
268
368
  * console.log("Flow cancelled:", event.flowId);
269
369
  * break;
370
+ * case EventType.DlqItemAdded:
371
+ * console.log("Job added to DLQ:", event.dlqItemId);
372
+ * break;
270
373
  * }
271
374
  * }
272
375
  * ```
@@ -283,4 +386,5 @@ export type FlowEvent =
283
386
  | FlowEventNodeEnd
284
387
  | FlowEventNodePause
285
388
  | FlowEventNodeResume
286
- | FlowEventNodeError;
389
+ | FlowEventNodeError
390
+ | DlqEvent;
@@ -53,6 +53,7 @@ export class FlowWaitUntil extends Context.Tag("FlowWaitUntil")<
53
53
 
54
54
  import { FlowEventEmitter, FlowJobKVStore } from "../types";
55
55
  import { UploadServer } from "../upload";
56
+ import { DeadLetterQueueService } from "./dead-letter-queue";
56
57
  import type { FlowEvent } from "./event";
57
58
  import type { FlowJob } from "./types/flow-job";
58
59
 
@@ -710,6 +711,7 @@ export function createFlowServer() {
710
711
  const eventEmitter = yield* FlowEventEmitter;
711
712
  const kvStore = yield* FlowJobKVStore;
712
713
  const uploadServer = yield* UploadServer;
714
+ const dlqOption = yield* DeadLetterQueueService.optional;
713
715
 
714
716
  const updateJob = (jobId: string, updates: Partial<FlowJob>) =>
715
717
  Effect.gen(function* () {
@@ -766,6 +768,50 @@ export function createFlowServer() {
766
768
  });
767
769
  });
768
770
 
771
+ // Helper function to add failed job to Dead Letter Queue
772
+ const addToDeadLetterQueue = (
773
+ jobId: string,
774
+ error: UploadistaError,
775
+ ) =>
776
+ Effect.gen(function* () {
777
+ if (Option.isNone(dlqOption)) {
778
+ // DLQ not configured, skip
779
+ yield* Effect.logDebug(
780
+ `[FlowServer] DLQ not configured, skipping for job: ${jobId}`,
781
+ );
782
+ return;
783
+ }
784
+
785
+ const dlq = dlqOption.value;
786
+
787
+ // Get the job to add to DLQ
788
+ const job = yield* Effect.catchAll(kvStore.get(jobId), () =>
789
+ Effect.succeed(null as FlowJob | null),
790
+ );
791
+
792
+ if (!job) {
793
+ yield* Effect.logWarning(
794
+ `[FlowServer] Job ${jobId} not found when adding to DLQ`,
795
+ );
796
+ return;
797
+ }
798
+
799
+ // Add to DLQ
800
+ yield* Effect.catchAll(dlq.add(job, error), (dlqError) =>
801
+ Effect.gen(function* () {
802
+ yield* Effect.logError(
803
+ `[FlowServer] Failed to add job ${jobId} to DLQ`,
804
+ dlqError,
805
+ );
806
+ return Effect.succeed(undefined);
807
+ }),
808
+ );
809
+
810
+ yield* Effect.logInfo(
811
+ `[FlowServer] Added job ${jobId} to Dead Letter Queue`,
812
+ );
813
+ });
814
+
769
815
  // Helper function to execute flow in background
770
816
  const executeFlowInBackground = ({
771
817
  jobId,
@@ -895,6 +941,18 @@ export function createFlowServer() {
895
941
  ),
896
942
  );
897
943
 
944
+ // Add failed job to Dead Letter Queue for retry/debugging
945
+ const uploadistaError =
946
+ error instanceof UploadistaError
947
+ ? error
948
+ : new UploadistaError({
949
+ code: "UNKNOWN_ERROR",
950
+ status: 500,
951
+ body: String(error),
952
+ cause: error,
953
+ });
954
+ yield* addToDeadLetterQueue(jobId, uploadistaError);
955
+
898
956
  throw error;
899
957
  }),
900
958
  ),
@@ -1216,6 +1274,18 @@ export function createFlowServer() {
1216
1274
  ),
1217
1275
  );
1218
1276
 
1277
+ // Add failed job to Dead Letter Queue for retry/debugging
1278
+ const uploadistaError =
1279
+ error instanceof UploadistaError
1280
+ ? error
1281
+ : new UploadistaError({
1282
+ code: "UNKNOWN_ERROR",
1283
+ status: 500,
1284
+ body: String(error),
1285
+ cause: error,
1286
+ });
1287
+ yield* addToDeadLetterQueue(jobId, uploadistaError);
1288
+
1219
1289
  throw error;
1220
1290
  }),
1221
1291
  ),