@getstrata/core 2.2.0 → 2.2.2

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 (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/contracts/serviceTokens.d.ts +3 -2
  3. package/dist/core/database/baseRepository.d.ts +5 -0
  4. package/dist/core/database/model.d.ts +3 -1
  5. package/dist/core/database/query.d.ts +6 -1
  6. package/dist/core/database/repositoryQuery.d.ts +7 -1
  7. package/dist/core/database/schema/blueprint.d.ts +2 -0
  8. package/dist/core/database/schema/columnDefinition.d.ts +1 -1
  9. package/dist/core/database/types.d.ts +10 -1
  10. package/dist/core/http/requestMetaContext.d.ts +2 -0
  11. package/dist/core/queue/createAppQueue.d.ts +2 -1
  12. package/dist/core/queue/failedJobService.d.ts +3 -1
  13. package/dist/core/queue/index.d.ts +10 -2
  14. package/dist/core/queue/jobRunner.d.ts +7 -1
  15. package/dist/core/queue/publicQueue.d.ts +2 -1
  16. package/dist/core/queue/queueConfig.d.ts +3 -0
  17. package/dist/core/queue/redisQueue.d.ts +5 -7
  18. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  19. package/dist/core/queue/redisStreams.d.ts +73 -0
  20. package/dist/core/queue/types.d.ts +1 -0
  21. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  22. package/dist/core/tenant/tenantMiddleware.d.ts +4 -0
  23. package/dist/entries/auth/basicAuthGuard.js +1 -0
  24. package/dist/entries/auth/jwtGuard.js +1 -0
  25. package/dist/entries/auth/sessionGuard.js +1 -0
  26. package/dist/entries/contracts/serviceTokens.js +2 -0
  27. package/dist/entries/database/query.js +70 -1
  28. package/dist/entries/database/repositoryQuery.js +99 -1
  29. package/dist/entries/database/schema.js +9 -0
  30. package/dist/entries/database/sqliteConnection.js +1 -1
  31. package/dist/entries/queue/createAppQueue.js +681 -66
  32. package/dist/entries/queue/failedJobRepository.js +1 -1
  33. package/dist/entries/queue/failedJobService.js +5 -1
  34. package/dist/entries/queue/jobRunner.js +67 -2
  35. package/dist/entries/queue/publicQueue.js +679 -65
  36. package/dist/entries/queue/queueMetrics.js +694 -72
  37. package/dist/entries/queue/redisQueue.js +765 -148
  38. package/dist/framework/public-api.d.ts +2 -2
  39. package/dist/index.js +855 -69
  40. package/package.json +1 -1
@@ -1,92 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/queue/redisQueue.ts
3
- var {RedisClient } = globalThis.Bun;
4
-
5
- // ../../src/core/runtime/appEnv.ts
6
- var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
7
- function normalizeEnvValue(value) {
8
- return (value ?? "").trim().toLowerCase();
9
- }
10
- function isProductionEnv(env = process.env) {
11
- const appEnv = normalizeEnvValue(env.APP_ENV);
12
- const nodeEnv = normalizeEnvValue(env.NODE_ENV);
13
- if (appEnv === "production" || nodeEnv === "production") {
14
- return true;
15
- }
16
- if (appEnv === "") {
17
- return false;
18
- }
19
- return !NON_PRODUCTION_APP_ENVS.has(appEnv);
20
- }
21
- function envFlagEnabled(value) {
22
- return value === "true";
23
- }
24
-
25
- // ../../src/core/runtime/appKeyPrefix.ts
26
- function appKeyPrefix() {
27
- return process.env.APP_KEY_PREFIX?.trim() || "strata";
28
- }
29
- function appCookieName(kind) {
30
- return `${appKeyPrefix()}_${kind}`;
31
- }
32
- function appDevSecret(kind) {
33
- return `${appKeyPrefix()}-dev-${kind}`;
34
- }
35
- function requireConfiguredSecret(names, devKind, env = process.env) {
36
- for (const name of names) {
37
- const value = env[name]?.trim();
38
- if (value) {
39
- return value;
40
- }
41
- }
42
- if (isProductionEnv(env)) {
43
- throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
44
- }
45
- return appDevSecret(devKind);
46
- }
47
- function namespacedRedisKey(kind) {
48
- return `${appKeyPrefix()}:${kind}`;
49
- }
50
- function smtpEhloHost() {
51
- const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
52
- const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
53
- return safe || "strata.local";
54
- }
55
- function siemEventType() {
56
- return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
57
- }
58
- function appUserAgent() {
59
- return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
60
- }
61
- function otelServiceName() {
62
- return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
63
- }
64
- function webhookSignatureHeader() {
65
- return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
66
- }
67
- function appDisplayName() {
68
- return process.env.APP_NAME?.trim() || "Strata";
69
- }
70
- function appEnv() {
71
- return process.env.APP_ENV?.trim() || "local";
72
- }
73
- function appUrl() {
74
- return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
75
- }
76
- function apiPrefix() {
77
- const raw = process.env.API_PREFIX?.trim() || "/api/v1";
78
- const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
79
- const trimmed = withSlash.replace(/\/+$/, "");
80
- return trimmed || "/api/v1";
81
- }
82
- function sdkClientClassName() {
83
- const override = process.env.APP_SDK_CLASS?.trim();
84
- if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
85
- return override;
86
- }
87
- const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
88
- return fromName ? `${fromName}Client` : "AppClient";
89
- }
3
+ var {RedisClient: RedisClient2 } = globalThis.Bun;
90
4
 
91
5
  // ../../src/core/queue/jobRegistry.ts
92
6
  class JobRegistry {
@@ -125,6 +39,9 @@ function readSharedJobRegistry() {
125
39
  }
126
40
  var jobRegistry = readSharedJobRegistry();
127
41
 
42
+ // ../../src/core/queue/jobRunner.ts
43
+ import { setTimeout as delay } from "timers/promises";
44
+
128
45
  // ../../src/core/queue/queueConfig.ts
129
46
  function resolveQueueConfig() {
130
47
  const driver = process.env.QUEUE_DRIVER;
@@ -147,22 +64,82 @@ var queueConfig = {
147
64
  return resolveQueueConfig().backoffMs;
148
65
  }
149
66
  };
67
+ function resolveRedisQueueTransport() {
68
+ const transport = process.env.QUEUE_REDIS_TRANSPORT ?? "lists";
69
+ if (transport !== "lists" && transport !== "streams")
70
+ throw new Error('QUEUE_REDIS_TRANSPORT must be "lists" or "streams".');
71
+ return transport;
72
+ }
73
+ function readQueueVisibilityMs() {
74
+ const value = Number(process.env.QUEUE_VISIBILITY_MS ?? "60000");
75
+ if (!Number.isSafeInteger(value) || value < 30)
76
+ throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
77
+ return value;
78
+ }
150
79
 
151
80
  // ../../src/core/queue/jobRunner.ts
81
+ function parseQueueJobEnvelope(rawPayload) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(rawPayload);
85
+ } catch {
86
+ console.error("[QueueWorker] Ignoring malformed queue payload");
87
+ return null;
88
+ }
89
+ if (!parsed || typeof parsed !== "object") {
90
+ console.error("[QueueWorker] Ignoring non-object queue payload");
91
+ return null;
92
+ }
93
+ const envelope = parsed;
94
+ if (typeof envelope.name !== "string" || envelope.name.length === 0) {
95
+ console.error("[QueueWorker] Ignoring queue payload without job name");
96
+ return null;
97
+ }
98
+ if (!jobRegistry.create(envelope.name)) {
99
+ console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
100
+ return null;
101
+ }
102
+ if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
103
+ console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
104
+ return null;
105
+ }
106
+ if (envelope.jobId !== undefined && (typeof envelope.jobId !== "string" || !envelope.jobId.length))
107
+ return null;
108
+ if (envelope.attempts !== undefined && (!Number.isSafeInteger(envelope.attempts) || envelope.attempts < 0))
109
+ return null;
110
+ if (envelope.deadlineAtMs !== undefined && (!Number.isSafeInteger(envelope.deadlineAtMs) || envelope.deadlineAtMs <= 0))
111
+ return null;
112
+ if (envelope.cancellable !== undefined && (typeof envelope.cancellable !== "boolean" || envelope.cancellable && !envelope.jobId))
113
+ return null;
114
+ return {
115
+ ...envelope.cancellable ? { cancellable: true } : {},
116
+ ...envelope.deadlineAtMs === undefined ? {} : { deadlineAtMs: envelope.deadlineAtMs },
117
+ jobId: envelope.jobId,
118
+ name: envelope.name,
119
+ payload: envelope.payload ?? {},
120
+ attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
121
+ };
122
+ }
152
123
  async function runQueueJob(envelope, failedJobs, options = {}) {
124
+ options.context?.signal.throwIfAborted();
153
125
  const job = jobRegistry.create(envelope.name);
154
126
  if (!job) {
155
127
  throw new Error(`Unknown job "${envelope.name}".`);
156
128
  }
157
129
  const attempts = envelope.attempts ?? 0;
158
130
  try {
159
- await job.handle(envelope.payload);
131
+ await job.handle(envelope.payload, options.context);
160
132
  } catch (error) {
133
+ options.context?.signal.throwIfAborted();
161
134
  const nextAttempt = attempts + 1;
162
135
  const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
163
136
  if (nextAttempt < maxAttempts) {
164
137
  const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
165
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
138
+ if (options.deferRetry) {
139
+ await options.deferRetry({ ...envelope, attempts: nextAttempt }, backoffMs * nextAttempt);
140
+ return;
141
+ }
142
+ await delay(backoffMs * nextAttempt, undefined, { signal: options.context?.signal });
166
143
  await runQueueJob({
167
144
  ...envelope,
168
145
  attempts: nextAttempt
@@ -171,6 +148,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
171
148
  }
172
149
  await failedJobs.recordFailure({
173
150
  jobName: envelope.name,
151
+ jobId: options.context?.jobId ?? envelope.jobId,
174
152
  payload: envelope.payload,
175
153
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
176
154
  });
@@ -179,7 +157,93 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
179
157
  }
180
158
  }
181
159
 
182
- // ../../src/core/queue/redisQueue.ts
160
+ // ../../src/core/runtime/appEnv.ts
161
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
162
+ function normalizeEnvValue(value) {
163
+ return (value ?? "").trim().toLowerCase();
164
+ }
165
+ function isProductionEnv(env = process.env) {
166
+ const appEnv = normalizeEnvValue(env.APP_ENV);
167
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
168
+ if (appEnv === "production" || nodeEnv === "production") {
169
+ return true;
170
+ }
171
+ if (appEnv === "") {
172
+ return false;
173
+ }
174
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
175
+ }
176
+ function envFlagEnabled(value) {
177
+ return value === "true";
178
+ }
179
+
180
+ // ../../src/core/runtime/appKeyPrefix.ts
181
+ function appKeyPrefix() {
182
+ return process.env.APP_KEY_PREFIX?.trim() || "strata";
183
+ }
184
+ function appCookieName(kind) {
185
+ return `${appKeyPrefix()}_${kind}`;
186
+ }
187
+ function appDevSecret(kind) {
188
+ return `${appKeyPrefix()}-dev-${kind}`;
189
+ }
190
+ function requireConfiguredSecret(names, devKind, env = process.env) {
191
+ for (const name of names) {
192
+ const value = env[name]?.trim();
193
+ if (value) {
194
+ return value;
195
+ }
196
+ }
197
+ if (isProductionEnv(env)) {
198
+ throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
199
+ }
200
+ return appDevSecret(devKind);
201
+ }
202
+ function namespacedRedisKey(kind) {
203
+ return `${appKeyPrefix()}:${kind}`;
204
+ }
205
+ function smtpEhloHost() {
206
+ const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
207
+ const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
208
+ return safe || "strata.local";
209
+ }
210
+ function siemEventType() {
211
+ return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
212
+ }
213
+ function appUserAgent() {
214
+ return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
215
+ }
216
+ function otelServiceName() {
217
+ return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
218
+ }
219
+ function webhookSignatureHeader() {
220
+ return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
221
+ }
222
+ function appDisplayName() {
223
+ return process.env.APP_NAME?.trim() || "Strata";
224
+ }
225
+ function appEnv() {
226
+ return process.env.APP_ENV?.trim() || "local";
227
+ }
228
+ function appUrl() {
229
+ return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
230
+ }
231
+ function apiPrefix() {
232
+ const raw = process.env.API_PREFIX?.trim() || "/api/v1";
233
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
234
+ const trimmed = withSlash.replace(/\/+$/, "");
235
+ return trimmed || "/api/v1";
236
+ }
237
+ function sdkClientClassName() {
238
+ const override = process.env.APP_SDK_CLASS?.trim();
239
+ if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
240
+ return override;
241
+ }
242
+ const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
243
+ return fromName ? `${fromName}Client` : "AppClient";
244
+ }
245
+
246
+ // ../../src/core/queue/redisQueueKeys.ts
183
247
  function queueListKey() {
184
248
  return namespacedRedisKey("queue:default");
185
249
  }
@@ -192,31 +256,10 @@ function queueLowKey() {
192
256
  var QUEUE_LIST_KEY = queueListKey();
193
257
  var QUEUE_HIGH_KEY = queueHighKey();
194
258
  var QUEUE_LOW_KEY = queueLowKey();
195
- var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
196
- var DEFAULT_VISIBILITY_MS = 60000;
197
- function queueProcessingKey(queueKey) {
198
- return `${queueKey}:processing`;
199
- }
200
- function queueProcessingLeaseKey(queueKey) {
201
- return `${queueKey}:processing:leases`;
202
- }
203
- function readVisibilityMs() {
204
- const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
205
- if (!Number.isSafeInteger(parsed) || parsed < 30) {
206
- throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
207
- }
208
- return parsed;
209
- }
210
- function reservationExpired(lease, now, visibilityMs) {
211
- if (lease === null || lease.length === 0) {
212
- return true;
213
- }
214
- const reservedAt = Number(lease);
215
- if (!Number.isFinite(reservedAt)) {
216
- return true;
217
- }
218
- return now - reservedAt >= visibilityMs;
259
+ function defaultQueueKeys() {
260
+ return [queueHighKey(), queueListKey(), queueLowKey()];
219
261
  }
262
+ var QUEUE_KEYS = defaultQueueKeys();
220
263
  function queueKeyForPriority(priority = "default") {
221
264
  switch (priority) {
222
265
  case "high":
@@ -227,36 +270,603 @@ function queueKeyForPriority(priority = "default") {
227
270
  return queueListKey();
228
271
  }
229
272
  }
230
- function parseQueueJobEnvelope(rawPayload) {
231
- let parsed;
273
+
274
+ // ../../src/core/queue/redisStreams.ts
275
+ var {RedisClient } = globalThis.Bun;
276
+ var STREAM_GROUP = "strata-workers-v1";
277
+ function streamQueueKey(queueKey) {
278
+ return `${queueKey}:stream`;
279
+ }
280
+ function streamDeadLetterKey(queueKey) {
281
+ return `${streamQueueKey(queueKey)}:invalid`;
282
+ }
283
+ function streamRetryKey(queueKey) {
284
+ return `${streamQueueKey(queueKey)}:retries`;
285
+ }
286
+ function streamControlKey(key, jobId) {
287
+ const prefix = `${streamQueueKey(key)}/`;
288
+ if (!jobId.startsWith(prefix) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(jobId.slice(prefix.length)))
289
+ throw new Error("Invalid cancellable Streams job ID");
290
+ return `${streamQueueKey(key)}:control:${jobId.slice(prefix.length)}`;
291
+ }
292
+ var ENQUEUE_CONTROLLED = `
293
+ local kind = redis.call('TYPE', KEYS[1]).ok
294
+ if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream admission key type') end
295
+ if redis.call('EXISTS', KEYS[2]) ~= 0 then return redis.error_reply('Streams control identity already exists') end
296
+ redis.call('XADD', KEYS[1], '*', 'payload', ARGV[1])
297
+ redis.call('HSET', KEYS[2], 'state', 'active')
298
+ return 1
299
+ `;
300
+ var CANCEL_STREAM = `
301
+ local state = redis.call('HGET', KEYS[1], 'state')
302
+ if not state then return 0 end
303
+ local kind = redis.call('TYPE', KEYS[2]).ok
304
+ if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
305
+ local payload = redis.call('HGET', KEYS[1], 'retryPayload')
306
+ if payload and redis.call('ZSCORE', KEYS[2], payload) then redis.call('ZADD', KEYS[2], 0, payload) end
307
+ redis.call('HSET', KEYS[1], 'state', 'cancelled')
308
+ return 1
309
+ `;
310
+ async function cancelStreamJob(client, key, jobId) {
311
+ return Number(await client.send("EVAL", [
312
+ CANCEL_STREAM,
313
+ "2",
314
+ streamControlKey(key, jobId),
315
+ streamRetryKey(key)
316
+ ])) === 1;
317
+ }
318
+ async function readStreamCancellation(client, controlKey) {
319
+ const state = await client.send("HGET", [controlKey, "state"]);
320
+ if (state !== "active" && state !== "cancelled")
321
+ throw new Error("Missing or invalid Streams control state");
322
+ return state === "cancelled";
323
+ }
324
+ var MAX_TIMER_MS = 2147483647;
325
+ async function readStreamTime(client) {
326
+ const value = await client.send("TIME", []);
327
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1]))
328
+ throw new Error("Invalid Redis TIME response");
329
+ const seconds = Number(value[0]);
330
+ const micros = Number(value[1]);
331
+ const now = seconds * 1000 + Math.floor(micros / 1000);
332
+ if (!Number.isSafeInteger(seconds) || seconds < 0 || !Number.isSafeInteger(micros) || micros < 0 || micros >= 1e6 || !Number.isSafeInteger(now))
333
+ throw new Error("Invalid Redis TIME response");
334
+ return now;
335
+ }
336
+ function watchStreamDeadline(client, deadlineAtMs, remainingMs, expire, fail) {
337
+ let stopped = false;
338
+ let timer;
339
+ const schedule = (remaining) => {
340
+ timer = setTimeout(() => {
341
+ (async () => {
342
+ try {
343
+ const remaining = deadlineAtMs - await readStreamTime(client);
344
+ if (stopped)
345
+ return;
346
+ if (remaining <= 0)
347
+ expire();
348
+ else
349
+ schedule(remaining);
350
+ } catch (error) {
351
+ if (!stopped)
352
+ fail(error);
353
+ }
354
+ })();
355
+ }, Math.max(1, Math.min(MAX_TIMER_MS, remaining)));
356
+ };
357
+ schedule(remainingMs);
358
+ return () => {
359
+ stopped = true;
360
+ clearTimeout(timer);
361
+ };
362
+ }
363
+ var SCHEDULE_RETRY = `
364
+ local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
365
+ if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
366
+ local kind = redis.call('TYPE', KEYS[2]).ok
367
+ if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
368
+ if ARGV[8] == '1' then
369
+ local state = redis.call('HGET', KEYS[3], 'state')
370
+ if state == 'cancelled' then return redis.error_reply('Streams job cancelled') end
371
+ if state ~= 'active' then return redis.error_reply('Missing or invalid Streams control state') end
372
+ end
373
+ local now = redis.call('TIME')
374
+ local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000) + tonumber(ARGV[5])
375
+ if ARGV[7] ~= '' then due = math.min(due, tonumber(ARGV[7])) end
376
+ redis.call('ZADD', KEYS[2], due, ARGV[6])
377
+ if ARGV[8] == '1' then redis.call('HSET', KEYS[3], 'retryPayload', ARGV[6]) end
378
+ redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
379
+ redis.call('XDEL', KEYS[1], ARGV[2])
380
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
381
+ return 1
382
+ `;
383
+ var PROMOTE_RETRIES = `
384
+ local kinds = {'stream','zset'}
385
+ for i=1,2 do
386
+ local kind = redis.call('TYPE', KEYS[i]).ok
387
+ if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
388
+ end
389
+ local now = redis.call('TIME')
390
+ local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
391
+ local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
392
+ for _, payload in ipairs(entries) do
393
+ redis.call('XADD', KEYS[1], '*', 'payload', payload)
394
+ redis.call('ZREM', KEYS[2], payload)
395
+ end
396
+ return #entries
397
+ `;
398
+ async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
399
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
400
+ throw new Error("Invalid Streams retry delay");
401
+ const scheduled = await client.send("EVAL", [
402
+ SCHEDULE_RETRY,
403
+ "3",
404
+ streamQueueKey(key),
405
+ streamRetryKey(key),
406
+ envelope.cancellable ? streamControlKey(key, envelope.jobId ?? "") : streamQueueKey(key),
407
+ STREAM_GROUP,
408
+ reservation.id,
409
+ reservation.owner,
410
+ String(readQueueVisibilityMs()),
411
+ String(delayMs),
412
+ JSON.stringify(envelope),
413
+ envelope.deadlineAtMs === undefined ? "" : String(envelope.deadlineAtMs),
414
+ envelope.cancellable ? "1" : "0"
415
+ ]);
416
+ if (Number(scheduled) !== 1)
417
+ throw new Error("Queue lease lost while scheduling retry");
418
+ }
419
+ async function promoteStreamRetries(client, key) {
420
+ return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
421
+ }
422
+ async function readStreamQueueDepth(client, key) {
423
+ return Number(await client.send("EVAL", [
424
+ "return redis.call('XLEN', KEYS[1]) + redis.call('ZCARD', KEYS[2])",
425
+ "2",
426
+ streamQueueKey(key),
427
+ streamRetryKey(key)
428
+ ]));
429
+ }
430
+ var ASSERT_DRAINED = `
431
+ if redis.call('LLEN', KEYS[1]) > 0 or redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then
432
+ return redis.error_reply('Legacy queue state requires maintenance conversion before Streams activation')
433
+ end
434
+ return 1
435
+ `;
436
+ async function assertLegacyDrained(client, key) {
437
+ await client.send("EVAL", [
438
+ ASSERT_DRAINED,
439
+ "3",
440
+ key,
441
+ `${key}:processing`,
442
+ `${key}:processing:leases`
443
+ ]);
444
+ }
445
+ var UPDATE_STREAM = `
446
+ local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
447
+ if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
448
+ if ARGV[7] == '1' then
449
+ local kind = redis.call('TYPE', KEYS[3]).ok
450
+ if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('Invalid stream control key type') end
451
+ end
452
+ if ARGV[5] == 'renew' then
453
+ local claimed = redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[3], 0, ARGV[2], 'JUSTID')
454
+ return #claimed
455
+ end
456
+ if ARGV[5] == 'quarantine' then
457
+ local kind = redis.call('TYPE', KEYS[2]).ok
458
+ if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream quarantine key type') end
459
+ redis.call('XADD', KEYS[2], '*', 'sourceId', ARGV[2], 'payload', ARGV[6])
460
+ end
461
+ local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
462
+ if acknowledged == 1 then
463
+ redis.call('XDEL', KEYS[1], ARGV[2])
464
+ if ARGV[7] == '1' then redis.call('DEL', KEYS[3]) end
465
+ end
466
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
467
+ return acknowledged
468
+ `;
469
+ var CLAIM_STREAM = `
470
+ local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
471
+ local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
472
+ for _, entry in ipairs(window) do
473
+ if entry[2] ~= ARGV[2] and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, entry[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], entry[2]) end
474
+ end
475
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
476
+ return claimed
477
+ `;
478
+ var READ_STREAM = `
479
+ local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
480
+ if not entries and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
481
+ return entries
482
+ `;
483
+ async function ensureStreamGroup(client, key) {
484
+ await assertLegacyDrained(client, key);
232
485
  try {
233
- parsed = JSON.parse(rawPayload);
234
- } catch {
235
- console.error("[QueueWorker] Ignoring malformed queue payload");
236
- return null;
486
+ await client.send("XGROUP", ["CREATE", streamQueueKey(key), STREAM_GROUP, "0", "MKSTREAM"]);
487
+ } catch (error) {
488
+ if (!(error instanceof Error) || !error.message.includes("BUSYGROUP"))
489
+ throw error;
237
490
  }
238
- if (!parsed || typeof parsed !== "object") {
239
- console.error("[QueueWorker] Ignoring non-object queue payload");
240
- return null;
491
+ }
492
+ function readStreamEntry(value, owner) {
493
+ if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
494
+ throw new Error("Invalid Redis stream entry response");
495
+ const fields = value[1];
496
+ let payload;
497
+ for (let index = 0;index < fields.length; index += 2) {
498
+ if (fields[index] === "payload" && typeof fields[index + 1] === "string")
499
+ payload = fields[index + 1];
241
500
  }
242
- const envelope = parsed;
243
- if (typeof envelope.name !== "string" || envelope.name.length === 0) {
244
- console.error("[QueueWorker] Ignoring queue payload without job name");
245
- return null;
501
+ return { id: value[0], owner, payload: payload ?? "" };
502
+ }
503
+ async function updateStreamReservation(client, key, reservation, action, controlKey) {
504
+ return Number(await client.send("EVAL", [
505
+ UPDATE_STREAM,
506
+ "3",
507
+ streamQueueKey(key),
508
+ streamDeadLetterKey(key),
509
+ controlKey ?? streamQueueKey(key),
510
+ STREAM_GROUP,
511
+ reservation.id,
512
+ reservation.owner,
513
+ String(readQueueVisibilityMs()),
514
+ action,
515
+ reservation.payload,
516
+ controlKey ? "1" : "0"
517
+ ])) === 1;
518
+ }
519
+ async function reserveStreamJob(client, key, owner, cursor = "0-0") {
520
+ const claimed = await client.send("EVAL", [
521
+ CLAIM_STREAM,
522
+ "1",
523
+ streamQueueKey(key),
524
+ STREAM_GROUP,
525
+ owner,
526
+ String(readQueueVisibilityMs()),
527
+ cursor
528
+ ]);
529
+ if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
530
+ throw new Error("Invalid Redis XAUTOCLAIM response");
531
+ const nextCursor = claimed[0];
532
+ if (claimed[1].length > 0)
533
+ return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
534
+ const fresh = await client.send("EVAL", [
535
+ READ_STREAM,
536
+ "1",
537
+ streamQueueKey(key),
538
+ STREAM_GROUP,
539
+ owner
540
+ ]);
541
+ if (fresh === null)
542
+ return { reservation: null, cursor: nextCursor };
543
+ if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
544
+ throw new Error("Invalid Redis XREADGROUP response");
545
+ return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
546
+ }
547
+
548
+ class RedisStreamsQueue {
549
+ client;
550
+ constructor(redisUrl) {
551
+ this.client = new RedisClient(redisUrl);
246
552
  }
247
- if (!jobRegistry.create(envelope.name)) {
248
- console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
249
- return null;
553
+ close() {
554
+ this.client.close();
250
555
  }
251
- if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
252
- console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
253
- return null;
556
+ async dispatch(job, payload) {
557
+ await this.enqueue(job, payload);
558
+ }
559
+ async enqueue(job, payload, options = {}) {
560
+ return this.admit(job, payload, options);
561
+ }
562
+ async replay(job, payload, jobId) {
563
+ const prefix = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
564
+ if (!prefix || !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9]+-[0-9]+)$/.test(jobId.slice(streamQueueKey(prefix).length + 1)))
565
+ throw new Error("Replay identity does not belong to this Streams namespace");
566
+ await this.admit(job, payload, {}, jobId);
567
+ }
568
+ async admit(job, payload, options, replayId) {
569
+ if (options.cancellable !== undefined && typeof options.cancellable !== "boolean")
570
+ throw new Error("cancellable must be a boolean");
571
+ if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > MAX_TIMER_MS))
572
+ throw new Error("Streams timeoutMs must be an integer between 1 and 2147483647");
573
+ const name = jobRegistry.resolveName(job);
574
+ if (!name)
575
+ throw new Error("Job is not registered with the queue worker registry.");
576
+ const key = queueKeyForPriority(job.priority);
577
+ await assertLegacyDrained(this.client, key);
578
+ const jobId = replayId ?? `${streamQueueKey(key)}/${crypto.randomUUID()}`;
579
+ const deadlineAtMs = options.timeoutMs === undefined ? undefined : await readStreamTime(this.client) + options.timeoutMs;
580
+ const envelope = JSON.stringify({
581
+ name,
582
+ payload,
583
+ attempts: 0,
584
+ jobId,
585
+ deadlineAtMs,
586
+ ...options.cancellable ? { cancellable: true } : {}
587
+ });
588
+ if (options.cancellable)
589
+ await this.client.send("EVAL", [
590
+ ENQUEUE_CONTROLLED,
591
+ "2",
592
+ streamQueueKey(key),
593
+ streamControlKey(key, jobId),
594
+ envelope
595
+ ]);
596
+ else
597
+ await this.client.send("XADD", [streamQueueKey(key), "*", "payload", envelope]);
598
+ return jobId;
599
+ }
600
+ async cancel(jobId) {
601
+ const key = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
602
+ if (!key)
603
+ throw new Error("Invalid cancellable Streams job ID");
604
+ return cancelStreamJob(this.client, key, jobId);
254
605
  }
255
- return {
256
- name: envelope.name,
257
- payload: envelope.payload ?? {},
258
- attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
259
- };
606
+ }
607
+
608
+ class RedisStreamsWorker {
609
+ failedJobs;
610
+ timeoutSeconds;
611
+ queueKeys;
612
+ client;
613
+ owner = crypto.randomUUID();
614
+ cursors = new Map;
615
+ processing = false;
616
+ initialized = false;
617
+ running = false;
618
+ stopping = false;
619
+ active;
620
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = defaultQueueKeys()) {
621
+ this.failedJobs = failedJobs;
622
+ this.timeoutSeconds = timeoutSeconds;
623
+ this.queueKeys = queueKeys;
624
+ readQueueVisibilityMs();
625
+ this.client = new RedisClient(redisUrl);
626
+ }
627
+ requestStop() {
628
+ this.stopping = true;
629
+ }
630
+ isRunning() {
631
+ return this.running;
632
+ }
633
+ close() {
634
+ this.stopping = true;
635
+ this.active?.abort(new Error("Queue worker closed"));
636
+ this.client.close();
637
+ }
638
+ async initialize() {
639
+ if (this.initialized)
640
+ return;
641
+ for (const key of this.queueKeys)
642
+ await ensureStreamGroup(this.client, key);
643
+ this.initialized = true;
644
+ }
645
+ async reserve(key) {
646
+ await promoteStreamRetries(this.client, key);
647
+ const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
648
+ this.cursors.set(key, result.cursor);
649
+ return result.reservation;
650
+ }
651
+ async processNext() {
652
+ if (this.processing)
653
+ throw new Error("Queue worker already processing a job.");
654
+ this.processing = true;
655
+ try {
656
+ return await this.processOne();
657
+ } finally {
658
+ this.processing = false;
659
+ }
660
+ }
661
+ async processOne() {
662
+ if (this.stopping)
663
+ return false;
664
+ await this.initialize();
665
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
666
+ do {
667
+ for (const key of this.queueKeys) {
668
+ if (this.stopping)
669
+ return false;
670
+ const reservation = await this.reserve(key);
671
+ if (!reservation)
672
+ continue;
673
+ await this.handle(key, reservation);
674
+ return true;
675
+ }
676
+ if (performance.now() >= deadline || this.stopping)
677
+ return false;
678
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
679
+ } while (!this.stopping);
680
+ return false;
681
+ }
682
+ async handle(key, reservation) {
683
+ const envelope = parseQueueJobEnvelope(reservation.payload);
684
+ if (!envelope) {
685
+ await updateStreamReservation(this.client, key, reservation, "quarantine");
686
+ return;
687
+ }
688
+ envelope.jobId ??= `${streamQueueKey(key)}/${reservation.id}`;
689
+ let controlKey;
690
+ if (envelope.cancellable) {
691
+ try {
692
+ controlKey = streamControlKey(key, envelope.jobId);
693
+ } catch {
694
+ await updateStreamReservation(this.client, key, reservation, "quarantine");
695
+ return;
696
+ }
697
+ }
698
+ const cancellationError = new Error("Streams job cancelled");
699
+ if (controlKey && await readStreamCancellation(this.client, controlKey)) {
700
+ await this.failedJobs.recordFailure({
701
+ jobName: envelope.name,
702
+ jobId: envelope.jobId,
703
+ payload: envelope.payload,
704
+ exception: cancellationError.message
705
+ });
706
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
707
+ return;
708
+ }
709
+ const remainingMs = envelope.deadlineAtMs === undefined ? undefined : envelope.deadlineAtMs - await readStreamTime(this.client);
710
+ const deadlineError = new Error("Streams job deadline exceeded");
711
+ if (remainingMs !== undefined && remainingMs <= 0) {
712
+ await this.failedJobs.recordFailure({
713
+ jobName: envelope.name,
714
+ jobId: envelope.jobId,
715
+ payload: envelope.payload,
716
+ exception: deadlineError.message
717
+ });
718
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
719
+ return;
720
+ }
721
+ const controller = new AbortController;
722
+ this.active = controller;
723
+ let acknowledge = false;
724
+ let released = false;
725
+ let ownershipLost = false;
726
+ let renewal;
727
+ let deadlineExpired = false;
728
+ let cancellationRequested = false;
729
+ let failureRecorded = false;
730
+ const loseOwnership = (error) => {
731
+ ownershipLost = true;
732
+ controller.abort(error);
733
+ };
734
+ const stopDeadlineWatch = remainingMs === undefined || envelope.deadlineAtMs === undefined ? undefined : watchStreamDeadline(this.client, envelope.deadlineAtMs, remainingMs, () => {
735
+ deadlineExpired = true;
736
+ controller.abort(deadlineError);
737
+ }, loseOwnership);
738
+ const heartbeat = setInterval(() => {
739
+ if (renewal || ownershipLost || released)
740
+ return;
741
+ renewal = (async () => {
742
+ try {
743
+ if (!await updateStreamReservation(this.client, key, reservation, "renew", controlKey))
744
+ loseOwnership(new Error("Queue lease lost"));
745
+ else if (controlKey && await readStreamCancellation(this.client, controlKey)) {
746
+ cancellationRequested = true;
747
+ controller.abort(cancellationError);
748
+ }
749
+ } catch (error) {
750
+ loseOwnership(error);
751
+ console.error("[StreamsWorker] Lease renewal failed:", error);
752
+ }
753
+ })().finally(() => {
754
+ renewal = undefined;
755
+ });
756
+ }, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
757
+ try {
758
+ await runQueueJob(envelope, this.failedJobs, {
759
+ context: {
760
+ jobId: envelope.jobId,
761
+ signal: controller.signal,
762
+ deadlineAtMs: envelope.deadlineAtMs
763
+ },
764
+ deferRetry: async (next, delayMs) => {
765
+ controller.signal.throwIfAborted();
766
+ await scheduleStreamRetry(this.client, key, reservation, next, delayMs);
767
+ released = true;
768
+ },
769
+ onFailureRecorded: () => {
770
+ acknowledge = true;
771
+ failureRecorded = true;
772
+ }
773
+ });
774
+ acknowledge = true;
775
+ } catch (error) {
776
+ console.error("[StreamsWorker] Job failed:", error);
777
+ } finally {
778
+ stopDeadlineWatch?.();
779
+ clearInterval(heartbeat);
780
+ await renewal;
781
+ this.active = undefined;
782
+ }
783
+ if (!ownershipLost && !released && controlKey && (cancellationRequested || await readStreamCancellation(this.client, controlKey))) {
784
+ if (!failureRecorded)
785
+ await this.failedJobs.recordFailure({
786
+ jobName: envelope.name,
787
+ jobId: envelope.jobId,
788
+ payload: envelope.payload,
789
+ exception: cancellationError.message
790
+ });
791
+ failureRecorded = true;
792
+ acknowledge = true;
793
+ }
794
+ if (!ownershipLost && !released && envelope.deadlineAtMs !== undefined && (deadlineExpired || envelope.deadlineAtMs <= await readStreamTime(this.client))) {
795
+ if (!failureRecorded)
796
+ await this.failedJobs.recordFailure({
797
+ jobName: envelope.name,
798
+ jobId: envelope.jobId,
799
+ payload: envelope.payload,
800
+ exception: deadlineError.message
801
+ });
802
+ acknowledge = true;
803
+ }
804
+ if (acknowledge && !ownershipLost && !released)
805
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
806
+ }
807
+ async run() {
808
+ this.running = true;
809
+ try {
810
+ while (!this.stopping)
811
+ await this.processNext();
812
+ } finally {
813
+ this.running = false;
814
+ }
815
+ }
816
+ }
817
+ var CONVERT_LIST = `
818
+ local kinds = {'list','list','hash','stream'}
819
+ for i=1,4 do
820
+ local kind = redis.call('TYPE', KEYS[i]).ok
821
+ if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid queue conversion key type') end
822
+ end
823
+ if redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then return redis.error_reply('Drain or recover processing reservations before queue conversion') end
824
+ local converted = 0
825
+ for i=1,tonumber(ARGV[1]) do
826
+ local payload = redis.call('LINDEX', KEYS[1], -1)
827
+ if not payload then break end
828
+ redis.call('XADD', KEYS[4], '*', 'payload', payload)
829
+ redis.call('RPOP', KEYS[1])
830
+ converted = converted + 1
831
+ end
832
+ return converted
833
+ `;
834
+ async function migrateLegacyQueueToStreams(client, options) {
835
+ if (options.maintenance !== true)
836
+ throw new Error("Queue conversion requires maintenance mode: stop all producers and workers first.");
837
+ const batchSize = options.batchSize ?? 100;
838
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 1000)
839
+ throw new Error("Queue conversion batchSize must be between 1 and 1000.");
840
+ let converted = 0;
841
+ for (const key of options.queueKeys ?? defaultQueueKeys())
842
+ converted += Number(await client.send("EVAL", [
843
+ CONVERT_LIST,
844
+ "4",
845
+ key,
846
+ `${key}:processing`,
847
+ `${key}:processing:leases`,
848
+ streamQueueKey(key),
849
+ String(batchSize)
850
+ ]));
851
+ return converted;
852
+ }
853
+
854
+ // ../../src/core/queue/redisQueue.ts
855
+ function queueProcessingKey(queueKey) {
856
+ return `${queueKey}:processing`;
857
+ }
858
+ function queueProcessingLeaseKey(queueKey) {
859
+ return `${queueKey}:processing:leases`;
860
+ }
861
+ function reservationExpired(lease, now, visibilityMs) {
862
+ if (lease === null || lease.length === 0) {
863
+ return true;
864
+ }
865
+ const reservedAt = Number(lease);
866
+ if (!Number.isFinite(reservedAt)) {
867
+ return true;
868
+ }
869
+ return now - reservedAt >= visibilityMs;
260
870
  }
261
871
  var SCRIPT_HEADER = `
262
872
  local function checkType(key, expected)
@@ -345,7 +955,7 @@ async function reserveQueueJob(client, queueKey, owner) {
345
955
  queueProcessingLeaseKey(queueKey),
346
956
  crypto.randomUUID(),
347
957
  owner,
348
- String(readVisibilityMs())
958
+ String(readQueueVisibilityMs())
349
959
  ]);
350
960
  if (typeof raw !== "string")
351
961
  return null;
@@ -363,7 +973,7 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
363
973
  reservation.owner,
364
974
  reservation.record,
365
975
  action,
366
- String(readVisibilityMs()),
976
+ String(readQueueVisibilityMs()),
367
977
  reservation.payload
368
978
  ])) === 1;
369
979
  }
@@ -376,7 +986,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
376
986
  key,
377
987
  queueProcessingKey(key),
378
988
  queueProcessingLeaseKey(key),
379
- String(readVisibilityMs())
989
+ String(readQueueVisibilityMs())
380
990
  ]));
381
991
  }
382
992
  return reclaimed;
@@ -385,7 +995,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
385
995
  class RedisQueue {
386
996
  client;
387
997
  constructor(redisUrl) {
388
- this.client = new RedisClient(redisUrl);
998
+ this.client = new RedisClient2(redisUrl);
389
999
  }
390
1000
  close() {
391
1001
  this.client.close();
@@ -417,7 +1027,7 @@ class QueueWorker {
417
1027
  this.failedJobs = failedJobs;
418
1028
  this.timeoutSeconds = timeoutSeconds;
419
1029
  this.queueKeys = queueKeys;
420
- this.client = new RedisClient(redisUrl);
1030
+ this.client = new RedisClient2(redisUrl);
421
1031
  }
422
1032
  requestStop() {
423
1033
  this.stopping = true;
@@ -472,7 +1082,7 @@ class QueueWorker {
472
1082
  })().finally(() => {
473
1083
  renewal = undefined;
474
1084
  });
475
- }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
1085
+ }, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
476
1086
  try {
477
1087
  await runQueueJob(envelope, this.failedJobs, {
478
1088
  onFailureRecorded: () => {
@@ -506,11 +1116,11 @@ class QueueWorker {
506
1116
  }
507
1117
  }
508
1118
  async function countPendingQueueJobs(redisUrl) {
509
- const client = new RedisClient(redisUrl);
1119
+ const client = new RedisClient2(redisUrl);
510
1120
  try {
511
1121
  let total = 0;
512
- for (const queueKey of QUEUE_KEYS) {
513
- total += await client.llen(queueKey);
1122
+ for (const queueKey of resolveRedisQueueTransport() === "streams" ? defaultQueueKeys() : QUEUE_KEYS) {
1123
+ total += resolveRedisQueueTransport() === "streams" ? await readStreamQueueDepth(client, queueKey) : await client.llen(queueKey);
514
1124
  }
515
1125
  return total;
516
1126
  } finally {
@@ -523,7 +1133,11 @@ export {
523
1133
  QUEUE_LOW_KEY,
524
1134
  QueueWorker,
525
1135
  RedisQueue,
1136
+ RedisStreamsQueue,
1137
+ RedisStreamsWorker,
1138
+ STREAM_GROUP,
526
1139
  countPendingQueueJobs,
1140
+ migrateLegacyQueueToStreams,
527
1141
  parseQueueJobEnvelope,
528
1142
  queueInvalidKey,
529
1143
  queueKeyForPriority,
@@ -532,5 +1146,8 @@ export {
532
1146
  reclaimExpiredQueueReservations,
533
1147
  reservationExpired,
534
1148
  reserveQueueJob,
535
- updateQueueReservation
1149
+ streamDeadLetterKey,
1150
+ streamQueueKey,
1151
+ updateQueueReservation,
1152
+ updateStreamReservation
536
1153
  };