@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,613 @@
1
+ /**
2
+ * Integration tests for the Dead Letter Queue service
3
+ *
4
+ * Covers:
5
+ * - Adding failed jobs to DLQ
6
+ * - Retrieving DLQ items
7
+ * - Listing with filters
8
+ * - Retry status transitions
9
+ * - Cleanup operations
10
+ * - Statistics calculation
11
+ */
12
+
13
+ import { it } from "@effect/vitest";
14
+ import { Context, Effect, Layer, Option } from "effect";
15
+ import { describe, expect } from "vitest";
16
+ import { UploadistaError } from "../../src/errors";
17
+ import {
18
+ createDeadLetterQueueService,
19
+ DeadLetterQueueService,
20
+ } from "../../src/flow/dead-letter-queue";
21
+ import type { DeadLetterItem } from "../../src/flow/types/dead-letter-item";
22
+ import type { FlowJob } from "../../src/flow/types/flow-job";
23
+ import {
24
+ BaseKvStoreService,
25
+ DeadLetterQueueKVStore,
26
+ deadLetterQueueKvStore,
27
+ type BaseKvStore,
28
+ } from "../../src/types/kv-store";
29
+
30
+ // In-memory KV store for testing
31
+ const createInMemoryKvStore = (): BaseKvStore => {
32
+ const store = new Map<string, string>();
33
+
34
+ return {
35
+ get: (key: string) =>
36
+ Effect.gen(function* () {
37
+ const value = store.get(key);
38
+ if (value === undefined) {
39
+ return yield* Effect.fail(
40
+ UploadistaError.fromCode("FILE_NOT_FOUND", {
41
+ cause: `Key ${key} not found`,
42
+ }),
43
+ );
44
+ }
45
+ return value;
46
+ }),
47
+ set: (key: string, value: string) =>
48
+ Effect.gen(function* () {
49
+ store.set(key, value);
50
+ }),
51
+ delete: (key: string) =>
52
+ Effect.gen(function* () {
53
+ store.delete(key);
54
+ }),
55
+ list: (prefix: string) =>
56
+ Effect.succeed([...store.keys()].filter((key) => key.startsWith(prefix))),
57
+ };
58
+ };
59
+
60
+ // Create test layers
61
+ const createTestLayers = () => {
62
+ const inMemoryStore = createInMemoryKvStore();
63
+ const baseKvStoreLayer = Layer.succeed(BaseKvStoreService, inMemoryStore);
64
+
65
+ return Layer.provide(deadLetterQueueKvStore, baseKvStoreLayer);
66
+ };
67
+
68
+ // Create a mock FlowJob for testing
69
+ const createMockFlowJob = (overrides?: Partial<FlowJob>): FlowJob => ({
70
+ id: `job_${Date.now()}`,
71
+ flowId: "test-flow",
72
+ storageId: "test-storage",
73
+ clientId: "test-client",
74
+ status: "failed",
75
+ tasks: [
76
+ {
77
+ nodeId: "input-node",
78
+ nodeName: "Input Node",
79
+ status: "completed",
80
+ result: { file: { id: "file_123" } },
81
+ },
82
+ {
83
+ nodeId: "process-node",
84
+ nodeName: "Process Node",
85
+ status: "failed",
86
+ },
87
+ ],
88
+ createdAt: new Date(),
89
+ updatedAt: new Date(),
90
+ executionState: {
91
+ executionOrder: ["input-node", "process-node"],
92
+ currentIndex: 1,
93
+ inputs: { input: { uploadId: "upload_123" } },
94
+ },
95
+ ...overrides,
96
+ });
97
+
98
+ describe("DeadLetterQueueService", () => {
99
+ describe("add", () => {
100
+ it.effect("should add a failed job to the DLQ", () =>
101
+ Effect.gen(function* () {
102
+ const dlqStore = yield* DeadLetterQueueKVStore;
103
+ const service = yield* createDeadLetterQueueService();
104
+
105
+ const job = createMockFlowJob();
106
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR", {
107
+ body: "Processing failed",
108
+ });
109
+
110
+ const item = yield* service.add(job, error);
111
+
112
+ expect(item.id).toMatch(/^dlq_/);
113
+ expect(item.jobId).toBe(job.id);
114
+ expect(item.flowId).toBe("test-flow");
115
+ expect(item.storageId).toBe("test-storage");
116
+ expect(item.clientId).toBe("test-client");
117
+ expect(item.error.code).toBe("FLOW_NODE_ERROR");
118
+ expect(item.error.message).toBe("Processing failed");
119
+ expect(item.retryCount).toBe(0);
120
+ expect(item.maxRetries).toBe(3); // Default
121
+ expect(item.status).toBe("pending");
122
+ expect(item.retryHistory).toHaveLength(0);
123
+ expect(item.createdAt).toBeInstanceOf(Date);
124
+ expect(item.updatedAt).toBeInstanceOf(Date);
125
+ }).pipe(Effect.provide(createTestLayers())),
126
+ );
127
+
128
+ it.effect("should capture node results from completed tasks", () =>
129
+ Effect.gen(function* () {
130
+ const service = yield* createDeadLetterQueueService();
131
+
132
+ const job = createMockFlowJob({
133
+ tasks: [
134
+ {
135
+ nodeId: "input-node",
136
+ nodeName: "Input",
137
+ status: "completed",
138
+ result: { file: { id: "file_123", size: 1024 } },
139
+ },
140
+ {
141
+ nodeId: "resize-node",
142
+ nodeName: "Resize",
143
+ status: "completed",
144
+ result: { thumbnail: { width: 200, height: 150 } },
145
+ },
146
+ {
147
+ nodeId: "output-node",
148
+ nodeName: "Output",
149
+ status: "failed",
150
+ },
151
+ ],
152
+ });
153
+ const error = new UploadistaError({
154
+ code: "STORAGE_ERROR",
155
+ status: 500,
156
+ body: "Upload failed",
157
+ });
158
+
159
+ const item = yield* service.add(job, error);
160
+
161
+ expect(item.nodeResults["input-node"]).toEqual({
162
+ file: { id: "file_123", size: 1024 },
163
+ });
164
+ expect(item.nodeResults["resize-node"]).toEqual({
165
+ thumbnail: { width: 200, height: 150 },
166
+ });
167
+ expect(item.failedAtNodeId).toBe("output-node");
168
+ }).pipe(Effect.provide(createTestLayers())),
169
+ );
170
+
171
+ it.effect("should use custom retry policy when provided", () =>
172
+ Effect.gen(function* () {
173
+ const service = yield* createDeadLetterQueueService();
174
+
175
+ const job = createMockFlowJob();
176
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
177
+
178
+ const item = yield* service.add(job, error, {
179
+ enabled: true,
180
+ maxRetries: 10,
181
+ backoff: { type: "immediate" },
182
+ ttlMs: 86400000, // 1 day
183
+ });
184
+
185
+ expect(item.maxRetries).toBe(10);
186
+ expect(item.expiresAt).toBeDefined();
187
+ }).pipe(Effect.provide(createTestLayers())),
188
+ );
189
+
190
+ it.effect("should mark as exhausted for non-retryable errors", () =>
191
+ Effect.gen(function* () {
192
+ const service = yield* createDeadLetterQueueService();
193
+
194
+ const job = createMockFlowJob();
195
+ const error = UploadistaError.fromCode("VALIDATION_ERROR");
196
+
197
+ const item = yield* service.add(job, error, {
198
+ enabled: true,
199
+ maxRetries: 3,
200
+ backoff: { type: "immediate" },
201
+ nonRetryableErrors: ["VALIDATION_ERROR"],
202
+ });
203
+
204
+ expect(item.status).toBe("exhausted");
205
+ expect(item.nextRetryAt).toBeUndefined();
206
+ }).pipe(Effect.provide(createTestLayers())),
207
+ );
208
+ });
209
+
210
+ describe("get and getOption", () => {
211
+ it.effect("should retrieve an existing item", () =>
212
+ Effect.gen(function* () {
213
+ const service = yield* createDeadLetterQueueService();
214
+
215
+ const job = createMockFlowJob();
216
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
217
+ const added = yield* service.add(job, error);
218
+
219
+ const retrieved = yield* service.get(added.id);
220
+
221
+ expect(retrieved.id).toBe(added.id);
222
+ expect(retrieved.jobId).toBe(job.id);
223
+ }).pipe(Effect.provide(createTestLayers())),
224
+ );
225
+
226
+ it.effect("should fail when item not found", () =>
227
+ Effect.gen(function* () {
228
+ const service = yield* createDeadLetterQueueService();
229
+
230
+ const result = yield* Effect.either(service.get("non-existent-id"));
231
+
232
+ expect(result._tag).toBe("Left");
233
+ }).pipe(Effect.provide(createTestLayers())),
234
+ );
235
+
236
+ it.effect("should return Option.none for non-existent item", () =>
237
+ Effect.gen(function* () {
238
+ const service = yield* createDeadLetterQueueService();
239
+
240
+ const result = yield* service.getOption("non-existent-id");
241
+
242
+ expect(Option.isNone(result)).toBe(true);
243
+ }).pipe(Effect.provide(createTestLayers())),
244
+ );
245
+
246
+ it.effect("should return Option.some for existing item", () =>
247
+ Effect.gen(function* () {
248
+ const service = yield* createDeadLetterQueueService();
249
+
250
+ const job = createMockFlowJob();
251
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
252
+ const added = yield* service.add(job, error);
253
+
254
+ const result = yield* service.getOption(added.id);
255
+
256
+ expect(Option.isSome(result)).toBe(true);
257
+ if (Option.isSome(result)) {
258
+ expect(result.value.id).toBe(added.id);
259
+ }
260
+ }).pipe(Effect.provide(createTestLayers())),
261
+ );
262
+ });
263
+
264
+ describe("list", () => {
265
+ it.effect("should list all items", () =>
266
+ Effect.gen(function* () {
267
+ const service = yield* createDeadLetterQueueService();
268
+
269
+ // Add multiple items
270
+ const job1 = createMockFlowJob({ id: "job_1" });
271
+ const job2 = createMockFlowJob({ id: "job_2" });
272
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
273
+
274
+ yield* service.add(job1, error);
275
+ yield* service.add(job2, error);
276
+
277
+ const { items, total } = yield* service.list();
278
+
279
+ expect(items).toHaveLength(2);
280
+ expect(total).toBe(2);
281
+ }).pipe(Effect.provide(createTestLayers())),
282
+ );
283
+
284
+ it.effect("should filter by status", () =>
285
+ Effect.gen(function* () {
286
+ const service = yield* createDeadLetterQueueService();
287
+
288
+ const job1 = createMockFlowJob({ id: "job_1" });
289
+ const job2 = createMockFlowJob({ id: "job_2" });
290
+ const retryableError = UploadistaError.fromCode("FLOW_NODE_ERROR");
291
+ const nonRetryableError = UploadistaError.fromCode("VALIDATION_ERROR");
292
+
293
+ yield* service.add(job1, retryableError);
294
+ yield* service.add(job2, nonRetryableError, {
295
+ enabled: true,
296
+ maxRetries: 0,
297
+ backoff: { type: "immediate" },
298
+ });
299
+
300
+ // Update second item to exhausted
301
+ const { items: allItems } = yield* service.list();
302
+ const job2Item = allItems.find((i) => i.jobId === "job_2");
303
+ if (job2Item) {
304
+ yield* service.update(job2Item.id, { status: "exhausted" });
305
+ }
306
+
307
+ const { items: pendingItems } = yield* service.list({
308
+ status: "pending",
309
+ });
310
+ const { items: exhaustedItems } = yield* service.list({
311
+ status: "exhausted",
312
+ });
313
+
314
+ expect(pendingItems.every((i) => i.status === "pending")).toBe(true);
315
+ expect(exhaustedItems.every((i) => i.status === "exhausted")).toBe(true);
316
+ }).pipe(Effect.provide(createTestLayers())),
317
+ );
318
+
319
+ it.effect("should filter by flowId", () =>
320
+ Effect.gen(function* () {
321
+ const service = yield* createDeadLetterQueueService();
322
+
323
+ const job1 = createMockFlowJob({ id: "job_1", flowId: "flow-a" });
324
+ const job2 = createMockFlowJob({ id: "job_2", flowId: "flow-b" });
325
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
326
+
327
+ yield* service.add(job1, error);
328
+ yield* service.add(job2, error);
329
+
330
+ const { items } = yield* service.list({ flowId: "flow-a" });
331
+
332
+ expect(items).toHaveLength(1);
333
+ expect(items[0]?.flowId).toBe("flow-a");
334
+ }).pipe(Effect.provide(createTestLayers())),
335
+ );
336
+
337
+ it.effect("should paginate results", () =>
338
+ Effect.gen(function* () {
339
+ const service = yield* createDeadLetterQueueService();
340
+
341
+ // Add 5 items
342
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
343
+ for (let i = 0; i < 5; i++) {
344
+ const job = createMockFlowJob({ id: `job_${i}` });
345
+ yield* service.add(job, error);
346
+ }
347
+
348
+ const { items: page1, total } = yield* service.list({
349
+ limit: 2,
350
+ offset: 0,
351
+ });
352
+ const { items: page2 } = yield* service.list({ limit: 2, offset: 2 });
353
+
354
+ expect(total).toBe(5);
355
+ expect(page1).toHaveLength(2);
356
+ expect(page2).toHaveLength(2);
357
+ }).pipe(Effect.provide(createTestLayers())),
358
+ );
359
+ });
360
+
361
+ describe("markRetrying and recordRetryFailure", () => {
362
+ it.effect("should transition to retrying status", () =>
363
+ Effect.gen(function* () {
364
+ const service = yield* createDeadLetterQueueService();
365
+
366
+ const job = createMockFlowJob();
367
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
368
+ const added = yield* service.add(job, error);
369
+
370
+ const updated = yield* service.markRetrying(added.id);
371
+
372
+ expect(updated.status).toBe("retrying");
373
+ // updatedAt should be at least the same or later (timing can be same millisecond)
374
+ expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
375
+ added.updatedAt.getTime(),
376
+ );
377
+ }).pipe(Effect.provide(createTestLayers())),
378
+ );
379
+
380
+ it.effect("should record retry failure and increment count", () =>
381
+ Effect.gen(function* () {
382
+ const service = yield* createDeadLetterQueueService();
383
+
384
+ const job = createMockFlowJob();
385
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
386
+ const added = yield* service.add(job, error);
387
+
388
+ yield* service.markRetrying(added.id);
389
+ const afterFailure = yield* service.recordRetryFailure(
390
+ added.id,
391
+ "Timeout error",
392
+ 5000,
393
+ );
394
+
395
+ expect(afterFailure.retryCount).toBe(1);
396
+ expect(afterFailure.status).toBe("pending");
397
+ expect(afterFailure.retryHistory).toHaveLength(1);
398
+ expect(afterFailure.retryHistory[0]?.error).toBe("Timeout error");
399
+ expect(afterFailure.retryHistory[0]?.durationMs).toBe(5000);
400
+ expect(afterFailure.nextRetryAt).toBeDefined();
401
+ }).pipe(Effect.provide(createTestLayers())),
402
+ );
403
+
404
+ it.effect("should mark as exhausted when max retries reached", () =>
405
+ Effect.gen(function* () {
406
+ const service = yield* createDeadLetterQueueService();
407
+
408
+ const job = createMockFlowJob();
409
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
410
+ const added = yield* service.add(job, error, {
411
+ enabled: true,
412
+ maxRetries: 2,
413
+ backoff: { type: "immediate" },
414
+ });
415
+
416
+ // First retry
417
+ yield* service.markRetrying(added.id);
418
+ yield* service.recordRetryFailure(added.id, "Error 1", 1000);
419
+
420
+ // Second retry (exhausts)
421
+ yield* service.markRetrying(added.id);
422
+ const afterSecondFailure = yield* service.recordRetryFailure(
423
+ added.id,
424
+ "Error 2",
425
+ 1000,
426
+ );
427
+
428
+ expect(afterSecondFailure.retryCount).toBe(2);
429
+ expect(afterSecondFailure.status).toBe("exhausted");
430
+ expect(afterSecondFailure.nextRetryAt).toBeUndefined();
431
+ }).pipe(Effect.provide(createTestLayers())),
432
+ );
433
+ });
434
+
435
+ describe("markResolved", () => {
436
+ it.effect("should transition to resolved status", () =>
437
+ Effect.gen(function* () {
438
+ const service = yield* createDeadLetterQueueService();
439
+
440
+ const job = createMockFlowJob();
441
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
442
+ const added = yield* service.add(job, error);
443
+
444
+ const resolved = yield* service.markResolved(added.id);
445
+
446
+ expect(resolved.status).toBe("resolved");
447
+ expect(resolved.nextRetryAt).toBeUndefined();
448
+ }).pipe(Effect.provide(createTestLayers())),
449
+ );
450
+ });
451
+
452
+ describe("delete", () => {
453
+ it.effect("should delete an item", () =>
454
+ Effect.gen(function* () {
455
+ const service = yield* createDeadLetterQueueService();
456
+
457
+ const job = createMockFlowJob();
458
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
459
+ const added = yield* service.add(job, error);
460
+
461
+ yield* service.delete(added.id);
462
+
463
+ const result = yield* service.getOption(added.id);
464
+ expect(Option.isNone(result)).toBe(true);
465
+ }).pipe(Effect.provide(createTestLayers())),
466
+ );
467
+ });
468
+
469
+ describe("getScheduledRetries", () => {
470
+ it.effect("should return items ready for retry", () =>
471
+ Effect.gen(function* () {
472
+ const service = yield* createDeadLetterQueueService();
473
+
474
+ const job = createMockFlowJob();
475
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
476
+ const added = yield* service.add(job, error, {
477
+ enabled: true,
478
+ maxRetries: 3,
479
+ backoff: { type: "immediate" }, // Immediate retry
480
+ });
481
+
482
+ // Item should be immediately ready for retry
483
+ const readyItems = yield* service.getScheduledRetries();
484
+
485
+ expect(readyItems.length).toBeGreaterThanOrEqual(1);
486
+ expect(readyItems.some((i) => i.id === added.id)).toBe(true);
487
+ }).pipe(Effect.provide(createTestLayers())),
488
+ );
489
+
490
+ it.effect("should respect limit parameter", () =>
491
+ Effect.gen(function* () {
492
+ const service = yield* createDeadLetterQueueService();
493
+
494
+ // Add multiple items
495
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
496
+ for (let i = 0; i < 5; i++) {
497
+ const job = createMockFlowJob({ id: `job_${i}` });
498
+ yield* service.add(job, error, {
499
+ enabled: true,
500
+ maxRetries: 3,
501
+ backoff: { type: "immediate" },
502
+ });
503
+ }
504
+
505
+ const readyItems = yield* service.getScheduledRetries(2);
506
+
507
+ expect(readyItems).toHaveLength(2);
508
+ }).pipe(Effect.provide(createTestLayers())),
509
+ );
510
+ });
511
+
512
+ describe("cleanup", () => {
513
+ it.effect("should cleanup items based on olderThan", () =>
514
+ Effect.gen(function* () {
515
+ const service = yield* createDeadLetterQueueService();
516
+
517
+ const job = createMockFlowJob();
518
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
519
+
520
+ // Add item
521
+ const added = yield* service.add(job, error);
522
+
523
+ // Mark as resolved (so it can be cleaned up)
524
+ yield* service.markResolved(added.id);
525
+
526
+ // Cleanup with a future date (should catch all)
527
+ const futureDate = new Date(Date.now() + 1000000);
528
+ const result = yield* service.cleanup({
529
+ olderThan: futureDate,
530
+ status: "resolved",
531
+ });
532
+
533
+ expect(result.deleted).toBeGreaterThanOrEqual(1);
534
+
535
+ const check = yield* service.getOption(added.id);
536
+ expect(Option.isNone(check)).toBe(true);
537
+ }).pipe(Effect.provide(createTestLayers())),
538
+ );
539
+
540
+ it.effect("should cleanup resolved items older than threshold", () =>
541
+ Effect.gen(function* () {
542
+ const service = yield* createDeadLetterQueueService();
543
+
544
+ const job = createMockFlowJob();
545
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
546
+ const added = yield* service.add(job, error);
547
+
548
+ // Mark as resolved
549
+ yield* service.markResolved(added.id);
550
+
551
+ // Cleanup items older than future date (should catch all)
552
+ const futureDate = new Date(Date.now() + 1000000);
553
+ const result = yield* service.cleanup({
554
+ olderThan: futureDate,
555
+ status: "resolved",
556
+ });
557
+
558
+ expect(result.deleted).toBeGreaterThanOrEqual(1);
559
+ }).pipe(Effect.provide(createTestLayers())),
560
+ );
561
+ });
562
+
563
+ describe("getStats", () => {
564
+ it.effect("should return correct statistics", () =>
565
+ Effect.gen(function* () {
566
+ const service = yield* createDeadLetterQueueService();
567
+
568
+ const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
569
+
570
+ // Add items with different statuses
571
+ const job1 = createMockFlowJob({ id: "job_1", flowId: "flow-a" });
572
+ const job2 = createMockFlowJob({ id: "job_2", flowId: "flow-a" });
573
+ const job3 = createMockFlowJob({ id: "job_3", flowId: "flow-b" });
574
+
575
+ const item1 = yield* service.add(job1, error);
576
+ const item2 = yield* service.add(job2, error);
577
+ yield* service.add(job3, error);
578
+
579
+ // Mark one as resolved
580
+ yield* service.markResolved(item1.id);
581
+
582
+ // Do a retry on another
583
+ yield* service.markRetrying(item2.id);
584
+ yield* service.recordRetryFailure(item2.id, "Error", 1000);
585
+
586
+ const stats = yield* service.getStats();
587
+
588
+ expect(stats.totalItems).toBe(3);
589
+ expect(stats.byStatus.resolved).toBe(1);
590
+ expect(stats.byStatus.pending).toBe(2);
591
+ expect(stats.byFlow["flow-a"]).toBe(2);
592
+ expect(stats.byFlow["flow-b"]).toBe(1);
593
+ expect(stats.averageRetryCount).toBeGreaterThan(0);
594
+ }).pipe(Effect.provide(createTestLayers())),
595
+ );
596
+
597
+ it.effect("should handle empty queue", () =>
598
+ Effect.gen(function* () {
599
+ const service = yield* createDeadLetterQueueService();
600
+
601
+ const stats = yield* service.getStats();
602
+
603
+ expect(stats.totalItems).toBe(0);
604
+ expect(stats.byStatus.pending).toBe(0);
605
+ expect(stats.byStatus.retrying).toBe(0);
606
+ expect(stats.byStatus.exhausted).toBe(0);
607
+ expect(stats.byStatus.resolved).toBe(0);
608
+ expect(stats.averageRetryCount).toBe(0);
609
+ expect(stats.oldestItem).toBeUndefined();
610
+ }).pipe(Effect.provide(createTestLayers())),
611
+ );
612
+ });
613
+ });