@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.
- package/dist/{checksum-BjP9nb5b.mjs → checksum-BRjFmTRk.mjs} +2 -2
- package/dist/{checksum-BjP9nb5b.mjs.map → checksum-BRjFmTRk.mjs.map} +1 -1
- package/dist/{checksum-B7RDiO7V.cjs → checksum-BrjQ8GJL.cjs} +1 -1
- package/dist/errors/index.cjs +1 -1
- package/dist/errors/index.d.cts +1 -1
- package/dist/errors/index.d.mts +1 -1
- package/dist/errors/index.mjs +1 -1
- package/dist/flow/index.cjs +1 -1
- package/dist/flow/index.d.cts +3 -2
- package/dist/flow/index.d.mts +8 -5
- package/dist/flow/index.mjs +1 -1
- package/dist/generate-id-BAMRQzMr.d.cts +34 -0
- package/dist/generate-id-BAMRQzMr.d.cts.map +1 -0
- package/dist/generate-id-DuZwLm4m.d.mts +34 -0
- package/dist/generate-id-DuZwLm4m.d.mts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +8 -5
- package/dist/index.d.mts +8 -5
- package/dist/index.mjs +1 -1
- package/dist/middleware-BghazxzH.d.cts +4129 -0
- package/dist/middleware-BghazxzH.d.cts.map +1 -0
- package/dist/middleware-CYizzAhP.d.mts +4129 -0
- package/dist/middleware-CYizzAhP.d.mts.map +1 -0
- package/dist/resolve-upload-metadata-CYl2PHIs.d.mts +4542 -0
- package/dist/resolve-upload-metadata-CYl2PHIs.d.mts.map +1 -0
- package/dist/resolve-upload-metadata-D0qFuyWc.d.cts +4542 -0
- package/dist/resolve-upload-metadata-D0qFuyWc.d.cts.map +1 -0
- package/dist/run-args-CM14Vtzu.cjs +1 -0
- package/dist/run-args-DSKHoSWs.mjs +2 -0
- package/dist/run-args-DSKHoSWs.mjs.map +1 -0
- package/dist/{stream-limiter-BCFULdAM.d.cts → stream-limiter-7wkBVLWT.d.mts} +2 -2
- package/dist/{stream-limiter-BCFULdAM.d.cts.map → stream-limiter-7wkBVLWT.d.mts.map} +1 -1
- package/dist/{stream-limiter-DZ22uIqf.cjs → stream-limiter-B-Y0DTgA.cjs} +1 -1
- package/dist/{stream-limiter-CTJPEJqE.mjs → stream-limiter-CvDuNIyd.mjs} +2 -2
- package/dist/{stream-limiter-CTJPEJqE.mjs.map → stream-limiter-CvDuNIyd.mjs.map} +1 -1
- package/dist/{stream-limiter-Bi7OTbRp.d.mts → stream-limiter-D1KC-6pK.d.cts} +2 -2
- package/dist/{stream-limiter-Bi7OTbRp.d.mts.map → stream-limiter-D1KC-6pK.d.cts.map} +1 -1
- package/dist/streams/index.cjs +1 -1
- package/dist/streams/index.d.cts +1 -1
- package/dist/streams/index.d.mts +2 -2
- package/dist/streams/index.mjs +1 -1
- package/dist/testing/index.cjs +1 -1
- package/dist/testing/index.d.cts +2 -1
- package/dist/testing/index.d.cts.map +1 -1
- package/dist/testing/index.d.mts +7 -4
- package/dist/testing/index.d.mts.map +1 -1
- package/dist/testing/index.mjs +1 -1
- package/dist/{throttle-Da0OA8JT.d.cts → throttle-3FRcr7MU.d.mts} +4 -34
- package/dist/throttle-3FRcr7MU.d.mts.map +1 -0
- package/dist/{throttle-ibiT6E4U.d.mts → throttle-BlH27EGu.d.cts} +4 -34
- package/dist/throttle-BlH27EGu.d.cts.map +1 -0
- package/dist/{throttle-KnkRgZPi.cjs → throttle-Dp59f37i.cjs} +1 -1
- package/dist/{throttle-CnDa3v1k.mjs → throttle-TFY-V41R.mjs} +2 -2
- package/dist/{throttle-CnDa3v1k.mjs.map → throttle-TFY-V41R.mjs.map} +1 -1
- package/dist/types/index.cjs +1 -1
- package/dist/types/index.d.cts +2 -2
- package/dist/types/index.d.mts +3 -5
- package/dist/types/index.mjs +1 -1
- package/dist/upload/index.cjs +1 -1
- package/dist/upload/index.d.cts +1 -1
- package/dist/upload/index.d.mts +4 -4
- package/dist/upload/index.mjs +1 -1
- package/dist/upload-strategy-negotiator-0-dpNIce.d.cts +455 -0
- package/dist/upload-strategy-negotiator-0-dpNIce.d.cts.map +1 -0
- package/dist/upload-strategy-negotiator-BR_o1Ez8.cjs +1 -0
- package/dist/upload-strategy-negotiator-C9MeoOnW.mjs +2 -0
- package/dist/upload-strategy-negotiator-C9MeoOnW.mjs.map +1 -0
- package/dist/upload-strategy-negotiator-CEnlfVgJ.d.mts +455 -0
- package/dist/upload-strategy-negotiator-CEnlfVgJ.d.mts.map +1 -0
- package/dist/{uploadista-error-B-geDgi8.cjs → uploadista-error-CZx1JU_L.cjs} +3 -1
- package/dist/{uploadista-error-Fsfvr2Bb.mjs → uploadista-error-DQ7V1FlX.mjs} +3 -1
- package/dist/uploadista-error-DQ7V1FlX.mjs.map +1 -0
- package/dist/{uploadista-error-BragVhIs.d.mts → uploadista-error-LtiZn-R_.d.mts} +2 -2
- package/dist/{uploadista-error-BragVhIs.d.mts.map → uploadista-error-LtiZn-R_.d.mts.map} +1 -1
- package/dist/{uploadista-error-Cj_pAFck.d.cts → uploadista-error-eZtG4iyf.d.cts} +2 -2
- package/dist/{uploadista-error-Cj_pAFck.d.cts.map → uploadista-error-eZtG4iyf.d.cts.map} +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +2 -1
- package/dist/utils/index.d.mts +3 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/websocket-Br0ijEZA.cjs +1 -0
- package/dist/websocket-DftnHFfN.mjs +2 -0
- package/dist/websocket-DftnHFfN.mjs.map +1 -0
- package/package.json +3 -3
- package/src/errors/uploadista-error.ts +11 -1
- package/src/flow/README.md +115 -0
- package/src/flow/flow-engine.ts +36 -2
- package/src/flow/flow-queue-store.ts +155 -0
- package/src/flow/flow-queue.ts +640 -0
- package/src/flow/index.ts +4 -0
- package/src/flow/types/flow-queue-item.ts +154 -0
- package/src/types/data-store.ts +3 -3
- package/src/types/kv-store.ts +31 -1
- package/src/upload/write-to-store.ts +24 -29
- package/tests/flow-queue-store.test.ts +150 -0
- package/tests/flow-queue.test.ts +308 -0
- package/dist/resolve-upload-metadata-BUVl1LoS.d.cts +0 -8723
- package/dist/resolve-upload-metadata-BUVl1LoS.d.cts.map +0 -1
- package/dist/resolve-upload-metadata-MPDmDfOZ.d.mts +0 -8723
- package/dist/resolve-upload-metadata-MPDmDfOZ.d.mts.map +0 -1
- package/dist/run-args-WD1otVrz.mjs +0 -2
- package/dist/run-args-WD1otVrz.mjs.map +0 -1
- package/dist/run-args-g74p8pEZ.cjs +0 -1
- package/dist/throttle-Da0OA8JT.d.cts.map +0 -1
- package/dist/throttle-ibiT6E4U.d.mts.map +0 -1
- package/dist/upload-strategy-negotiator-BuxPf1sa.mjs +0 -2
- package/dist/upload-strategy-negotiator-BuxPf1sa.mjs.map +0 -1
- package/dist/upload-strategy-negotiator-DfiQ0Fy0.cjs +0 -1
- package/dist/uploadista-error-Fsfvr2Bb.mjs.map +0 -1
- package/dist/websocket-Avz4T8YB.cjs +0 -1
- package/dist/websocket-CdgVhVJs.mjs +0 -2
- 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
|
+
};
|
package/src/types/data-store.ts
CHANGED
|
@@ -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.
|
|
216
|
+
* yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () {
|
|
217
217
|
* writeChunk(file_id, chunk, bytesWritten);
|
|
218
218
|
* bytesWritten += chunk.byteLength;
|
|
219
|
-
* onProgress?.(
|
|
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?: (
|
|
289
|
+
onProgress?: (offset: number) => Effect.Effect<void>;
|
|
290
290
|
},
|
|
291
291
|
) => Effect.Effect<number, UploadistaError>;
|
|
292
292
|
/**
|
package/src/types/kv-store.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
2
|
import { UploadistaError } from "../errors";
|
|
3
|
-
import type { DeadLetterItem
|
|
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
|
|
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
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
});
|