@blamejs/core 0.7.18 → 0.7.19

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 (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
@@ -1,745 +1,745 @@
1
- "use strict";
2
- /**
3
- * Redis-protocol queue adapter — backs b.queue with Redis instead of
4
- * the framework's main DB. Lets operators run multiple app nodes that
5
- * share a single queue without each needing to be cluster leader,
6
- * since Redis itself is the coordination point.
7
- *
8
- * Storage layout (operator-overridable prefix, default "blamejs:queue"):
9
- * <prefix>:job:<jobId> HASH — full job record (sealed payload + lastError)
10
- * <prefix>:q:<queue>:ready ZSET — member=jobId, score=availableAtMs (lease index)
11
- * <prefix>:q:<queue>:inflight ZSET — member=jobId, score=leaseExpiresAtMs (sweep index)
12
- * <prefix>:q:<queue>:dlq ZSET — member=jobId, score=finishedAtMs (failed jobs)
13
- * <prefix>:q:<queue>:queues SET — registry of known queue names (for purge/size scans)
14
- *
15
- * Atomicity: lease / sweep / fail / complete all run as Lua scripts so
16
- * the inflight-zset / ready-zset / job-hash mutations land in a single
17
- * Redis op without a window for concurrent consumers to double-lease
18
- * or for sweep to race a complete.
19
- *
20
- * Field-crypto integration: payload + lastError seal/unseal go through
21
- * cryptoField.sealRow("_blamejs_jobs", row) and unsealRow(...) — the
22
- * SAME crypto-field config the local backend uses, keyed by the
23
- * "_blamejs_jobs" table name. Operators configuring sealedFields on the
24
- * jobs table get the same protection on Redis as on SQLite.
25
- *
26
- * Cron-repeat: handled at complete()-time in JS (not Lua) — re-enqueues
27
- * the next firing as a fresh jobId with availableAt = next-cron-fire.
28
- *
29
- * Out of scope (defer to follow-up patches):
30
- * - Redis Cluster (slot-routing across multi-node Redis)
31
- * - Sentinel (managed primary failover)
32
- */
33
- var C = require("./constants");
34
- var cryptoField = require("./crypto-field");
35
- var { generateToken } = require("./crypto");
36
- var lazyRequire = require("./lazy-require");
37
- var redisClient = require("./redis-client");
38
- var safeJson = require("./safe-json");
39
- var scheduler = require("./scheduler");
40
- var { QueueError } = require("./framework-error");
41
-
42
- var _err = QueueError.factory;
43
-
44
- // vault is lazy-required because some flows (sealed lastError) only
45
- // touch it on retry-with-error paths, and the import order
46
- // (queue-redis → vault → db → audit) tolerates the late bind.
47
- var vault = lazyRequire(function () { return require("./vault"); });
48
-
49
- var DEFAULT_PREFIX = "blamejs:queue";
50
-
51
- // ---- Lua scripts ----
52
- //
53
- // LEASE_LUA — atomically pull up to maxRows jobs from the ready zset
54
- // whose availableAt is <= nowMs, move them to the inflight zset with
55
- // score = leaseExpiresAt, increment attempts, flip status, and return
56
- // the jobIds in priority order. The JS side then HGETALLs each id.
57
- //
58
- // Priority semantics — matches queue-local's `ORDER BY priority DESC,
59
- // availableAt ASC, enqueuedAt ASC` over the same `WHERE availableAt
60
- // <= nowMs` filter. The Redis ZSET orders by availableAt only (the
61
- // score), which is enough to filter "ready vs not-yet-ready" via
62
- // ZRANGEBYSCORE 0..nowMs but NOT enough to surface a high-priority
63
- // job ahead of an earlier-availableAt low-priority job. So we
64
- // over-fetch maxRows*5 candidates by score, HMGET the priority +
65
- // availableAt for each, sort priority-DESC / availableAt-ASC server-
66
- // side in Lua, and lease the top maxRows. The over-fetch factor is
67
- // chosen so a queue with a typical priority distribution surfaces
68
- // the right jobs without scanning the entire ready set; queues with
69
- // >5x as many priority-0 jobs as priority-N jobs would still pull
70
- // the top-priority ones first.
71
- //
72
- // KEYS[1] = ready zset
73
- // KEYS[2] = inflight zset
74
- // ARGV[1] = nowMs
75
- // ARGV[2] = leaseExpiresAt
76
- // ARGV[3] = maxRows
77
- // ARGV[4] = job-key prefix (e.g. "blamejs:queue:job:")
78
- var LEASE_LUA = [
79
- 'local readyKey = KEYS[1]',
80
- 'local inflightKey = KEYS[2]',
81
- 'local nowMs = tonumber(ARGV[1])',
82
- 'local leaseExpiresAt = tonumber(ARGV[2])',
83
- 'local maxRows = tonumber(ARGV[3])',
84
- 'local jobKeyPrefix = ARGV[4]',
85
- 'local oversample = maxRows * 5',
86
- 'local jobIds = redis.call("ZRANGEBYSCORE", readyKey, 0, nowMs, "LIMIT", 0, oversample)',
87
- 'if #jobIds == 0 then return {} end',
88
- // Pull priority + availableAt + enqueuedAt for each candidate so the
89
- // sort uses the same triple queue-local sorts on. enqueuedAt is the
90
- // tiebreaker among same-priority same-availableAt jobs (FIFO within
91
- // a priority lane).
92
- 'local rows = {}',
93
- 'for i = 1, #jobIds do',
94
- ' local jobId = jobIds[i]',
95
- ' local h = redis.call("HMGET", jobKeyPrefix..jobId, "priority", "availableAt", "enqueuedAt")',
96
- ' rows[i] = { jobId, tonumber(h[1] or "0") or 0, tonumber(h[2] or "0") or 0, tonumber(h[3] or "0") or 0 }',
97
- 'end',
98
- // priority DESC, availableAt ASC, enqueuedAt ASC.
99
- 'table.sort(rows, function(a, b)',
100
- ' if a[2] ~= b[2] then return a[2] > b[2] end',
101
- ' if a[3] ~= b[3] then return a[3] < b[3] end',
102
- ' return a[4] < b[4]',
103
- 'end)',
104
- 'local picked = {}',
105
- 'local n = math.min(maxRows, #rows)',
106
- 'for i = 1, n do',
107
- ' local jobId = rows[i][1]',
108
- ' redis.call("ZREM", readyKey, jobId)',
109
- ' redis.call("ZADD", inflightKey, leaseExpiresAt, jobId)',
110
- ' redis.call("HINCRBY", jobKeyPrefix..jobId, "attempts", 1)',
111
- ' redis.call("HSET", jobKeyPrefix..jobId,',
112
- ' "status", "inflight",',
113
- ' "leasedAt", nowMs,',
114
- ' "leaseExpiresAt", leaseExpiresAt)',
115
- ' picked[i] = jobId',
116
- 'end',
117
- 'return picked',
118
- ].join("\n");
119
-
120
- // SWEEP_LUA — find jobs in inflight whose lease expired, push back to
121
- // ready with score=nowMs (so they're immediately leasable again).
122
- //
123
- // KEYS[1] = inflight zset
124
- // KEYS[2] = ready zset
125
- // ARGV[1] = nowMs
126
- // ARGV[2] = job-key prefix
127
- var SWEEP_LUA = [
128
- 'local inflightKey = KEYS[1]',
129
- 'local readyKey = KEYS[2]',
130
- 'local nowMs = tonumber(ARGV[1])',
131
- 'local jobKeyPrefix = ARGV[2]',
132
- 'local expired = redis.call("ZRANGEBYSCORE", inflightKey, 0, nowMs)',
133
- 'local count = 0',
134
- 'for i = 1, #expired do',
135
- ' local jobId = expired[i]',
136
- ' redis.call("ZREM", inflightKey, jobId)',
137
- ' redis.call("ZADD", readyKey, nowMs, jobId)',
138
- ' redis.call("HSET", jobKeyPrefix..jobId, "status", "pending", "leaseExpiresAt", "")',
139
- ' count = count + 1',
140
- 'end',
141
- 'return count',
142
- ].join("\n");
143
-
144
- // COMPLETE_LUA — atomically remove from inflight zset, flip status to
145
- // done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
146
- //
147
- // KEYS[1] = inflight zset
148
- // KEYS[2] = job hash key
149
- // ARGV[1] = jobId (member to ZREM)
150
- // ARGV[2] = nowMs
151
- var COMPLETE_LUA = [
152
- 'local inflightKey = KEYS[1]',
153
- 'local jobKey = KEYS[2]',
154
- 'local jobId = ARGV[1]',
155
- 'local nowMs = tonumber(ARGV[2])',
156
- 'local removed = redis.call("ZREM", inflightKey, jobId)',
157
- 'if removed == 1 then',
158
- ' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
159
- 'end',
160
- 'return removed',
161
- ].join("\n");
162
-
163
- // FAIL_LUA — decide retry vs DLQ based on the row's current attempts
164
- // vs maxAttempts (read from HASH for race-freedom). Retry: ZADD ready
165
- // at score=nextAvailableAt, status=pending. DLQ: ZADD dlq at
166
- // score=nowMs, status=failed.
167
- //
168
- // KEYS[1] = inflight zset
169
- // KEYS[2] = ready zset
170
- // KEYS[3] = dlq zset
171
- // KEYS[4] = job hash key
172
- // ARGV[1] = jobId
173
- // ARGV[2] = nowMs
174
- // ARGV[3] = sealedErr (string; "" if no error)
175
- // ARGV[4] = nextAvailableAt
176
- var FAIL_LUA = [
177
- 'local inflightKey = KEYS[1]',
178
- 'local readyKey = KEYS[2]',
179
- 'local dlqKey = KEYS[3]',
180
- 'local jobKey = KEYS[4]',
181
- 'local jobId = ARGV[1]',
182
- 'local nowMs = tonumber(ARGV[2])',
183
- 'local sealedErr = ARGV[3]',
184
- 'local nextAvailableAt = tonumber(ARGV[4])',
185
- 'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
186
- 'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
187
- 'redis.call("ZREM", inflightKey, jobId)',
188
- 'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
189
- 'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
190
- 'if attempts < maxAttempts then',
191
- ' redis.call("HSET", jobKey, "status", "pending", "availableAt", nextAvailableAt)',
192
- ' redis.call("ZADD", readyKey, nextAvailableAt, jobId)',
193
- ' return 0', // retried
194
- 'else',
195
- ' redis.call("HSET", jobKey, "status", "failed", "finishedAt", nowMs, "availableAt", "")',
196
- ' redis.call("ZADD", dlqKey, nowMs, jobId)',
197
- ' return 1', // landed in dlq
198
- 'end',
199
- ].join("\n");
200
-
201
- // EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight.
202
- //
203
- // KEYS[1] = inflight zset
204
- // KEYS[2] = job hash key
205
- // ARGV[1] = jobId
206
- // ARGV[2] = newExpiry
207
- var EXTEND_LUA = [
208
- 'local inflightKey = KEYS[1]',
209
- 'local jobKey = KEYS[2]',
210
- 'local jobId = ARGV[1]',
211
- 'local newExpiry = tonumber(ARGV[2])',
212
- 'local score = redis.call("ZSCORE", inflightKey, jobId)',
213
- 'if score == false then return 0 end',
214
- 'redis.call("ZADD", inflightKey, newExpiry, jobId)',
215
- 'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
216
- 'return 1',
217
- ].join("\n");
218
-
219
- // DLQ_RETRY_LUA — pull a job out of dlq, reset attempts, ZADD ready.
220
- //
221
- // KEYS[1] = dlq zset
222
- // KEYS[2] = ready zset
223
- // KEYS[3] = job hash key
224
- // ARGV[1] = jobId
225
- // ARGV[2] = nowMs
226
- var DLQ_RETRY_LUA = [
227
- 'local dlqKey = KEYS[1]',
228
- 'local readyKey = KEYS[2]',
229
- 'local jobKey = KEYS[3]',
230
- 'local jobId = ARGV[1]',
231
- 'local nowMs = tonumber(ARGV[2])',
232
- 'local removed = redis.call("ZREM", dlqKey, jobId)',
233
- 'if removed == 0 then return 0 end',
234
- 'redis.call("HSET", jobKey,',
235
- ' "status", "pending",',
236
- ' "attempts", 0,',
237
- ' "availableAt", nowMs,',
238
- ' "lastError", "",',
239
- ' "finishedAt", "",',
240
- ' "leasedAt", "",',
241
- ' "leaseExpiresAt", "")',
242
- 'redis.call("ZADD", readyKey, nowMs, jobId)',
243
- 'return 1',
244
- ].join("\n");
245
-
246
- // ---- Adapter ----
247
-
248
- function create(opts) {
249
- opts = opts || {};
250
- if (typeof opts.url !== "string" || opts.url.length === 0) {
251
- throw _err("INVALID_CONFIG",
252
- "queue-redis: opts.url is required (e.g. redis://localhost:6379/0)", true);
253
- }
254
- var prefix = typeof opts.keyPrefix === "string" && opts.keyPrefix.length > 0
255
- ? opts.keyPrefix : DEFAULT_PREFIX;
256
-
257
- var client = redisClient.create(redisClient.pickClientOpts(opts));
258
-
259
- // Lazy connect — defer first connect until the first operation so
260
- // queue.init({ backends }) doesn't have to be async.
261
- var connectPromise = null;
262
- function _ensureConnected() {
263
- if (client.isOpen()) return Promise.resolve();
264
- if (!connectPromise) connectPromise = client.connect();
265
- return connectPromise;
266
- }
267
-
268
- // ---- Key helpers ----
269
- function _jobKey(jobId) { return prefix + ":job:" + jobId; }
270
- function _readyKey(queueName) { return prefix + ":q:" + queueName + ":ready"; }
271
- function _inflightKey(queueName){ return prefix + ":q:" + queueName + ":inflight"; }
272
- function _dlqKey(queueName) { return prefix + ":q:" + queueName + ":dlq"; }
273
- function _queuesKey() { return prefix + ":queues"; }
274
- function _jobKeyPrefix() { return prefix + ":job:"; }
275
- function _flowKey(flowId) { return prefix + ":flow:" + flowId; }
276
-
277
- // ---- Row encoding ----
278
- //
279
- // Redis HSET fields are flat string-or-binary. Encode a JS object
280
- // into HSET-friendly args while preserving null/undefined as missing
281
- // (HDEL on update; never sent on insert) and boolean/number/buffer
282
- // as their natural string form.
283
- function _hsetArgs(jobId, fieldsObj) {
284
- var args = ["HSET", _jobKey(jobId)];
285
- Object.keys(fieldsObj).forEach(function (k) {
286
- var v = fieldsObj[k];
287
- if (v === null || v === undefined) return; // skip
288
- args.push(k);
289
- if (Buffer.isBuffer(v)) args.push(v);
290
- else if (v === true || v === false) args.push(v ? "1" : "0");
291
- else args.push(String(v));
292
- });
293
- return args;
294
- }
295
-
296
- // Decode an HGETALL reply (alternating field/value Buffers) into a
297
- // plain object with Buffer/string values as appropriate. Returns
298
- // null when the hash didn't exist (HGETALL on missing key returns []).
299
- function _decodeHash(hashArr) {
300
- if (!hashArr || hashArr.length === 0) return null;
301
- var out = {};
302
- for (var i = 0; i + 1 < hashArr.length; i += 2) {
303
- var k = Buffer.isBuffer(hashArr[i]) ? hashArr[i].toString("utf8") : String(hashArr[i]);
304
- out[k] = Buffer.isBuffer(hashArr[i + 1]) ? hashArr[i + 1].toString("utf8") : hashArr[i + 1];
305
- }
306
- return out;
307
- }
308
-
309
- // Shape a leased row into the same { jobId, queueName, payload, ... }
310
- // contract queue-local returns from _shapeLeasedRow.
311
- function _shapeLeasedRow(jobId, raw) {
312
- if (!raw) return null;
313
- // Pretend it's a "_blamejs_jobs" row so cryptoField unseals correctly.
314
- var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
315
- return {
316
- jobId: jobId,
317
- queueName: unsealed.queueName,
318
- payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
319
- attempts: Number(unsealed.attempts),
320
- maxAttempts: Number(unsealed.maxAttempts),
321
- traceId: unsealed.traceId || null,
322
- classification: unsealed.classification || null,
323
- enqueuedAt: Number(unsealed.enqueuedAt),
324
- leaseExpiresAt: Number(unsealed.leaseExpiresAt),
325
- repeatCron: unsealed.repeatCron || null,
326
- repeatTimezone: unsealed.repeatTimezone || null,
327
- flowId: unsealed.flowId || null,
328
- flowChildName: unsealed.flowChildName || null,
329
- };
330
- }
331
-
332
- // ---- Public adapter ops ----
333
-
334
- async function enqueue(queueName, payload, opts2) {
335
- await _ensureConnected();
336
- opts2 = opts2 || {};
337
- var nowMs = Date.now();
338
- // Same SCHEDULING PRECEDENCE rule as queue-local: opts.availableAt
339
- // wins when finite; relative form is shorthand only.
340
- var availableAt;
341
- if (typeof opts2.availableAt === "number" && isFinite(opts2.availableAt)) {
342
- availableAt = opts2.availableAt;
343
- } else {
344
- availableAt = nowMs + (opts2.delaySeconds ? C.TIME.seconds(opts2.delaySeconds) : 0);
345
- }
346
- var jobId = generateToken(C.BYTES.bytes(16));
347
- var row = {
348
- _id: jobId,
349
- queueName: queueName,
350
- payload: payload === undefined ? null : JSON.stringify(payload),
351
- status: "pending",
352
- enqueuedAt: nowMs,
353
- availableAt: availableAt,
354
- attempts: 0,
355
- maxAttempts: opts2.maxAttempts != null ? opts2.maxAttempts : 5,
356
- lastError: null,
357
- finishedAt: null,
358
- traceId: opts2.traceId || null,
359
- classification: opts2.classification || null,
360
- priority: (typeof opts2.priority === "number" && isFinite(opts2.priority))
361
- ? Math.floor(opts2.priority) : 0,
362
- repeatCron: opts2.repeat && typeof opts2.repeat.cron === "string"
363
- ? opts2.repeat.cron : null,
364
- repeatTimezone: opts2.repeat && typeof opts2.repeat.timezone === "string"
365
- ? opts2.repeat.timezone : null,
366
- flowId: typeof opts2.flowId === "string" ? opts2.flowId : null,
367
- flowChildName: typeof opts2.flowChildName === "string" ? opts2.flowChildName : null,
368
- dependsOn: Array.isArray(opts2.dependsOn) && opts2.dependsOn.length > 0
369
- ? JSON.stringify(opts2.dependsOn) : null,
370
- };
371
- var sealed = cryptoField.sealRow("_blamejs_jobs", row);
372
-
373
- // Pipeline: HSET job + ZADD ready + SADD queues + (if flowId)
374
- // SADD flow set. Pipelined writes hit Redis without round-trips
375
- // between them. The flow set is the per-flow registry that
376
- // complete() walks to release dependents — it lets us avoid a
377
- // SCAN over every job hash when releasing children, matching the
378
- // queue-local pattern of "SELECT siblings WHERE flowId = ?".
379
- var hsetArgs = _hsetArgs(jobId, sealed);
380
- var pipeline = [
381
- client.command.apply(null, hsetArgs),
382
- client.command("ZADD", _readyKey(queueName), String(availableAt), jobId),
383
- client.command("SADD", _queuesKey(), queueName),
384
- ];
385
- if (row.flowId) {
386
- pipeline.push(client.command("SADD", _flowKey(row.flowId), jobId));
387
- }
388
- await Promise.all(pipeline);
389
-
390
- return {
391
- jobId: jobId,
392
- queueName: queueName,
393
- enqueuedAt: nowMs,
394
- availableAt: availableAt,
395
- classification: row.classification,
396
- };
397
- }
398
-
399
- async function lease(queueName, leaseMs, count) {
400
- await _ensureConnected();
401
- var nowMs = Date.now();
402
- var leaseExpiresAt = nowMs + leaseMs;
403
- var maxRows = count != null ? count : 1;
404
-
405
- var jobIdsRaw = await client.runScript(
406
- LEASE_LUA, 2,
407
- _readyKey(queueName), _inflightKey(queueName),
408
- String(nowMs), String(leaseExpiresAt), String(maxRows), _jobKeyPrefix()
409
- );
410
- if (!jobIdsRaw || jobIdsRaw.length === 0) return [];
411
-
412
- var jobIds = jobIdsRaw.map(function (x) {
413
- return Buffer.isBuffer(x) ? x.toString("utf8") : String(x);
414
- });
415
-
416
- // Fetch each job's full record. Pipelined HGETALLs.
417
- var hashes = await Promise.all(jobIds.map(function (id) {
418
- return client.command("HGETALL", _jobKey(id));
419
- }));
420
- var leased = [];
421
- for (var i = 0; i < jobIds.length; i++) {
422
- var raw = _decodeHash(hashes[i]);
423
- var shaped = _shapeLeasedRow(jobIds[i], raw);
424
- if (shaped) leased.push(shaped);
425
- }
426
- return leased;
427
- }
428
-
429
- async function extendLease(jobId, additionalMs) {
430
- await _ensureConnected();
431
- if (typeof additionalMs !== "number" || additionalMs <= 0) {
432
- throw _err("INVALID_LEASE_EXTENSION",
433
- "extendLease: additionalMs must be a positive number", true);
434
- }
435
- var newExpiry = Date.now() + additionalMs;
436
- // We don't know which queue the job belongs to without a HGET, so
437
- // fetch queueName first (avoids storing inflight by queue, which
438
- // would otherwise need a global secondary index).
439
- var qBuf = await client.command("HGET", _jobKey(jobId), "queueName");
440
- if (qBuf === null || qBuf === undefined) return false;
441
- var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
442
- var rv = await client.runScript(
443
- EXTEND_LUA, 2,
444
- _inflightKey(queueName), _jobKey(jobId),
445
- jobId, String(newExpiry)
446
- );
447
- return rv === 1;
448
- }
449
-
450
- async function complete(jobId) {
451
- await _ensureConnected();
452
- var nowMs = Date.now();
453
- // Read row first to act on cron-repeat metadata. Same shape as
454
- // queue-local: SELECT row → flip status → if repeatCron, enqueue
455
- // next firing.
456
- var rawArr = await client.command("HGETALL", _jobKey(jobId));
457
- var raw = _decodeHash(rawArr);
458
- if (!raw) return false;
459
- var queueName = raw.queueName || "unknown";
460
-
461
- await client.runScript(
462
- COMPLETE_LUA, 2,
463
- _inflightKey(queueName), _jobKey(jobId),
464
- jobId, String(nowMs)
465
- );
466
-
467
- if (raw.repeatCron) {
468
- try {
469
- var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
470
- var cron = scheduler.parseCron(unsealed.repeatCron);
471
- var nextMs = scheduler.nextCronFire(
472
- cron, new Date(nowMs), unsealed.repeatTimezone || null);
473
- await enqueue(unsealed.queueName,
474
- unsealed.payload ? safeJson.parse(unsealed.payload) : null,
475
- {
476
- availableAt: nextMs,
477
- repeat: { cron: unsealed.repeatCron, timezone: unsealed.repeatTimezone },
478
- priority: Number(unsealed.priority) || 0,
479
- classification: unsealed.classification || null,
480
- traceId: unsealed.traceId || null,
481
- });
482
- } catch (_e) { /* best-effort — cron resumes next tick if op fixes the issue */ }
483
- }
484
-
485
- // Flow propagation: walk siblings whose dependsOn includes this
486
- // jobId (or this job's flowChildName) and bump availableAt to now
487
- // if ALL their deps are now complete. Mirrors queue-local's
488
- // _maybeReleaseFlowChildren, but uses a per-flow Redis SET as
489
- // the sibling registry instead of a SQL SELECT.
490
- if (raw.flowId) {
491
- try {
492
- await _maybeReleaseFlowChildren(raw.flowId, jobId, raw.flowChildName || null, nowMs);
493
- } catch (_e) { /* best-effort — sweepExpired retries if a deps check fails */ }
494
- // The completed job stays in the flow set so a LATER sibling
495
- // whose dependsOn includes this job's flowChildName can still
496
- // find a "done" sibling when its own complete() walks the flow.
497
- // The set is purge()'d when the queue is purged or — for the
498
- // typical case where the operator wants to reclaim flow memory
499
- // after the whole flow finishes — by an explicit operator call
500
- // to purge() OR by letting `_maybeReleaseFlowChildren` reach a
501
- // state where every sibling has status='done' (which the
502
- // operator can detect via b.queue.dlqList / app-level queries).
503
- }
504
- return true;
505
- }
506
-
507
- async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
508
- var flowKey = _flowKey(flowId);
509
- var members = await client.command("SMEMBERS", flowKey);
510
- if (!members || members.length === 0) return;
511
-
512
- // Pull dependsOn + status + flowChildName for every sibling in one
513
- // pipelined batch — far cheaper than per-sibling HMGET round trips.
514
- var siblingIds = members.map(function (m) {
515
- return Buffer.isBuffer(m) ? m.toString("utf8") : String(m);
516
- }).filter(function (id) { return id !== completedJobId; });
517
- if (siblingIds.length === 0) return;
518
-
519
- var hmgetCalls = siblingIds.map(function (sibId) {
520
- return client.command("HMGET", _jobKey(sibId),
521
- "dependsOn", "status", "flowChildName");
522
- });
523
- var results = await Promise.all(hmgetCalls);
524
-
525
- // For each pending sibling with a dependsOn array, check if all
526
- // deps are satisfied (the just-completed job AND any prior
527
- // already-done sibling in the flow).
528
- for (var i = 0; i < siblingIds.length; i++) {
529
- var sibId = siblingIds[i];
530
- var rv = results[i];
531
- if (!rv || rv.length < 3) continue;
532
- var rawDeps = rv[0] && (Buffer.isBuffer(rv[0]) ? rv[0].toString("utf8") : String(rv[0]));
533
- var status = rv[1] && (Buffer.isBuffer(rv[1]) ? rv[1].toString("utf8") : String(rv[1]));
534
- if (!rawDeps || status !== "pending") continue;
535
- var deps;
536
- try { deps = safeJson.parse(rawDeps, { maxBytes: C.BYTES.mib(1) }); }
537
- catch (_e) { continue; }
538
- if (!Array.isArray(deps) || deps.length === 0) continue;
539
-
540
- // Resolve each dep against the just-completed job (id or child
541
- // name) OR a prior-completed sibling in the flow.
542
- var allDone = true;
543
- for (var d = 0; d < deps.length; d++) {
544
- var dep = deps[d];
545
- if (dep === completedJobId) continue;
546
- if (completedChildName && dep === completedChildName) continue;
547
- // Look up by id first; if no hit, fall back to scanning the
548
- // flow set for a sibling whose flowChildName matches.
549
- var depHash = await client.command("HMGET", _jobKey(dep), "status", "flowId");
550
- if (depHash && depHash[0]) {
551
- var depStatus = Buffer.isBuffer(depHash[0]) ? depHash[0].toString("utf8") : String(depHash[0]);
552
- var depFlow = depHash[1] ? (Buffer.isBuffer(depHash[1]) ? depHash[1].toString("utf8") : String(depHash[1])) : "";
553
- if (depStatus === "done" && depFlow === flowId) continue;
554
- }
555
- // Scan the flow's set for a child-name match — cheap because
556
- // a flow typically has 5-50 children, not thousands.
557
- var matched = false;
558
- for (var s = 0; s < siblingIds.length && !matched; s++) {
559
- if (siblingIds[s] === sibId) continue;
560
- var sRv = results[s];
561
- if (!sRv || !sRv[2]) continue;
562
- var sName = Buffer.isBuffer(sRv[2]) ? sRv[2].toString("utf8") : String(sRv[2]);
563
- var sStatus = sRv[1] ? (Buffer.isBuffer(sRv[1]) ? sRv[1].toString("utf8") : String(sRv[1])) : "";
564
- if (sName === dep && sStatus === "done") matched = true;
565
- }
566
- if (!matched) { allDone = false; break; }
567
- }
568
-
569
- if (allDone) {
570
- // Read queueName so the ZADD targets the right ready zset.
571
- var qBuf = await client.command("HGET", _jobKey(sibId), "queueName");
572
- if (!qBuf) continue;
573
- var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
574
- await Promise.all([
575
- client.command("HSET", _jobKey(sibId), "availableAt", String(nowMs)),
576
- client.command("ZADD", _readyKey(queueName), String(nowMs), sibId),
577
- ]);
578
- }
579
- }
580
- }
581
-
582
- async function fail(jobId, errorMessage, retryDelayMs) {
583
- await _ensureConnected();
584
- var nowMs = Date.now();
585
- if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
586
- retryDelayMs = 0;
587
- }
588
- var nextAvailableAt = nowMs + retryDelayMs;
589
-
590
- var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
591
- if (queueBuf === null || queueBuf === undefined) return false;
592
- var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
593
-
594
- var sealedErr = errorMessage ? vault().seal(String(errorMessage)) : "";
595
-
596
- await client.runScript(
597
- FAIL_LUA, 4,
598
- _inflightKey(queueName), _readyKey(queueName), _dlqKey(queueName), _jobKey(jobId),
599
- jobId, String(nowMs), sealedErr, String(nextAvailableAt)
600
- );
601
- return true;
602
- }
603
-
604
- async function sweepExpired() {
605
- await _ensureConnected();
606
- // Walk every known queue; the queues SET keeps the list current
607
- // (enqueue SADDs the name).
608
- var qs = await client.command("SMEMBERS", _queuesKey());
609
- if (!qs || qs.length === 0) return 0;
610
- var nowMs = Date.now();
611
- var totals = await Promise.all(qs.map(function (qBuf) {
612
- var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
613
- return client.runScript(
614
- SWEEP_LUA, 2,
615
- _inflightKey(queueName), _readyKey(queueName),
616
- String(nowMs), _jobKeyPrefix());
617
- }));
618
- return totals.reduce(function (acc, n) { return acc + Number(n || 0); }, 0);
619
- }
620
-
621
- async function size(queueName) {
622
- await _ensureConnected();
623
- var [r, i] = await Promise.all([
624
- client.command("ZCARD", _readyKey(queueName)),
625
- client.command("ZCARD", _inflightKey(queueName)),
626
- ]);
627
- return Number(r || 0) + Number(i || 0);
628
- }
629
-
630
- async function purge(queueName) {
631
- await _ensureConnected();
632
- // Walk the ready + inflight + dlq zsets, collect every job id,
633
- // also clear each job from its flow set (if any), then DEL the
634
- // job hashes + zsets + queues-membership. Flow sets are cleared
635
- // job-by-job because a single flow can span multiple queues, and
636
- // we only want to evict THIS queue's contributors.
637
- var [readyMembers, inflightMembers, dlqMembers] = await Promise.all([
638
- client.command("ZRANGE", _readyKey(queueName), "0", "-1"),
639
- client.command("ZRANGE", _inflightKey(queueName), "0", "-1"),
640
- client.command("ZRANGE", _dlqKey(queueName), "0", "-1"),
641
- ]);
642
- var allIds = [].concat(readyMembers || [], inflightMembers || [], dlqMembers || [])
643
- .map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
644
- // Pull the flowId for each job (if set) so we can SREM from the
645
- // matching flow set BEFORE we DEL the job hash.
646
- var flowIdLookups = await Promise.all(allIds.map(function (id) {
647
- return client.command("HGET", _jobKey(id), "flowId");
648
- }));
649
- var flowSrems = [];
650
- for (var fi = 0; fi < allIds.length; fi++) {
651
- var fIdBuf = flowIdLookups[fi];
652
- if (!fIdBuf) continue;
653
- var fId = Buffer.isBuffer(fIdBuf) ? fIdBuf.toString("utf8") : String(fIdBuf);
654
- if (fId) flowSrems.push(client.command("SREM", _flowKey(fId), allIds[fi]));
655
- }
656
- var dels = allIds.map(function (id) { return client.command("DEL", _jobKey(id)); });
657
- var zdrops = [
658
- client.command("DEL", _readyKey(queueName)),
659
- client.command("DEL", _inflightKey(queueName)),
660
- client.command("DEL", _dlqKey(queueName)),
661
- client.command("SREM", _queuesKey(), queueName),
662
- ];
663
- await Promise.all(flowSrems.concat(dels, zdrops));
664
- return allIds.length;
665
- }
666
-
667
- async function dlqList(queueName, opts2) {
668
- await _ensureConnected();
669
- opts2 = opts2 || {};
670
- var limit = (typeof opts2.limit === "number" && opts2.limit > 0) ? opts2.limit : 100;
671
- // Newest failures first — score is finishedAtMs, so ZREVRANGE.
672
- var ids = await client.command(
673
- "ZREVRANGE", _dlqKey(queueName), "0", String(limit - 1));
674
- if (!ids || ids.length === 0) return [];
675
- var idStrs = ids.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
676
- var hashes = await Promise.all(idStrs.map(function (id) {
677
- return client.command("HGETALL", _jobKey(id));
678
- }));
679
- var out = [];
680
- for (var i = 0; i < idStrs.length; i++) {
681
- var raw = _decodeHash(hashes[i]);
682
- if (!raw) continue;
683
- var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
684
- out.push({
685
- jobId: idStrs[i],
686
- queueName: unsealed.queueName,
687
- payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
688
- status: unsealed.status,
689
- enqueuedAt: Number(unsealed.enqueuedAt),
690
- finishedAt: unsealed.finishedAt ? Number(unsealed.finishedAt) : null,
691
- attempts: Number(unsealed.attempts),
692
- maxAttempts: Number(unsealed.maxAttempts),
693
- lastError: unsealed.lastError || null,
694
- traceId: unsealed.traceId || null,
695
- classification: unsealed.classification || null,
696
- });
697
- }
698
- return out;
699
- }
700
-
701
- async function dlqRetry(jobId) {
702
- await _ensureConnected();
703
- var nowMs = Date.now();
704
- var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
705
- if (queueBuf === null || queueBuf === undefined) return false;
706
- var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
707
- var rv = await client.runScript(
708
- DLQ_RETRY_LUA, 3,
709
- _dlqKey(queueName), _readyKey(queueName), _jobKey(jobId),
710
- jobId, String(nowMs)
711
- );
712
- return rv === 1;
713
- }
714
-
715
- async function dlqSize(queueName) {
716
- await _ensureConnected();
717
- var n = await client.command("ZCARD", _dlqKey(queueName));
718
- return Number(n || 0);
719
- }
720
-
721
- async function shutdown() {
722
- try { await client.close(); } catch (_e) { /* best effort */ }
723
- }
724
-
725
- return {
726
- protocol: "redis",
727
- enqueue: enqueue,
728
- lease: lease,
729
- extendLease: extendLease,
730
- complete: complete,
731
- fail: fail,
732
- sweepExpired: sweepExpired,
733
- size: size,
734
- purge: purge,
735
- dlqList: dlqList,
736
- dlqRetry: dlqRetry,
737
- dlqSize: dlqSize,
738
- shutdown: shutdown,
739
- // Diagnostic — exposed for tests + ops dashboards
740
- _client: client,
741
- _prefix: function () { return prefix; },
742
- };
743
- }
744
-
745
- module.exports = { create: create };
1
+ "use strict";
2
+ /**
3
+ * Redis-protocol queue adapter — backs b.queue with Redis instead of
4
+ * the framework's main DB. Lets operators run multiple app nodes that
5
+ * share a single queue without each needing to be cluster leader,
6
+ * since Redis itself is the coordination point.
7
+ *
8
+ * Storage layout (operator-overridable prefix, default "blamejs:queue"):
9
+ * <prefix>:job:<jobId> HASH — full job record (sealed payload + lastError)
10
+ * <prefix>:q:<queue>:ready ZSET — member=jobId, score=availableAtMs (lease index)
11
+ * <prefix>:q:<queue>:inflight ZSET — member=jobId, score=leaseExpiresAtMs (sweep index)
12
+ * <prefix>:q:<queue>:dlq ZSET — member=jobId, score=finishedAtMs (failed jobs)
13
+ * <prefix>:q:<queue>:queues SET — registry of known queue names (for purge/size scans)
14
+ *
15
+ * Atomicity: lease / sweep / fail / complete all run as Lua scripts so
16
+ * the inflight-zset / ready-zset / job-hash mutations land in a single
17
+ * Redis op without a window for concurrent consumers to double-lease
18
+ * or for sweep to race a complete.
19
+ *
20
+ * Field-crypto integration: payload + lastError seal/unseal go through
21
+ * cryptoField.sealRow("_blamejs_jobs", row) and unsealRow(...) — the
22
+ * SAME crypto-field config the local backend uses, keyed by the
23
+ * "_blamejs_jobs" table name. Operators configuring sealedFields on the
24
+ * jobs table get the same protection on Redis as on SQLite.
25
+ *
26
+ * Cron-repeat: handled at complete()-time in JS (not Lua) — re-enqueues
27
+ * the next firing as a fresh jobId with availableAt = next-cron-fire.
28
+ *
29
+ * Out of scope (defer to follow-up patches):
30
+ * - Redis Cluster (slot-routing across multi-node Redis)
31
+ * - Sentinel (managed primary failover)
32
+ */
33
+ var C = require("./constants");
34
+ var cryptoField = require("./crypto-field");
35
+ var { generateToken } = require("./crypto");
36
+ var lazyRequire = require("./lazy-require");
37
+ var redisClient = require("./redis-client");
38
+ var safeJson = require("./safe-json");
39
+ var scheduler = require("./scheduler");
40
+ var { QueueError } = require("./framework-error");
41
+
42
+ var _err = QueueError.factory;
43
+
44
+ // vault is lazy-required because some flows (sealed lastError) only
45
+ // touch it on retry-with-error paths, and the import order
46
+ // (queue-redis → vault → db → audit) tolerates the late bind.
47
+ var vault = lazyRequire(function () { return require("./vault"); });
48
+
49
+ var DEFAULT_PREFIX = "blamejs:queue";
50
+
51
+ // ---- Lua scripts ----
52
+ //
53
+ // LEASE_LUA — atomically pull up to maxRows jobs from the ready zset
54
+ // whose availableAt is <= nowMs, move them to the inflight zset with
55
+ // score = leaseExpiresAt, increment attempts, flip status, and return
56
+ // the jobIds in priority order. The JS side then HGETALLs each id.
57
+ //
58
+ // Priority semantics — matches queue-local's `ORDER BY priority DESC,
59
+ // availableAt ASC, enqueuedAt ASC` over the same `WHERE availableAt
60
+ // <= nowMs` filter. The Redis ZSET orders by availableAt only (the
61
+ // score), which is enough to filter "ready vs not-yet-ready" via
62
+ // ZRANGEBYSCORE 0..nowMs but NOT enough to surface a high-priority
63
+ // job ahead of an earlier-availableAt low-priority job. So we
64
+ // over-fetch maxRows*5 candidates by score, HMGET the priority +
65
+ // availableAt for each, sort priority-DESC / availableAt-ASC server-
66
+ // side in Lua, and lease the top maxRows. The over-fetch factor is
67
+ // chosen so a queue with a typical priority distribution surfaces
68
+ // the right jobs without scanning the entire ready set; queues with
69
+ // >5x as many priority-0 jobs as priority-N jobs would still pull
70
+ // the top-priority ones first.
71
+ //
72
+ // KEYS[1] = ready zset
73
+ // KEYS[2] = inflight zset
74
+ // ARGV[1] = nowMs
75
+ // ARGV[2] = leaseExpiresAt
76
+ // ARGV[3] = maxRows
77
+ // ARGV[4] = job-key prefix (e.g. "blamejs:queue:job:")
78
+ var LEASE_LUA = [
79
+ 'local readyKey = KEYS[1]',
80
+ 'local inflightKey = KEYS[2]',
81
+ 'local nowMs = tonumber(ARGV[1])',
82
+ 'local leaseExpiresAt = tonumber(ARGV[2])',
83
+ 'local maxRows = tonumber(ARGV[3])',
84
+ 'local jobKeyPrefix = ARGV[4]',
85
+ 'local oversample = maxRows * 5',
86
+ 'local jobIds = redis.call("ZRANGEBYSCORE", readyKey, 0, nowMs, "LIMIT", 0, oversample)',
87
+ 'if #jobIds == 0 then return {} end',
88
+ // Pull priority + availableAt + enqueuedAt for each candidate so the
89
+ // sort uses the same triple queue-local sorts on. enqueuedAt is the
90
+ // tiebreaker among same-priority same-availableAt jobs (FIFO within
91
+ // a priority lane).
92
+ 'local rows = {}',
93
+ 'for i = 1, #jobIds do',
94
+ ' local jobId = jobIds[i]',
95
+ ' local h = redis.call("HMGET", jobKeyPrefix..jobId, "priority", "availableAt", "enqueuedAt")',
96
+ ' rows[i] = { jobId, tonumber(h[1] or "0") or 0, tonumber(h[2] or "0") or 0, tonumber(h[3] or "0") or 0 }',
97
+ 'end',
98
+ // priority DESC, availableAt ASC, enqueuedAt ASC.
99
+ 'table.sort(rows, function(a, b)',
100
+ ' if a[2] ~= b[2] then return a[2] > b[2] end',
101
+ ' if a[3] ~= b[3] then return a[3] < b[3] end',
102
+ ' return a[4] < b[4]',
103
+ 'end)',
104
+ 'local picked = {}',
105
+ 'local n = math.min(maxRows, #rows)',
106
+ 'for i = 1, n do',
107
+ ' local jobId = rows[i][1]',
108
+ ' redis.call("ZREM", readyKey, jobId)',
109
+ ' redis.call("ZADD", inflightKey, leaseExpiresAt, jobId)',
110
+ ' redis.call("HINCRBY", jobKeyPrefix..jobId, "attempts", 1)',
111
+ ' redis.call("HSET", jobKeyPrefix..jobId,',
112
+ ' "status", "inflight",',
113
+ ' "leasedAt", nowMs,',
114
+ ' "leaseExpiresAt", leaseExpiresAt)',
115
+ ' picked[i] = jobId',
116
+ 'end',
117
+ 'return picked',
118
+ ].join("\n");
119
+
120
+ // SWEEP_LUA — find jobs in inflight whose lease expired, push back to
121
+ // ready with score=nowMs (so they're immediately leasable again).
122
+ //
123
+ // KEYS[1] = inflight zset
124
+ // KEYS[2] = ready zset
125
+ // ARGV[1] = nowMs
126
+ // ARGV[2] = job-key prefix
127
+ var SWEEP_LUA = [
128
+ 'local inflightKey = KEYS[1]',
129
+ 'local readyKey = KEYS[2]',
130
+ 'local nowMs = tonumber(ARGV[1])',
131
+ 'local jobKeyPrefix = ARGV[2]',
132
+ 'local expired = redis.call("ZRANGEBYSCORE", inflightKey, 0, nowMs)',
133
+ 'local count = 0',
134
+ 'for i = 1, #expired do',
135
+ ' local jobId = expired[i]',
136
+ ' redis.call("ZREM", inflightKey, jobId)',
137
+ ' redis.call("ZADD", readyKey, nowMs, jobId)',
138
+ ' redis.call("HSET", jobKeyPrefix..jobId, "status", "pending", "leaseExpiresAt", "")',
139
+ ' count = count + 1',
140
+ 'end',
141
+ 'return count',
142
+ ].join("\n");
143
+
144
+ // COMPLETE_LUA — atomically remove from inflight zset, flip status to
145
+ // done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
146
+ //
147
+ // KEYS[1] = inflight zset
148
+ // KEYS[2] = job hash key
149
+ // ARGV[1] = jobId (member to ZREM)
150
+ // ARGV[2] = nowMs
151
+ var COMPLETE_LUA = [
152
+ 'local inflightKey = KEYS[1]',
153
+ 'local jobKey = KEYS[2]',
154
+ 'local jobId = ARGV[1]',
155
+ 'local nowMs = tonumber(ARGV[2])',
156
+ 'local removed = redis.call("ZREM", inflightKey, jobId)',
157
+ 'if removed == 1 then',
158
+ ' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
159
+ 'end',
160
+ 'return removed',
161
+ ].join("\n");
162
+
163
+ // FAIL_LUA — decide retry vs DLQ based on the row's current attempts
164
+ // vs maxAttempts (read from HASH for race-freedom). Retry: ZADD ready
165
+ // at score=nextAvailableAt, status=pending. DLQ: ZADD dlq at
166
+ // score=nowMs, status=failed.
167
+ //
168
+ // KEYS[1] = inflight zset
169
+ // KEYS[2] = ready zset
170
+ // KEYS[3] = dlq zset
171
+ // KEYS[4] = job hash key
172
+ // ARGV[1] = jobId
173
+ // ARGV[2] = nowMs
174
+ // ARGV[3] = sealedErr (string; "" if no error)
175
+ // ARGV[4] = nextAvailableAt
176
+ var FAIL_LUA = [
177
+ 'local inflightKey = KEYS[1]',
178
+ 'local readyKey = KEYS[2]',
179
+ 'local dlqKey = KEYS[3]',
180
+ 'local jobKey = KEYS[4]',
181
+ 'local jobId = ARGV[1]',
182
+ 'local nowMs = tonumber(ARGV[2])',
183
+ 'local sealedErr = ARGV[3]',
184
+ 'local nextAvailableAt = tonumber(ARGV[4])',
185
+ 'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
186
+ 'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
187
+ 'redis.call("ZREM", inflightKey, jobId)',
188
+ 'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
189
+ 'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
190
+ 'if attempts < maxAttempts then',
191
+ ' redis.call("HSET", jobKey, "status", "pending", "availableAt", nextAvailableAt)',
192
+ ' redis.call("ZADD", readyKey, nextAvailableAt, jobId)',
193
+ ' return 0', // retried
194
+ 'else',
195
+ ' redis.call("HSET", jobKey, "status", "failed", "finishedAt", nowMs, "availableAt", "")',
196
+ ' redis.call("ZADD", dlqKey, nowMs, jobId)',
197
+ ' return 1', // landed in dlq
198
+ 'end',
199
+ ].join("\n");
200
+
201
+ // EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight.
202
+ //
203
+ // KEYS[1] = inflight zset
204
+ // KEYS[2] = job hash key
205
+ // ARGV[1] = jobId
206
+ // ARGV[2] = newExpiry
207
+ var EXTEND_LUA = [
208
+ 'local inflightKey = KEYS[1]',
209
+ 'local jobKey = KEYS[2]',
210
+ 'local jobId = ARGV[1]',
211
+ 'local newExpiry = tonumber(ARGV[2])',
212
+ 'local score = redis.call("ZSCORE", inflightKey, jobId)',
213
+ 'if score == false then return 0 end',
214
+ 'redis.call("ZADD", inflightKey, newExpiry, jobId)',
215
+ 'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
216
+ 'return 1',
217
+ ].join("\n");
218
+
219
+ // DLQ_RETRY_LUA — pull a job out of dlq, reset attempts, ZADD ready.
220
+ //
221
+ // KEYS[1] = dlq zset
222
+ // KEYS[2] = ready zset
223
+ // KEYS[3] = job hash key
224
+ // ARGV[1] = jobId
225
+ // ARGV[2] = nowMs
226
+ var DLQ_RETRY_LUA = [
227
+ 'local dlqKey = KEYS[1]',
228
+ 'local readyKey = KEYS[2]',
229
+ 'local jobKey = KEYS[3]',
230
+ 'local jobId = ARGV[1]',
231
+ 'local nowMs = tonumber(ARGV[2])',
232
+ 'local removed = redis.call("ZREM", dlqKey, jobId)',
233
+ 'if removed == 0 then return 0 end',
234
+ 'redis.call("HSET", jobKey,',
235
+ ' "status", "pending",',
236
+ ' "attempts", 0,',
237
+ ' "availableAt", nowMs,',
238
+ ' "lastError", "",',
239
+ ' "finishedAt", "",',
240
+ ' "leasedAt", "",',
241
+ ' "leaseExpiresAt", "")',
242
+ 'redis.call("ZADD", readyKey, nowMs, jobId)',
243
+ 'return 1',
244
+ ].join("\n");
245
+
246
+ // ---- Adapter ----
247
+
248
+ function create(opts) {
249
+ opts = opts || {};
250
+ if (typeof opts.url !== "string" || opts.url.length === 0) {
251
+ throw _err("INVALID_CONFIG",
252
+ "queue-redis: opts.url is required (e.g. redis://localhost:6379/0)", true);
253
+ }
254
+ var prefix = typeof opts.keyPrefix === "string" && opts.keyPrefix.length > 0
255
+ ? opts.keyPrefix : DEFAULT_PREFIX;
256
+
257
+ var client = redisClient.create(redisClient.pickClientOpts(opts));
258
+
259
+ // Lazy connect — defer first connect until the first operation so
260
+ // queue.init({ backends }) doesn't have to be async.
261
+ var connectPromise = null;
262
+ function _ensureConnected() {
263
+ if (client.isOpen()) return Promise.resolve();
264
+ if (!connectPromise) connectPromise = client.connect();
265
+ return connectPromise;
266
+ }
267
+
268
+ // ---- Key helpers ----
269
+ function _jobKey(jobId) { return prefix + ":job:" + jobId; }
270
+ function _readyKey(queueName) { return prefix + ":q:" + queueName + ":ready"; }
271
+ function _inflightKey(queueName){ return prefix + ":q:" + queueName + ":inflight"; }
272
+ function _dlqKey(queueName) { return prefix + ":q:" + queueName + ":dlq"; }
273
+ function _queuesKey() { return prefix + ":queues"; }
274
+ function _jobKeyPrefix() { return prefix + ":job:"; }
275
+ function _flowKey(flowId) { return prefix + ":flow:" + flowId; }
276
+
277
+ // ---- Row encoding ----
278
+ //
279
+ // Redis HSET fields are flat string-or-binary. Encode a JS object
280
+ // into HSET-friendly args while preserving null/undefined as missing
281
+ // (HDEL on update; never sent on insert) and boolean/number/buffer
282
+ // as their natural string form.
283
+ function _hsetArgs(jobId, fieldsObj) {
284
+ var args = ["HSET", _jobKey(jobId)];
285
+ Object.keys(fieldsObj).forEach(function (k) {
286
+ var v = fieldsObj[k];
287
+ if (v === null || v === undefined) return; // skip
288
+ args.push(k);
289
+ if (Buffer.isBuffer(v)) args.push(v);
290
+ else if (v === true || v === false) args.push(v ? "1" : "0");
291
+ else args.push(String(v));
292
+ });
293
+ return args;
294
+ }
295
+
296
+ // Decode an HGETALL reply (alternating field/value Buffers) into a
297
+ // plain object with Buffer/string values as appropriate. Returns
298
+ // null when the hash didn't exist (HGETALL on missing key returns []).
299
+ function _decodeHash(hashArr) {
300
+ if (!hashArr || hashArr.length === 0) return null;
301
+ var out = {};
302
+ for (var i = 0; i + 1 < hashArr.length; i += 2) {
303
+ var k = Buffer.isBuffer(hashArr[i]) ? hashArr[i].toString("utf8") : String(hashArr[i]);
304
+ out[k] = Buffer.isBuffer(hashArr[i + 1]) ? hashArr[i + 1].toString("utf8") : hashArr[i + 1];
305
+ }
306
+ return out;
307
+ }
308
+
309
+ // Shape a leased row into the same { jobId, queueName, payload, ... }
310
+ // contract queue-local returns from _shapeLeasedRow.
311
+ function _shapeLeasedRow(jobId, raw) {
312
+ if (!raw) return null;
313
+ // Pretend it's a "_blamejs_jobs" row so cryptoField unseals correctly.
314
+ var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
315
+ return {
316
+ jobId: jobId,
317
+ queueName: unsealed.queueName,
318
+ payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
319
+ attempts: Number(unsealed.attempts),
320
+ maxAttempts: Number(unsealed.maxAttempts),
321
+ traceId: unsealed.traceId || null,
322
+ classification: unsealed.classification || null,
323
+ enqueuedAt: Number(unsealed.enqueuedAt),
324
+ leaseExpiresAt: Number(unsealed.leaseExpiresAt),
325
+ repeatCron: unsealed.repeatCron || null,
326
+ repeatTimezone: unsealed.repeatTimezone || null,
327
+ flowId: unsealed.flowId || null,
328
+ flowChildName: unsealed.flowChildName || null,
329
+ };
330
+ }
331
+
332
+ // ---- Public adapter ops ----
333
+
334
+ async function enqueue(queueName, payload, opts2) {
335
+ await _ensureConnected();
336
+ opts2 = opts2 || {};
337
+ var nowMs = Date.now();
338
+ // Same SCHEDULING PRECEDENCE rule as queue-local: opts.availableAt
339
+ // wins when finite; relative form is shorthand only.
340
+ var availableAt;
341
+ if (typeof opts2.availableAt === "number" && isFinite(opts2.availableAt)) {
342
+ availableAt = opts2.availableAt;
343
+ } else {
344
+ availableAt = nowMs + (opts2.delaySeconds ? C.TIME.seconds(opts2.delaySeconds) : 0);
345
+ }
346
+ var jobId = generateToken(C.BYTES.bytes(16));
347
+ var row = {
348
+ _id: jobId,
349
+ queueName: queueName,
350
+ payload: payload === undefined ? null : JSON.stringify(payload),
351
+ status: "pending",
352
+ enqueuedAt: nowMs,
353
+ availableAt: availableAt,
354
+ attempts: 0,
355
+ maxAttempts: opts2.maxAttempts != null ? opts2.maxAttempts : 5,
356
+ lastError: null,
357
+ finishedAt: null,
358
+ traceId: opts2.traceId || null,
359
+ classification: opts2.classification || null,
360
+ priority: (typeof opts2.priority === "number" && isFinite(opts2.priority))
361
+ ? Math.floor(opts2.priority) : 0,
362
+ repeatCron: opts2.repeat && typeof opts2.repeat.cron === "string"
363
+ ? opts2.repeat.cron : null,
364
+ repeatTimezone: opts2.repeat && typeof opts2.repeat.timezone === "string"
365
+ ? opts2.repeat.timezone : null,
366
+ flowId: typeof opts2.flowId === "string" ? opts2.flowId : null,
367
+ flowChildName: typeof opts2.flowChildName === "string" ? opts2.flowChildName : null,
368
+ dependsOn: Array.isArray(opts2.dependsOn) && opts2.dependsOn.length > 0
369
+ ? JSON.stringify(opts2.dependsOn) : null,
370
+ };
371
+ var sealed = cryptoField.sealRow("_blamejs_jobs", row);
372
+
373
+ // Pipeline: HSET job + ZADD ready + SADD queues + (if flowId)
374
+ // SADD flow set. Pipelined writes hit Redis without round-trips
375
+ // between them. The flow set is the per-flow registry that
376
+ // complete() walks to release dependents — it lets us avoid a
377
+ // SCAN over every job hash when releasing children, matching the
378
+ // queue-local pattern of "SELECT siblings WHERE flowId = ?".
379
+ var hsetArgs = _hsetArgs(jobId, sealed);
380
+ var pipeline = [
381
+ client.command.apply(null, hsetArgs),
382
+ client.command("ZADD", _readyKey(queueName), String(availableAt), jobId),
383
+ client.command("SADD", _queuesKey(), queueName),
384
+ ];
385
+ if (row.flowId) {
386
+ pipeline.push(client.command("SADD", _flowKey(row.flowId), jobId));
387
+ }
388
+ await Promise.all(pipeline);
389
+
390
+ return {
391
+ jobId: jobId,
392
+ queueName: queueName,
393
+ enqueuedAt: nowMs,
394
+ availableAt: availableAt,
395
+ classification: row.classification,
396
+ };
397
+ }
398
+
399
+ async function lease(queueName, leaseMs, count) {
400
+ await _ensureConnected();
401
+ var nowMs = Date.now();
402
+ var leaseExpiresAt = nowMs + leaseMs;
403
+ var maxRows = count != null ? count : 1;
404
+
405
+ var jobIdsRaw = await client.runScript(
406
+ LEASE_LUA, 2,
407
+ _readyKey(queueName), _inflightKey(queueName),
408
+ String(nowMs), String(leaseExpiresAt), String(maxRows), _jobKeyPrefix()
409
+ );
410
+ if (!jobIdsRaw || jobIdsRaw.length === 0) return [];
411
+
412
+ var jobIds = jobIdsRaw.map(function (x) {
413
+ return Buffer.isBuffer(x) ? x.toString("utf8") : String(x);
414
+ });
415
+
416
+ // Fetch each job's full record. Pipelined HGETALLs.
417
+ var hashes = await Promise.all(jobIds.map(function (id) {
418
+ return client.command("HGETALL", _jobKey(id));
419
+ }));
420
+ var leased = [];
421
+ for (var i = 0; i < jobIds.length; i++) {
422
+ var raw = _decodeHash(hashes[i]);
423
+ var shaped = _shapeLeasedRow(jobIds[i], raw);
424
+ if (shaped) leased.push(shaped);
425
+ }
426
+ return leased;
427
+ }
428
+
429
+ async function extendLease(jobId, additionalMs) {
430
+ await _ensureConnected();
431
+ if (typeof additionalMs !== "number" || additionalMs <= 0) {
432
+ throw _err("INVALID_LEASE_EXTENSION",
433
+ "extendLease: additionalMs must be a positive number", true);
434
+ }
435
+ var newExpiry = Date.now() + additionalMs;
436
+ // We don't know which queue the job belongs to without a HGET, so
437
+ // fetch queueName first (avoids storing inflight by queue, which
438
+ // would otherwise need a global secondary index).
439
+ var qBuf = await client.command("HGET", _jobKey(jobId), "queueName");
440
+ if (qBuf === null || qBuf === undefined) return false;
441
+ var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
442
+ var rv = await client.runScript(
443
+ EXTEND_LUA, 2,
444
+ _inflightKey(queueName), _jobKey(jobId),
445
+ jobId, String(newExpiry)
446
+ );
447
+ return rv === 1;
448
+ }
449
+
450
+ async function complete(jobId) {
451
+ await _ensureConnected();
452
+ var nowMs = Date.now();
453
+ // Read row first to act on cron-repeat metadata. Same shape as
454
+ // queue-local: SELECT row → flip status → if repeatCron, enqueue
455
+ // next firing.
456
+ var rawArr = await client.command("HGETALL", _jobKey(jobId));
457
+ var raw = _decodeHash(rawArr);
458
+ if (!raw) return false;
459
+ var queueName = raw.queueName || "unknown";
460
+
461
+ await client.runScript(
462
+ COMPLETE_LUA, 2,
463
+ _inflightKey(queueName), _jobKey(jobId),
464
+ jobId, String(nowMs)
465
+ );
466
+
467
+ if (raw.repeatCron) {
468
+ try {
469
+ var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
470
+ var cron = scheduler.parseCron(unsealed.repeatCron);
471
+ var nextMs = scheduler.nextCronFire(
472
+ cron, new Date(nowMs), unsealed.repeatTimezone || null);
473
+ await enqueue(unsealed.queueName,
474
+ unsealed.payload ? safeJson.parse(unsealed.payload) : null,
475
+ {
476
+ availableAt: nextMs,
477
+ repeat: { cron: unsealed.repeatCron, timezone: unsealed.repeatTimezone },
478
+ priority: Number(unsealed.priority) || 0,
479
+ classification: unsealed.classification || null,
480
+ traceId: unsealed.traceId || null,
481
+ });
482
+ } catch (_e) { /* best-effort — cron resumes next tick if op fixes the issue */ }
483
+ }
484
+
485
+ // Flow propagation: walk siblings whose dependsOn includes this
486
+ // jobId (or this job's flowChildName) and bump availableAt to now
487
+ // if ALL their deps are now complete. Mirrors queue-local's
488
+ // _maybeReleaseFlowChildren, but uses a per-flow Redis SET as
489
+ // the sibling registry instead of a SQL SELECT.
490
+ if (raw.flowId) {
491
+ try {
492
+ await _maybeReleaseFlowChildren(raw.flowId, jobId, raw.flowChildName || null, nowMs);
493
+ } catch (_e) { /* best-effort — sweepExpired retries if a deps check fails */ }
494
+ // The completed job stays in the flow set so a LATER sibling
495
+ // whose dependsOn includes this job's flowChildName can still
496
+ // find a "done" sibling when its own complete() walks the flow.
497
+ // The set is purge()'d when the queue is purged or — for the
498
+ // typical case where the operator wants to reclaim flow memory
499
+ // after the whole flow finishes — by an explicit operator call
500
+ // to purge() OR by letting `_maybeReleaseFlowChildren` reach a
501
+ // state where every sibling has status='done' (which the
502
+ // operator can detect via b.queue.dlqList / app-level queries).
503
+ }
504
+ return true;
505
+ }
506
+
507
+ async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
508
+ var flowKey = _flowKey(flowId);
509
+ var members = await client.command("SMEMBERS", flowKey);
510
+ if (!members || members.length === 0) return;
511
+
512
+ // Pull dependsOn + status + flowChildName for every sibling in one
513
+ // pipelined batch — far cheaper than per-sibling HMGET round trips.
514
+ var siblingIds = members.map(function (m) {
515
+ return Buffer.isBuffer(m) ? m.toString("utf8") : String(m);
516
+ }).filter(function (id) { return id !== completedJobId; });
517
+ if (siblingIds.length === 0) return;
518
+
519
+ var hmgetCalls = siblingIds.map(function (sibId) {
520
+ return client.command("HMGET", _jobKey(sibId),
521
+ "dependsOn", "status", "flowChildName");
522
+ });
523
+ var results = await Promise.all(hmgetCalls);
524
+
525
+ // For each pending sibling with a dependsOn array, check if all
526
+ // deps are satisfied (the just-completed job AND any prior
527
+ // already-done sibling in the flow).
528
+ for (var i = 0; i < siblingIds.length; i++) {
529
+ var sibId = siblingIds[i];
530
+ var rv = results[i];
531
+ if (!rv || rv.length < 3) continue;
532
+ var rawDeps = rv[0] && (Buffer.isBuffer(rv[0]) ? rv[0].toString("utf8") : String(rv[0]));
533
+ var status = rv[1] && (Buffer.isBuffer(rv[1]) ? rv[1].toString("utf8") : String(rv[1]));
534
+ if (!rawDeps || status !== "pending") continue;
535
+ var deps;
536
+ try { deps = safeJson.parse(rawDeps, { maxBytes: C.BYTES.mib(1) }); }
537
+ catch (_e) { continue; }
538
+ if (!Array.isArray(deps) || deps.length === 0) continue;
539
+
540
+ // Resolve each dep against the just-completed job (id or child
541
+ // name) OR a prior-completed sibling in the flow.
542
+ var allDone = true;
543
+ for (var d = 0; d < deps.length; d++) {
544
+ var dep = deps[d];
545
+ if (dep === completedJobId) continue;
546
+ if (completedChildName && dep === completedChildName) continue;
547
+ // Look up by id first; if no hit, fall back to scanning the
548
+ // flow set for a sibling whose flowChildName matches.
549
+ var depHash = await client.command("HMGET", _jobKey(dep), "status", "flowId");
550
+ if (depHash && depHash[0]) {
551
+ var depStatus = Buffer.isBuffer(depHash[0]) ? depHash[0].toString("utf8") : String(depHash[0]);
552
+ var depFlow = depHash[1] ? (Buffer.isBuffer(depHash[1]) ? depHash[1].toString("utf8") : String(depHash[1])) : "";
553
+ if (depStatus === "done" && depFlow === flowId) continue;
554
+ }
555
+ // Scan the flow's set for a child-name match — cheap because
556
+ // a flow typically has 5-50 children, not thousands.
557
+ var matched = false;
558
+ for (var s = 0; s < siblingIds.length && !matched; s++) {
559
+ if (siblingIds[s] === sibId) continue;
560
+ var sRv = results[s];
561
+ if (!sRv || !sRv[2]) continue;
562
+ var sName = Buffer.isBuffer(sRv[2]) ? sRv[2].toString("utf8") : String(sRv[2]);
563
+ var sStatus = sRv[1] ? (Buffer.isBuffer(sRv[1]) ? sRv[1].toString("utf8") : String(sRv[1])) : "";
564
+ if (sName === dep && sStatus === "done") matched = true;
565
+ }
566
+ if (!matched) { allDone = false; break; }
567
+ }
568
+
569
+ if (allDone) {
570
+ // Read queueName so the ZADD targets the right ready zset.
571
+ var qBuf = await client.command("HGET", _jobKey(sibId), "queueName");
572
+ if (!qBuf) continue;
573
+ var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
574
+ await Promise.all([
575
+ client.command("HSET", _jobKey(sibId), "availableAt", String(nowMs)),
576
+ client.command("ZADD", _readyKey(queueName), String(nowMs), sibId),
577
+ ]);
578
+ }
579
+ }
580
+ }
581
+
582
+ async function fail(jobId, errorMessage, retryDelayMs) {
583
+ await _ensureConnected();
584
+ var nowMs = Date.now();
585
+ if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
586
+ retryDelayMs = 0;
587
+ }
588
+ var nextAvailableAt = nowMs + retryDelayMs;
589
+
590
+ var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
591
+ if (queueBuf === null || queueBuf === undefined) return false;
592
+ var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
593
+
594
+ var sealedErr = errorMessage ? vault().seal(String(errorMessage)) : "";
595
+
596
+ await client.runScript(
597
+ FAIL_LUA, 4,
598
+ _inflightKey(queueName), _readyKey(queueName), _dlqKey(queueName), _jobKey(jobId),
599
+ jobId, String(nowMs), sealedErr, String(nextAvailableAt)
600
+ );
601
+ return true;
602
+ }
603
+
604
+ async function sweepExpired() {
605
+ await _ensureConnected();
606
+ // Walk every known queue; the queues SET keeps the list current
607
+ // (enqueue SADDs the name).
608
+ var qs = await client.command("SMEMBERS", _queuesKey());
609
+ if (!qs || qs.length === 0) return 0;
610
+ var nowMs = Date.now();
611
+ var totals = await Promise.all(qs.map(function (qBuf) {
612
+ var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
613
+ return client.runScript(
614
+ SWEEP_LUA, 2,
615
+ _inflightKey(queueName), _readyKey(queueName),
616
+ String(nowMs), _jobKeyPrefix());
617
+ }));
618
+ return totals.reduce(function (acc, n) { return acc + Number(n || 0); }, 0);
619
+ }
620
+
621
+ async function size(queueName) {
622
+ await _ensureConnected();
623
+ var [r, i] = await Promise.all([
624
+ client.command("ZCARD", _readyKey(queueName)),
625
+ client.command("ZCARD", _inflightKey(queueName)),
626
+ ]);
627
+ return Number(r || 0) + Number(i || 0);
628
+ }
629
+
630
+ async function purge(queueName) {
631
+ await _ensureConnected();
632
+ // Walk the ready + inflight + dlq zsets, collect every job id,
633
+ // also clear each job from its flow set (if any), then DEL the
634
+ // job hashes + zsets + queues-membership. Flow sets are cleared
635
+ // job-by-job because a single flow can span multiple queues, and
636
+ // we only want to evict THIS queue's contributors.
637
+ var [readyMembers, inflightMembers, dlqMembers] = await Promise.all([
638
+ client.command("ZRANGE", _readyKey(queueName), "0", "-1"),
639
+ client.command("ZRANGE", _inflightKey(queueName), "0", "-1"),
640
+ client.command("ZRANGE", _dlqKey(queueName), "0", "-1"),
641
+ ]);
642
+ var allIds = [].concat(readyMembers || [], inflightMembers || [], dlqMembers || [])
643
+ .map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
644
+ // Pull the flowId for each job (if set) so we can SREM from the
645
+ // matching flow set BEFORE we DEL the job hash.
646
+ var flowIdLookups = await Promise.all(allIds.map(function (id) {
647
+ return client.command("HGET", _jobKey(id), "flowId");
648
+ }));
649
+ var flowSrems = [];
650
+ for (var fi = 0; fi < allIds.length; fi++) {
651
+ var fIdBuf = flowIdLookups[fi];
652
+ if (!fIdBuf) continue;
653
+ var fId = Buffer.isBuffer(fIdBuf) ? fIdBuf.toString("utf8") : String(fIdBuf);
654
+ if (fId) flowSrems.push(client.command("SREM", _flowKey(fId), allIds[fi]));
655
+ }
656
+ var dels = allIds.map(function (id) { return client.command("DEL", _jobKey(id)); });
657
+ var zdrops = [
658
+ client.command("DEL", _readyKey(queueName)),
659
+ client.command("DEL", _inflightKey(queueName)),
660
+ client.command("DEL", _dlqKey(queueName)),
661
+ client.command("SREM", _queuesKey(), queueName),
662
+ ];
663
+ await Promise.all(flowSrems.concat(dels, zdrops));
664
+ return allIds.length;
665
+ }
666
+
667
+ async function dlqList(queueName, opts2) {
668
+ await _ensureConnected();
669
+ opts2 = opts2 || {};
670
+ var limit = (typeof opts2.limit === "number" && opts2.limit > 0) ? opts2.limit : 100;
671
+ // Newest failures first — score is finishedAtMs, so ZREVRANGE.
672
+ var ids = await client.command(
673
+ "ZREVRANGE", _dlqKey(queueName), "0", String(limit - 1));
674
+ if (!ids || ids.length === 0) return [];
675
+ var idStrs = ids.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
676
+ var hashes = await Promise.all(idStrs.map(function (id) {
677
+ return client.command("HGETALL", _jobKey(id));
678
+ }));
679
+ var out = [];
680
+ for (var i = 0; i < idStrs.length; i++) {
681
+ var raw = _decodeHash(hashes[i]);
682
+ if (!raw) continue;
683
+ var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
684
+ out.push({
685
+ jobId: idStrs[i],
686
+ queueName: unsealed.queueName,
687
+ payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
688
+ status: unsealed.status,
689
+ enqueuedAt: Number(unsealed.enqueuedAt),
690
+ finishedAt: unsealed.finishedAt ? Number(unsealed.finishedAt) : null,
691
+ attempts: Number(unsealed.attempts),
692
+ maxAttempts: Number(unsealed.maxAttempts),
693
+ lastError: unsealed.lastError || null,
694
+ traceId: unsealed.traceId || null,
695
+ classification: unsealed.classification || null,
696
+ });
697
+ }
698
+ return out;
699
+ }
700
+
701
+ async function dlqRetry(jobId) {
702
+ await _ensureConnected();
703
+ var nowMs = Date.now();
704
+ var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
705
+ if (queueBuf === null || queueBuf === undefined) return false;
706
+ var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
707
+ var rv = await client.runScript(
708
+ DLQ_RETRY_LUA, 3,
709
+ _dlqKey(queueName), _readyKey(queueName), _jobKey(jobId),
710
+ jobId, String(nowMs)
711
+ );
712
+ return rv === 1;
713
+ }
714
+
715
+ async function dlqSize(queueName) {
716
+ await _ensureConnected();
717
+ var n = await client.command("ZCARD", _dlqKey(queueName));
718
+ return Number(n || 0);
719
+ }
720
+
721
+ async function shutdown() {
722
+ try { await client.close(); } catch (_e) { /* best effort */ }
723
+ }
724
+
725
+ return {
726
+ protocol: "redis",
727
+ enqueue: enqueue,
728
+ lease: lease,
729
+ extendLease: extendLease,
730
+ complete: complete,
731
+ fail: fail,
732
+ sweepExpired: sweepExpired,
733
+ size: size,
734
+ purge: purge,
735
+ dlqList: dlqList,
736
+ dlqRetry: dlqRetry,
737
+ dlqSize: dlqSize,
738
+ shutdown: shutdown,
739
+ // Diagnostic — exposed for tests + ops dashboards
740
+ _client: client,
741
+ _prefix: function () { return prefix; },
742
+ };
743
+ }
744
+
745
+ module.exports = { create: create };