@blamejs/core 0.7.1 → 0.7.18

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