@uploadista/server 0.0.18-beta.4 → 0.0.18-beta.6

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,177 @@
1
+ import { DeadLetterQueueService } from "@uploadista/core/flow";
2
+ import { Effect } from "effect";
3
+ import type {
4
+ DlqCleanupRequest,
5
+ DlqCleanupResponse,
6
+ DlqDeleteRequest,
7
+ DlqDeleteResponse,
8
+ DlqGetRequest,
9
+ DlqGetResponse,
10
+ DlqListRequest,
11
+ DlqListResponse,
12
+ DlqResolveRequest,
13
+ DlqResolveResponse,
14
+ DlqRetryAllRequest,
15
+ DlqRetryAllResponse,
16
+ DlqRetryRequest,
17
+ DlqRetryResponse,
18
+ DlqStatsRequest,
19
+ DlqStatsResponse,
20
+ } from "../routes";
21
+
22
+ /**
23
+ * Handle GET /api/dlq - List DLQ items
24
+ */
25
+ export const handleDlqList = (req: DlqListRequest) =>
26
+ Effect.gen(function* () {
27
+ const dlq = yield* DeadLetterQueueService;
28
+ const result = yield* dlq.list(req.options);
29
+
30
+ return {
31
+ type: "dlq-list",
32
+ status: 200,
33
+ headers: { "Content-Type": "application/json" },
34
+ body: result,
35
+ } satisfies DlqListResponse;
36
+ });
37
+
38
+ /**
39
+ * Handle GET /api/dlq/:itemId - Get a specific DLQ item
40
+ */
41
+ export const handleDlqGet = (req: DlqGetRequest) =>
42
+ Effect.gen(function* () {
43
+ const dlq = yield* DeadLetterQueueService;
44
+ const item = yield* dlq.get(req.itemId);
45
+
46
+ return {
47
+ type: "dlq-get",
48
+ status: 200,
49
+ headers: { "Content-Type": "application/json" },
50
+ body: item,
51
+ } satisfies DlqGetResponse;
52
+ });
53
+
54
+ /**
55
+ * Handle POST /api/dlq/:itemId/retry - Retry a specific DLQ item
56
+ */
57
+ export const handleDlqRetry = (req: DlqRetryRequest) =>
58
+ Effect.gen(function* () {
59
+ const dlq = yield* DeadLetterQueueService;
60
+
61
+ // Mark item as retrying
62
+ yield* dlq.markRetrying(req.itemId);
63
+
64
+ // TODO: Implement actual retry logic by re-executing the flow
65
+ // This would require access to FlowServer and the original job context
66
+ // For now, we just mark it as retrying and return success
67
+ // The actual retry would be handled by a background scheduler
68
+
69
+ return {
70
+ type: "dlq-retry",
71
+ status: 200,
72
+ headers: { "Content-Type": "application/json" },
73
+ body: { success: true },
74
+ } satisfies DlqRetryResponse;
75
+ });
76
+
77
+ /**
78
+ * Handle POST /api/dlq/retry-all - Retry all matching DLQ items
79
+ */
80
+ export const handleDlqRetryAll = (req: DlqRetryAllRequest) =>
81
+ Effect.gen(function* () {
82
+ const dlq = yield* DeadLetterQueueService;
83
+
84
+ // List items matching the filter
85
+ const { items } = yield* dlq.list({
86
+ status: req.options?.status,
87
+ flowId: req.options?.flowId,
88
+ });
89
+
90
+ let succeeded = 0;
91
+ let failed = 0;
92
+
93
+ // Mark each item for retry
94
+ for (const item of items) {
95
+ const result = yield* Effect.either(dlq.markRetrying(item.id));
96
+ if (result._tag === "Right") {
97
+ succeeded++;
98
+ } else {
99
+ failed++;
100
+ }
101
+ }
102
+
103
+ return {
104
+ type: "dlq-retry-all",
105
+ status: 200,
106
+ headers: { "Content-Type": "application/json" },
107
+ body: {
108
+ retried: items.length,
109
+ succeeded,
110
+ failed,
111
+ },
112
+ } satisfies DlqRetryAllResponse;
113
+ });
114
+
115
+ /**
116
+ * Handle DELETE /api/dlq/:itemId - Delete a DLQ item
117
+ */
118
+ export const handleDlqDelete = (req: DlqDeleteRequest) =>
119
+ Effect.gen(function* () {
120
+ const dlq = yield* DeadLetterQueueService;
121
+ yield* dlq.delete(req.itemId);
122
+
123
+ return {
124
+ type: "dlq-delete",
125
+ status: 200,
126
+ headers: { "Content-Type": "application/json" },
127
+ body: { success: true },
128
+ } satisfies DlqDeleteResponse;
129
+ });
130
+
131
+ /**
132
+ * Handle POST /api/dlq/:itemId/resolve - Manually resolve a DLQ item
133
+ */
134
+ export const handleDlqResolve = (req: DlqResolveRequest) =>
135
+ Effect.gen(function* () {
136
+ const dlq = yield* DeadLetterQueueService;
137
+ const item = yield* dlq.markResolved(req.itemId);
138
+
139
+ return {
140
+ type: "dlq-resolve",
141
+ status: 200,
142
+ headers: { "Content-Type": "application/json" },
143
+ body: item,
144
+ } satisfies DlqResolveResponse;
145
+ });
146
+
147
+ /**
148
+ * Handle POST /api/dlq/cleanup - Cleanup old DLQ items
149
+ */
150
+ export const handleDlqCleanup = (req: DlqCleanupRequest) =>
151
+ Effect.gen(function* () {
152
+ const dlq = yield* DeadLetterQueueService;
153
+ const result = yield* dlq.cleanup(req.options);
154
+
155
+ return {
156
+ type: "dlq-cleanup",
157
+ status: 200,
158
+ headers: { "Content-Type": "application/json" },
159
+ body: result,
160
+ } satisfies DlqCleanupResponse;
161
+ });
162
+
163
+ /**
164
+ * Handle GET /api/dlq/stats - Get DLQ statistics
165
+ */
166
+ export const handleDlqStats = (_req: DlqStatsRequest) =>
167
+ Effect.gen(function* () {
168
+ const dlq = yield* DeadLetterQueueService;
169
+ const stats = yield* dlq.getStats();
170
+
171
+ return {
172
+ type: "dlq-stats",
173
+ status: 200,
174
+ headers: { "Content-Type": "application/json" },
175
+ body: stats,
176
+ } satisfies DlqStatsResponse;
177
+ });
@@ -1,5 +1,15 @@
1
1
  import { Effect } from "effect";
2
2
  import type { UploadistaRequest, UploadistaResponse } from "../routes";
3
+ import {
4
+ handleDlqCleanup,
5
+ handleDlqDelete,
6
+ handleDlqGet,
7
+ handleDlqList,
8
+ handleDlqResolve,
9
+ handleDlqRetry,
10
+ handleDlqRetryAll,
11
+ handleDlqStats,
12
+ } from "./dlq-http-handlers";
3
13
  import {
4
14
  handleCancelFlow,
5
15
  handleGetFlow,
@@ -44,6 +54,23 @@ export const handleUploadistaRequest = <TRequirements>(
44
54
  return (yield* handlePauseFlow(req)) as UploadistaResponse;
45
55
  case "cancel-flow":
46
56
  return (yield* handleCancelFlow(req)) as UploadistaResponse;
57
+ // DLQ Admin routes
58
+ case "dlq-list":
59
+ return (yield* handleDlqList(req)) as UploadistaResponse;
60
+ case "dlq-get":
61
+ return (yield* handleDlqGet(req)) as UploadistaResponse;
62
+ case "dlq-retry":
63
+ return (yield* handleDlqRetry(req)) as UploadistaResponse;
64
+ case "dlq-retry-all":
65
+ return (yield* handleDlqRetryAll(req)) as UploadistaResponse;
66
+ case "dlq-delete":
67
+ return (yield* handleDlqDelete(req)) as UploadistaResponse;
68
+ case "dlq-resolve":
69
+ return (yield* handleDlqResolve(req)) as UploadistaResponse;
70
+ case "dlq-cleanup":
71
+ return (yield* handleDlqCleanup(req)) as UploadistaResponse;
72
+ case "dlq-stats":
73
+ return (yield* handleDlqStats(req)) as UploadistaResponse;
47
74
  case "not-found":
48
75
  return {
49
76
  status: 404,
@@ -1,5 +1,11 @@
1
1
  import type {
2
2
  DataStoreCapabilities,
3
+ DeadLetterCleanupOptions,
4
+ DeadLetterCleanupResult,
5
+ DeadLetterItem,
6
+ DeadLetterItemStatus,
7
+ DeadLetterListOptions,
8
+ DeadLetterQueueStats,
3
9
  FlowData,
4
10
  FlowJob,
5
11
  UploadFile,
@@ -17,6 +23,16 @@ export type UploadistaRouteType =
17
23
  | "resume-flow"
18
24
  | "pause-flow"
19
25
  | "cancel-flow"
26
+ // DLQ Admin routes
27
+ | "dlq-list"
28
+ | "dlq-get"
29
+ | "dlq-retry"
30
+ | "dlq-retry-all"
31
+ | "dlq-delete"
32
+ | "dlq-resolve"
33
+ | "dlq-cleanup"
34
+ | "dlq-stats"
35
+ // Error routes
20
36
  | "not-found"
21
37
  | "bad-request"
22
38
  | "method-not-allowed"
@@ -154,6 +170,69 @@ export type CancelFlowResponse = UploadistaStandardResponse<
154
170
  FlowJob
155
171
  >;
156
172
 
173
+ // ============================================================================
174
+ // Dead Letter Queue Admin Routes
175
+ // ============================================================================
176
+
177
+ export type DlqListRequest = UploadistaRoute<"dlq-list"> & {
178
+ options?: DeadLetterListOptions;
179
+ };
180
+ export type DlqListResponse = UploadistaStandardResponse<
181
+ "dlq-list",
182
+ { items: DeadLetterItem[]; total: number }
183
+ >;
184
+
185
+ export type DlqGetRequest = UploadistaRoute<"dlq-get"> & {
186
+ itemId: string;
187
+ };
188
+ export type DlqGetResponse = UploadistaStandardResponse<"dlq-get", DeadLetterItem>;
189
+
190
+ export type DlqRetryRequest = UploadistaRoute<"dlq-retry"> & {
191
+ itemId: string;
192
+ };
193
+ export type DlqRetryResponse = UploadistaStandardResponse<
194
+ "dlq-retry",
195
+ { success: boolean; newJobId?: string }
196
+ >;
197
+
198
+ export type DlqRetryAllRequest = UploadistaRoute<"dlq-retry-all"> & {
199
+ options?: { status?: DeadLetterItemStatus; flowId?: string };
200
+ };
201
+ export type DlqRetryAllResponse = UploadistaStandardResponse<
202
+ "dlq-retry-all",
203
+ { retried: number; succeeded: number; failed: number }
204
+ >;
205
+
206
+ export type DlqDeleteRequest = UploadistaRoute<"dlq-delete"> & {
207
+ itemId: string;
208
+ };
209
+ export type DlqDeleteResponse = UploadistaStandardResponse<
210
+ "dlq-delete",
211
+ { success: boolean }
212
+ >;
213
+
214
+ export type DlqResolveRequest = UploadistaRoute<"dlq-resolve"> & {
215
+ itemId: string;
216
+ };
217
+ export type DlqResolveResponse = UploadistaStandardResponse<
218
+ "dlq-resolve",
219
+ DeadLetterItem
220
+ >;
221
+
222
+ export type DlqCleanupRequest = UploadistaRoute<"dlq-cleanup"> & {
223
+ options?: DeadLetterCleanupOptions;
224
+ };
225
+ export type DlqCleanupResponse = UploadistaStandardResponse<
226
+ "dlq-cleanup",
227
+ DeadLetterCleanupResult
228
+ >;
229
+
230
+ export type DlqStatsRequest = UploadistaRoute<"dlq-stats">;
231
+ export type DlqStatsResponse = UploadistaStandardResponse<
232
+ "dlq-stats",
233
+ DeadLetterQueueStats
234
+ >;
235
+
157
236
  export type UploadistaRequest =
158
237
  | CreateUploadRequest
159
238
  | GetCapabilitiesRequest
@@ -165,6 +244,16 @@ export type UploadistaRequest =
165
244
  | ResumeFlowRequest
166
245
  | PauseFlowRequest
167
246
  | CancelFlowRequest
247
+ // DLQ Admin requests
248
+ | DlqListRequest
249
+ | DlqGetRequest
250
+ | DlqRetryRequest
251
+ | DlqRetryAllRequest
252
+ | DlqDeleteRequest
253
+ | DlqResolveRequest
254
+ | DlqCleanupRequest
255
+ | DlqStatsRequest
256
+ // Error requests
168
257
  | NotFoundRequest
169
258
  | BadRequestRequest
170
259
  | MethodNotAllowedRequest
@@ -181,6 +270,16 @@ export type UploadistaResponse =
181
270
  | ResumeFlowResponse
182
271
  | PauseFlowResponse
183
272
  | CancelFlowResponse
273
+ // DLQ Admin responses
274
+ | DlqListResponse
275
+ | DlqGetResponse
276
+ | DlqRetryResponse
277
+ | DlqRetryAllResponse
278
+ | DlqDeleteResponse
279
+ | DlqResolveResponse
280
+ | DlqCleanupResponse
281
+ | DlqStatsResponse
282
+ // Error responses
184
283
  | NotFoundResponse
185
284
  | BadRequestResponse
186
285
  | MethodNotAllowedResponse
@@ -1,5 +1,6 @@
1
1
  import type { PluginLayer, UploadistaError } from "@uploadista/core";
2
2
  import {
3
+ deadLetterQueueService,
3
4
  type Flow,
4
5
  FlowProvider,
5
6
  FlowWaitUntil,
@@ -7,6 +8,7 @@ import {
7
8
  } from "@uploadista/core/flow";
8
9
  import {
9
10
  createDataStoreLayer,
11
+ deadLetterQueueKvStore,
10
12
  type UploadFileDataStores,
11
13
  type UploadFileKVStore,
12
14
  } from "@uploadista/core/types";
@@ -197,6 +199,7 @@ export const createUploadistaServer = async <
197
199
  adapter,
198
200
  authCacheConfig,
199
201
  circuitBreaker = true,
202
+ deadLetterQueue = false,
200
203
  }: UploadistaServerConfig<
201
204
  TContext,
202
205
  TResponse,
@@ -272,22 +275,30 @@ export const createUploadistaServer = async <
272
275
  ? kvCircuitBreakerStoreLayer.pipe(Layer.provide(kvStore))
273
276
  : null;
274
277
 
278
+ // Create dead letter queue layer if enabled (uses the provided kvStore)
279
+ // The DLQ layer provides both the KV store wrapper and the service
280
+ const dlqLayer = deadLetterQueue
281
+ ? deadLetterQueueService.pipe(
282
+ Layer.provide(deadLetterQueueKvStore),
283
+ Layer.provide(kvStore),
284
+ )
285
+ : null;
286
+
275
287
  /**
276
288
  * Merge all server layers including plugins.
277
289
  *
278
290
  * This combines the core server infrastructure (upload server, flow server,
279
- * metrics, auth cache, circuit breaker) with user-provided plugin layers.
291
+ * metrics, auth cache, circuit breaker, dead letter queue) with user-provided plugin layers.
280
292
  */
281
- const baseServerLayer = Layer.mergeAll(
293
+ const serverLayerRaw = Layer.mergeAll(
282
294
  uploadServerLayer,
283
295
  flowServerLayer,
284
296
  effectiveMetricsLayer,
285
297
  authCacheLayer,
286
298
  ...plugins,
299
+ ...(circuitBreakerStoreLayer ? [circuitBreakerStoreLayer] : []),
300
+ ...(dlqLayer ? [dlqLayer] : []),
287
301
  );
288
- const serverLayerRaw = circuitBreakerStoreLayer
289
- ? Layer.merge(baseServerLayer, circuitBreakerStoreLayer)
290
- : baseServerLayer;
291
302
 
292
303
  /**
293
304
  * Type Casting Rationale for Plugin System
@@ -485,7 +496,7 @@ export const createUploadistaServer = async <
485
496
  }
486
497
  }
487
498
 
488
- // Combine auth context, auth cache, metrics layers, plugins, circuit breaker, and waitUntil
499
+ // Combine auth context, auth cache, metrics layers, plugins, circuit breaker, DLQ, and waitUntil
489
500
  // This ensures that flow nodes have access to all required services
490
501
  const baseRequestContextLayer = Layer.mergeAll(
491
502
  authContextLayer,
@@ -494,9 +505,12 @@ export const createUploadistaServer = async <
494
505
  ...plugins,
495
506
  ...waitUntilLayers,
496
507
  );
497
- const requestContextLayer = circuitBreakerStoreLayer
508
+ const withCircuitBreakerContext = circuitBreakerStoreLayer
498
509
  ? Layer.merge(baseRequestContextLayer, circuitBreakerStoreLayer)
499
510
  : baseRequestContextLayer;
511
+ const requestContextLayer = dlqLayer
512
+ ? Layer.merge(withCircuitBreakerContext, dlqLayer)
513
+ : withCircuitBreakerContext;
500
514
 
501
515
  // Check for baseUrl/api/ prefix
502
516
  if (uploadistaRequest.type === "not-found") {
package/src/core/types.ts CHANGED
@@ -323,6 +323,34 @@ export interface UploadistaServerConfig<
323
323
  * ```
324
324
  */
325
325
  circuitBreaker?: boolean;
326
+
327
+ /**
328
+ * Optional: Enable dead letter queue for failed flow jobs.
329
+ *
330
+ * When enabled, failed flow jobs are captured in a DLQ with full context
331
+ * for debugging and retry. The DLQ state is stored in the KV store,
332
+ * allowing it to be shared across multiple server instances.
333
+ *
334
+ * Set to `false` to disable the DLQ entirely.
335
+ *
336
+ * @default false
337
+ *
338
+ * @example
339
+ * ```typescript
340
+ * // Enable DLQ (uses the provided kvStore)
341
+ * const server = await createUploadistaServer({
342
+ * kvStore: redisKvStore,
343
+ * deadLetterQueue: true
344
+ * });
345
+ *
346
+ * // DLQ is disabled by default
347
+ * const server = await createUploadistaServer({
348
+ * kvStore: redisKvStore,
349
+ * // deadLetterQueue: false (default)
350
+ * });
351
+ * ```
352
+ */
353
+ deadLetterQueue?: boolean;
326
354
  }
327
355
 
328
356
  /**