@uploadista/core 0.2.0 → 1.0.0-beta.3

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 (112) hide show
  1. package/dist/{checksum-BjP9nb5b.mjs → checksum-BRjFmTRk.mjs} +2 -2
  2. package/dist/{checksum-BjP9nb5b.mjs.map → checksum-BRjFmTRk.mjs.map} +1 -1
  3. package/dist/{checksum-B7RDiO7V.cjs → checksum-BrjQ8GJL.cjs} +1 -1
  4. package/dist/errors/index.cjs +1 -1
  5. package/dist/errors/index.d.cts +1 -1
  6. package/dist/errors/index.d.mts +1 -1
  7. package/dist/errors/index.mjs +1 -1
  8. package/dist/flow/index.cjs +1 -1
  9. package/dist/flow/index.d.cts +3 -2
  10. package/dist/flow/index.d.mts +8 -5
  11. package/dist/flow/index.mjs +1 -1
  12. package/dist/generate-id-BAMRQzMr.d.cts +34 -0
  13. package/dist/generate-id-BAMRQzMr.d.cts.map +1 -0
  14. package/dist/generate-id-DuZwLm4m.d.mts +34 -0
  15. package/dist/generate-id-DuZwLm4m.d.mts.map +1 -0
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.d.cts +8 -5
  18. package/dist/index.d.mts +8 -5
  19. package/dist/index.mjs +1 -1
  20. package/dist/middleware-BghazxzH.d.cts +4129 -0
  21. package/dist/middleware-BghazxzH.d.cts.map +1 -0
  22. package/dist/middleware-CYizzAhP.d.mts +4129 -0
  23. package/dist/middleware-CYizzAhP.d.mts.map +1 -0
  24. package/dist/resolve-upload-metadata-CYl2PHIs.d.mts +4542 -0
  25. package/dist/resolve-upload-metadata-CYl2PHIs.d.mts.map +1 -0
  26. package/dist/resolve-upload-metadata-D0qFuyWc.d.cts +4542 -0
  27. package/dist/resolve-upload-metadata-D0qFuyWc.d.cts.map +1 -0
  28. package/dist/run-args-CM14Vtzu.cjs +1 -0
  29. package/dist/run-args-DSKHoSWs.mjs +2 -0
  30. package/dist/run-args-DSKHoSWs.mjs.map +1 -0
  31. package/dist/{stream-limiter-BCFULdAM.d.cts → stream-limiter-7wkBVLWT.d.mts} +2 -2
  32. package/dist/{stream-limiter-BCFULdAM.d.cts.map → stream-limiter-7wkBVLWT.d.mts.map} +1 -1
  33. package/dist/{stream-limiter-DZ22uIqf.cjs → stream-limiter-B-Y0DTgA.cjs} +1 -1
  34. package/dist/{stream-limiter-CTJPEJqE.mjs → stream-limiter-CvDuNIyd.mjs} +2 -2
  35. package/dist/{stream-limiter-CTJPEJqE.mjs.map → stream-limiter-CvDuNIyd.mjs.map} +1 -1
  36. package/dist/{stream-limiter-Bi7OTbRp.d.mts → stream-limiter-D1KC-6pK.d.cts} +2 -2
  37. package/dist/{stream-limiter-Bi7OTbRp.d.mts.map → stream-limiter-D1KC-6pK.d.cts.map} +1 -1
  38. package/dist/streams/index.cjs +1 -1
  39. package/dist/streams/index.d.cts +1 -1
  40. package/dist/streams/index.d.mts +2 -2
  41. package/dist/streams/index.mjs +1 -1
  42. package/dist/testing/index.cjs +1 -1
  43. package/dist/testing/index.d.cts +2 -1
  44. package/dist/testing/index.d.cts.map +1 -1
  45. package/dist/testing/index.d.mts +7 -4
  46. package/dist/testing/index.d.mts.map +1 -1
  47. package/dist/testing/index.mjs +1 -1
  48. package/dist/{throttle-Da0OA8JT.d.cts → throttle-3FRcr7MU.d.mts} +4 -34
  49. package/dist/throttle-3FRcr7MU.d.mts.map +1 -0
  50. package/dist/{throttle-ibiT6E4U.d.mts → throttle-BlH27EGu.d.cts} +4 -34
  51. package/dist/throttle-BlH27EGu.d.cts.map +1 -0
  52. package/dist/{throttle-KnkRgZPi.cjs → throttle-Dp59f37i.cjs} +1 -1
  53. package/dist/{throttle-CnDa3v1k.mjs → throttle-TFY-V41R.mjs} +2 -2
  54. package/dist/{throttle-CnDa3v1k.mjs.map → throttle-TFY-V41R.mjs.map} +1 -1
  55. package/dist/types/index.cjs +1 -1
  56. package/dist/types/index.d.cts +2 -2
  57. package/dist/types/index.d.mts +3 -5
  58. package/dist/types/index.mjs +1 -1
  59. package/dist/upload/index.cjs +1 -1
  60. package/dist/upload/index.d.cts +1 -1
  61. package/dist/upload/index.d.mts +4 -4
  62. package/dist/upload/index.mjs +1 -1
  63. package/dist/upload-strategy-negotiator-0-dpNIce.d.cts +455 -0
  64. package/dist/upload-strategy-negotiator-0-dpNIce.d.cts.map +1 -0
  65. package/dist/upload-strategy-negotiator-BR_o1Ez8.cjs +1 -0
  66. package/dist/upload-strategy-negotiator-C9MeoOnW.mjs +2 -0
  67. package/dist/upload-strategy-negotiator-C9MeoOnW.mjs.map +1 -0
  68. package/dist/upload-strategy-negotiator-CEnlfVgJ.d.mts +455 -0
  69. package/dist/upload-strategy-negotiator-CEnlfVgJ.d.mts.map +1 -0
  70. package/dist/{uploadista-error-B-geDgi8.cjs → uploadista-error-CZx1JU_L.cjs} +3 -1
  71. package/dist/{uploadista-error-Fsfvr2Bb.mjs → uploadista-error-DQ7V1FlX.mjs} +3 -1
  72. package/dist/uploadista-error-DQ7V1FlX.mjs.map +1 -0
  73. package/dist/{uploadista-error-BragVhIs.d.mts → uploadista-error-LtiZn-R_.d.mts} +2 -2
  74. package/dist/{uploadista-error-BragVhIs.d.mts.map → uploadista-error-LtiZn-R_.d.mts.map} +1 -1
  75. package/dist/{uploadista-error-Cj_pAFck.d.cts → uploadista-error-eZtG4iyf.d.cts} +2 -2
  76. package/dist/{uploadista-error-Cj_pAFck.d.cts.map → uploadista-error-eZtG4iyf.d.cts.map} +1 -1
  77. package/dist/utils/index.cjs +1 -1
  78. package/dist/utils/index.d.cts +2 -1
  79. package/dist/utils/index.d.mts +3 -2
  80. package/dist/utils/index.mjs +1 -1
  81. package/dist/websocket-Br0ijEZA.cjs +1 -0
  82. package/dist/websocket-DftnHFfN.mjs +2 -0
  83. package/dist/websocket-DftnHFfN.mjs.map +1 -0
  84. package/package.json +3 -3
  85. package/src/errors/uploadista-error.ts +11 -1
  86. package/src/flow/README.md +115 -0
  87. package/src/flow/flow-engine.ts +36 -2
  88. package/src/flow/flow-queue-store.ts +155 -0
  89. package/src/flow/flow-queue.ts +640 -0
  90. package/src/flow/index.ts +4 -0
  91. package/src/flow/types/flow-queue-item.ts +154 -0
  92. package/src/types/data-store.ts +3 -3
  93. package/src/types/kv-store.ts +31 -1
  94. package/src/upload/write-to-store.ts +24 -29
  95. package/tests/flow-queue-store.test.ts +150 -0
  96. package/tests/flow-queue.test.ts +308 -0
  97. package/dist/resolve-upload-metadata-BUVl1LoS.d.cts +0 -8723
  98. package/dist/resolve-upload-metadata-BUVl1LoS.d.cts.map +0 -1
  99. package/dist/resolve-upload-metadata-MPDmDfOZ.d.mts +0 -8723
  100. package/dist/resolve-upload-metadata-MPDmDfOZ.d.mts.map +0 -1
  101. package/dist/run-args-WD1otVrz.mjs +0 -2
  102. package/dist/run-args-WD1otVrz.mjs.map +0 -1
  103. package/dist/run-args-g74p8pEZ.cjs +0 -1
  104. package/dist/throttle-Da0OA8JT.d.cts.map +0 -1
  105. package/dist/throttle-ibiT6E4U.d.mts.map +0 -1
  106. package/dist/upload-strategy-negotiator-BuxPf1sa.mjs +0 -2
  107. package/dist/upload-strategy-negotiator-BuxPf1sa.mjs.map +0 -1
  108. package/dist/upload-strategy-negotiator-DfiQ0Fy0.cjs +0 -1
  109. package/dist/uploadista-error-Fsfvr2Bb.mjs.map +0 -1
  110. package/dist/websocket-Avz4T8YB.cjs +0 -1
  111. package/dist/websocket-CdgVhVJs.mjs +0 -2
  112. package/dist/websocket-CdgVhVJs.mjs.map +0 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Flow Queue item types and configuration.
3
+ *
4
+ * A FlowQueueItem represents a queued flow execution request, tracking its
5
+ * lifecycle from pending → running → completed | failed.
6
+ *
7
+ * @module flow/types/flow-queue-item
8
+ * @see {@link FlowQueueService} for queue operations
9
+ */
10
+
11
+ /**
12
+ * Status of a flow queue item.
13
+ *
14
+ * Item lifecycle: pending → running → completed | failed
15
+ *
16
+ * - `pending`: Waiting for a concurrency slot to become available
17
+ * - `running`: Currently being executed by the flow engine
18
+ * - `completed`: Flow execution finished successfully
19
+ * - `failed`: Flow execution ended with an error
20
+ */
21
+ export type FlowQueueItemStatus = "pending" | "running" | "completed" | "failed";
22
+
23
+ /**
24
+ * Represents a single queued flow execution request.
25
+ *
26
+ * FlowQueueItems are created when a caller enqueues a flow for execution.
27
+ * The worker loop picks up pending items, transitions them to running, and
28
+ * dispatches them to FlowEngine. On completion or failure the item is updated.
29
+ *
30
+ * @property id - Unique queue item identifier (UUID)
31
+ * @property flowId - The flow definition to execute
32
+ * @property storageId - Target storage for flow outputs
33
+ * @property input - Original input payload passed to the flow
34
+ * @property clientId - Client who initiated the request (null for anonymous)
35
+ * @property status - Current lifecycle status of the queue item
36
+ * @property dlqItemId - Set when this item is a DLQ retry; links back to the DLQ item
37
+ * @property enqueuedAt - When the item was added to the queue
38
+ * @property startedAt - When the worker started executing this item
39
+ * @property completedAt - When execution finished (success or failure)
40
+ * @property error - Error message recorded if status is "failed"
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const item: FlowQueueItem = {
45
+ * id: "q_abc123",
46
+ * flowId: "image-resize-pipeline",
47
+ * storageId: "s3-production",
48
+ * input: { input: { uploadId: "upload_xyz" } },
49
+ * clientId: "client_456",
50
+ * status: "pending",
51
+ * enqueuedAt: new Date(),
52
+ * };
53
+ * ```
54
+ */
55
+ export interface FlowQueueItem {
56
+ /** Unique queue item identifier (UUID) */
57
+ id: string;
58
+ /** The flow definition to execute */
59
+ flowId: string;
60
+ /** Target storage for flow outputs */
61
+ storageId: string;
62
+ /** Original input payload passed to the flow */
63
+ input: unknown;
64
+ /** Client who initiated the request (null for anonymous) */
65
+ clientId: string | null;
66
+ /** Current lifecycle status */
67
+ status: FlowQueueItemStatus;
68
+ /** Set when this is a DLQ retry; references the DLQ item for result correlation */
69
+ dlqItemId?: string;
70
+ /** When the item was added to the queue */
71
+ enqueuedAt: Date;
72
+ /** When the worker began executing this item */
73
+ startedAt?: Date;
74
+ /** When execution finished (success or failure) */
75
+ completedAt?: Date;
76
+ /** Error message if status is "failed" */
77
+ error?: string;
78
+ }
79
+
80
+ /**
81
+ * Aggregate statistics about the flow queue.
82
+ *
83
+ * Provides counts by status and concurrency information for monitoring.
84
+ *
85
+ * @property pending - Number of items waiting for a concurrency slot
86
+ * @property running - Number of items currently being executed
87
+ * @property completed - Number of items that finished successfully
88
+ * @property failed - Number of items that ended with an error
89
+ * @property maxConcurrency - Configured maximum simultaneous executions
90
+ * @property currentConcurrency - Number of items currently running
91
+ */
92
+ export interface FlowQueueStats {
93
+ /** Number of items waiting for a concurrency slot */
94
+ pending: number;
95
+ /** Number of items currently being executed */
96
+ running: number;
97
+ /** Number of items that finished successfully */
98
+ completed: number;
99
+ /** Number of items that ended with an error */
100
+ failed: number;
101
+ /** Configured maximum simultaneous executions */
102
+ maxConcurrency: number;
103
+ /** Number of items currently running */
104
+ currentConcurrency: number;
105
+ }
106
+
107
+ /**
108
+ * Configuration options for the FlowQueueService.
109
+ *
110
+ * All fields are optional; defaults are applied via DEFAULT_QUEUE_CONFIG.
111
+ *
112
+ * @property maxConcurrency - Maximum number of simultaneously running flows (default: 4)
113
+ * @property dlqRetryIntervalMs - How often the DLQ retry loop fires in milliseconds (default: 30_000)
114
+ * @property dlqRetryBatchSize - Maximum DLQ items processed per retry loop tick (default: 10)
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * const config: FlowQueueConfig = {
119
+ * maxConcurrency: 8,
120
+ * dlqRetryIntervalMs: 60_000, // check every 60 seconds
121
+ * dlqRetryBatchSize: 5,
122
+ * };
123
+ * ```
124
+ */
125
+ export interface FlowQueueConfig {
126
+ /**
127
+ * Maximum number of simultaneously running flows.
128
+ * Flows beyond this limit remain pending until a slot opens.
129
+ * @default 4
130
+ */
131
+ maxConcurrency?: number;
132
+ /**
133
+ * Interval in milliseconds between DLQ retry loop ticks.
134
+ * Only relevant when DeadLetterQueueService is present.
135
+ * @default 30_000
136
+ */
137
+ dlqRetryIntervalMs?: number;
138
+ /**
139
+ * Maximum number of DLQ items to re-enqueue per retry loop tick.
140
+ * @default 10
141
+ */
142
+ dlqRetryBatchSize?: number;
143
+ }
144
+
145
+ /**
146
+ * Default configuration values for FlowQueueService.
147
+ *
148
+ * Applied when specific config fields are omitted.
149
+ */
150
+ export const DEFAULT_QUEUE_CONFIG: Required<FlowQueueConfig> = {
151
+ maxConcurrency: 4,
152
+ dlqRetryIntervalMs: 30_000,
153
+ dlqRetryBatchSize: 10,
154
+ };
@@ -213,10 +213,10 @@ export type DataStoreCapabilities = {
213
213
  * write: ({ file_id, stream, offset }, { onProgress }) => Effect.gen(function* () {
214
214
  * // Write chunks to storage
215
215
  * let bytesWritten = offset;
216
- * yield* Stream.runForEach(stream, (chunk) => Effect.sync(() => {
216
+ * yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () {
217
217
  * writeChunk(file_id, chunk, bytesWritten);
218
218
  * bytesWritten += chunk.byteLength;
219
- * onProgress?.(chunk.byteLength);
219
+ * yield* (onProgress?.(bytesWritten) ?? Effect.void);
220
220
  * }));
221
221
  * return bytesWritten;
222
222
  * }),
@@ -286,7 +286,7 @@ export type DataStore<TData = unknown> = {
286
286
  readonly write: (
287
287
  options: DataStoreWriteOptions,
288
288
  dependencies: {
289
- onProgress?: (chunkSize: number) => void;
289
+ onProgress?: (offset: number) => Effect.Effect<void>;
290
290
  },
291
291
  ) => Effect.Effect<number, UploadistaError>;
292
292
  /**
@@ -1,6 +1,8 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import { UploadistaError } from "../errors";
3
- import type { DeadLetterItem, FlowJob } from "../flow";
3
+ import type { DeadLetterItem } from "../flow/types/dead-letter-item";
4
+ import type { FlowJob } from "../flow/types/flow-job";
5
+ import type { FlowQueueItem } from "../flow/types/flow-queue-item";
4
6
  import type { UploadFile } from "./upload-file";
5
7
 
6
8
  /**
@@ -454,3 +456,31 @@ export const deadLetterQueueKvStore = Layer.effect(
454
456
  );
455
457
  }),
456
458
  );
459
+
460
+ /**
461
+ * Effect-TS context tag for the FlowQueueItem typed KV store.
462
+ */
463
+ export class FlowQueueKVStore extends Context.Tag("FlowQueueKVStore")<
464
+ FlowQueueKVStore,
465
+ KvStore<FlowQueueItem>
466
+ >() {}
467
+
468
+ /**
469
+ * Effect Layer that creates the FlowQueueKVStore from a BaseKvStore.
470
+ *
471
+ * Stores queue items as JSON under the "uploadista:queue-item:" prefix.
472
+ * Used by FlowQueueService.fromKvStore() so the queue can be backed by
473
+ * any BaseKvStoreService (Redis, filesystem, Cloudflare KV, etc.).
474
+ */
475
+ export const flowQueueKvStore = Layer.effect(
476
+ FlowQueueKVStore,
477
+ Effect.gen(function* () {
478
+ const baseStore = yield* BaseKvStoreService;
479
+ return new TypedKvStore<FlowQueueItem>(
480
+ baseStore,
481
+ "uploadista:queue-item:",
482
+ jsonSerializer.serialize,
483
+ jsonSerializer.deserialize,
484
+ );
485
+ }),
486
+ );
@@ -1,4 +1,4 @@
1
- import { Effect, Ref } from "effect";
1
+ import { Effect } from "effect";
2
2
  import { UploadistaError } from "../errors";
3
3
  import { StreamLimiterEffect } from "../streams/stream-limiter";
4
4
  import type { DataStore, UploadEvent, UploadFile } from "../types";
@@ -108,8 +108,13 @@ export function writeToStore({
108
108
  Effect.sync(() => ({ signal, onAbort })),
109
109
  ({ signal: _signal }) =>
110
110
  Effect.gen(function* () {
111
- // Create a ref to track the last progress emission time for throttling
112
- const lastEmitTime = yield* Ref.make(0);
111
+ // Plain mutable variable for throttle timing.
112
+ // Must NOT use a Ref here: the onProgress callback is synchronous and
113
+ // called from within the data-store's stream fiber. Using Effect.runPromise
114
+ // inside onProgress causes a race condition where all calls capture the same
115
+ // `now` timestamp before any update runs, so only 1 event fires per PATCH.
116
+ // A plain let variable updates synchronously, preventing the race.
117
+ let lastEmitTimeMs = 0;
113
118
 
114
119
  // Create the stream limiter
115
120
  const limiter = StreamLimiterEffect.limit({
@@ -127,33 +132,23 @@ export function writeToStore({
127
132
  offset: upload.offset,
128
133
  },
129
134
  {
130
- onProgress: (newOffset: number) => {
131
- // Simple throttling using timestamp check
135
+ onProgress: (newOffset: number): Effect.Effect<void> => {
132
136
  const now = Date.now();
133
- Ref.get(lastEmitTime)
134
- .pipe(
135
- Effect.flatMap((lastTime) => {
136
- if (now - lastTime >= uploadProgressInterval) {
137
- return Effect.gen(function* () {
138
- yield* Ref.set(lastEmitTime, now);
139
- yield* eventEmitter.emit(upload.id, {
140
- type: UploadEventType.UPLOAD_PROGRESS,
141
- data: {
142
- id: upload.id,
143
- progress: newOffset,
144
- total: upload.size ?? 0,
145
- },
146
- flow: upload.flow,
147
- });
148
- });
149
- }
150
- return Effect.void;
151
- }),
152
- Effect.runPromise,
153
- )
154
- .catch(() => {
155
- // Ignore errors during progress emission
156
- });
137
+ if (now - lastEmitTimeMs >= uploadProgressInterval) {
138
+ lastEmitTimeMs = now;
139
+ return eventEmitter
140
+ .emit(upload.id, {
141
+ type: UploadEventType.UPLOAD_PROGRESS,
142
+ data: {
143
+ id: upload.id,
144
+ progress: newOffset,
145
+ total: upload.size ?? 0,
146
+ },
147
+ flow: upload.flow,
148
+ })
149
+ .pipe(Effect.catchAll(() => Effect.void));
150
+ }
151
+ return Effect.void;
157
152
  },
158
153
  },
159
154
  );
@@ -0,0 +1,150 @@
1
+ import { Effect } from "effect";
2
+ import { describe, expect, it, beforeEach } from "vitest";
3
+ import { MemoryFlowQueueStore } from "../src/flow/flow-queue-store";
4
+ import type { FlowQueueItem } from "../src/flow/types/flow-queue-item";
5
+
6
+ function makeItem(overrides: Partial<FlowQueueItem> = {}): FlowQueueItem {
7
+ return {
8
+ id: `q_${Math.random().toString(36).slice(2)}`,
9
+ flowId: "test-flow",
10
+ storageId: "s3-test",
11
+ input: { files: ["a.png"] },
12
+ clientId: "client-1",
13
+ status: "pending",
14
+ enqueuedAt: new Date("2024-01-01T10:00:00Z"),
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ describe("MemoryFlowQueueStore", () => {
20
+ let store: MemoryFlowQueueStore;
21
+
22
+ beforeEach(() => {
23
+ store = new MemoryFlowQueueStore();
24
+ });
25
+
26
+ describe("createItem", () => {
27
+ it("stores and returns the item", async () => {
28
+ const item = makeItem();
29
+ const result = await Effect.runPromise(store.createItem(item));
30
+ expect(result).toMatchObject({ id: item.id, status: "pending" });
31
+ });
32
+
33
+ it("allows creating multiple items", async () => {
34
+ const a = makeItem({ id: "q_a" });
35
+ const b = makeItem({ id: "q_b" });
36
+ await Effect.runPromise(
37
+ Effect.all([store.createItem(a), store.createItem(b)]),
38
+ );
39
+ const pending = await Effect.runPromise(store.listByStatus("pending"));
40
+ expect(pending).toHaveLength(2);
41
+ });
42
+ });
43
+
44
+ describe("getItem", () => {
45
+ it("returns the item when it exists", async () => {
46
+ const item = makeItem({ id: "q_known" });
47
+ await Effect.runPromise(store.createItem(item));
48
+ const result = await Effect.runPromise(store.getItem("q_known"));
49
+ expect(result).not.toBeNull();
50
+ expect(result!.id).toBe("q_known");
51
+ });
52
+
53
+ it("returns null for unknown IDs", async () => {
54
+ const result = await Effect.runPromise(store.getItem("q_unknown"));
55
+ expect(result).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("updateItem", () => {
60
+ it("applies partial updates", async () => {
61
+ const item = makeItem({ id: "q_upd" });
62
+ await Effect.runPromise(store.createItem(item));
63
+
64
+ const updated = await Effect.runPromise(
65
+ store.updateItem("q_upd", { status: "running", startedAt: new Date() }),
66
+ );
67
+
68
+ expect(updated.status).toBe("running");
69
+ expect(updated.startedAt).toBeInstanceOf(Date);
70
+ expect(updated.flowId).toBe("test-flow"); // unchanged
71
+ });
72
+
73
+ it("fails with FLOW_JOB_NOT_FOUND for unknown ID", async () => {
74
+ const result = await Effect.runPromise(
75
+ Effect.either(store.updateItem("q_missing", { status: "running" })),
76
+ );
77
+ expect(result._tag).toBe("Left");
78
+ if (result._tag === "Left") {
79
+ expect(result.left.code).toBe("FLOW_JOB_NOT_FOUND");
80
+ }
81
+ });
82
+ });
83
+
84
+ describe("listByStatus", () => {
85
+ it("filters by status correctly", async () => {
86
+ const pending = makeItem({ id: "q_p1", status: "pending" });
87
+ const running = makeItem({ id: "q_r1", status: "running" });
88
+ const completed = makeItem({ id: "q_c1", status: "completed" });
89
+ await Effect.runPromise(
90
+ Effect.all([
91
+ store.createItem(pending),
92
+ store.createItem(running),
93
+ store.createItem(completed),
94
+ ]),
95
+ );
96
+
97
+ const pendingList = await Effect.runPromise(
98
+ store.listByStatus("pending"),
99
+ );
100
+ expect(pendingList).toHaveLength(1);
101
+ expect(pendingList[0].id).toBe("q_p1");
102
+
103
+ const runningList = await Effect.runPromise(
104
+ store.listByStatus("running"),
105
+ );
106
+ expect(runningList).toHaveLength(1);
107
+ expect(runningList[0].id).toBe("q_r1");
108
+ });
109
+
110
+ it("returns pending items in FIFO order (oldest first)", async () => {
111
+ const early = makeItem({
112
+ id: "q_early",
113
+ enqueuedAt: new Date("2024-01-01T09:00:00Z"),
114
+ });
115
+ const late = makeItem({
116
+ id: "q_late",
117
+ enqueuedAt: new Date("2024-01-01T11:00:00Z"),
118
+ });
119
+ // Insert late first to verify sorting
120
+ await Effect.runPromise(
121
+ Effect.all([store.createItem(late), store.createItem(early)]),
122
+ );
123
+
124
+ const pending = await Effect.runPromise(store.listByStatus("pending"));
125
+ expect(pending[0].id).toBe("q_early");
126
+ expect(pending[1].id).toBe("q_late");
127
+ });
128
+
129
+ it("returns empty array when no items match status", async () => {
130
+ const pending = await Effect.runPromise(store.listByStatus("completed"));
131
+ expect(pending).toHaveLength(0);
132
+ });
133
+ });
134
+
135
+ describe("deleteItem", () => {
136
+ it("removes the item", async () => {
137
+ const item = makeItem({ id: "q_del" });
138
+ await Effect.runPromise(store.createItem(item));
139
+ await Effect.runPromise(store.deleteItem("q_del"));
140
+ const result = await Effect.runPromise(store.getItem("q_del"));
141
+ expect(result).toBeNull();
142
+ });
143
+
144
+ it("is idempotent for unknown IDs", async () => {
145
+ await expect(
146
+ Effect.runPromise(store.deleteItem("q_nonexistent")),
147
+ ).resolves.toBeUndefined();
148
+ });
149
+ });
150
+ });