@blamejs/core 0.14.16 → 0.14.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.
@@ -22,15 +22,50 @@
22
22
  * If `accessControl: "public"` (the default), the middleware emits
23
23
  * `Access-Control-Allow-Origin: *` so external doc tooling can fetch.
24
24
  * For internal-only docs operators set `accessControl: "same-origin"`
25
- * which omits the CORS header.
25
+ * which omits the CORS header. To allow exactly one external origin set
26
+ * `accessControl: { allowOrigin: "https://docs.example.com" }`; the
27
+ * origin is validated and echoed verbatim with `Vary: Origin`.
26
28
  */
27
29
 
28
30
  var nodeCrypto = require("node:crypto");
29
31
  var validateOpts = require("../validate-opts");
30
32
  var lazyRequire = require("../lazy-require");
33
+ var safeUrl = require("../safe-url");
31
34
  var { defineClass } = require("../framework-error");
32
35
  var OpenApiError = defineClass("OpenApiError", { alwaysPermanent: true });
33
36
 
37
+ // Validate an operator-supplied accessControl.allowOrigin and return the
38
+ // canonical `scheme://host[:port]` string for the Access-Control-Allow-
39
+ // Origin response header. CORS (Fetch Standard §3.2.1) requires a single
40
+ // concrete origin with no path / query / fragment; the empty-string and
41
+ // "*" wildcard forms are spelled separately ("same-origin" / "public").
42
+ // Parsing through safeUrl rejects header-injection bytes (CR/LF) and
43
+ // userinfo, and confirms the value is a real http(s) origin. Throws so
44
+ // the operator catches a typo'd allowOrigin at boot.
45
+ function _canonicalAllowOrigin(value, label) {
46
+ if (typeof value !== "string" || value.length === 0) {
47
+ throw new OpenApiError("openapi/bad-access-control",
48
+ label + ": accessControl.allowOrigin must be a non-empty origin string " +
49
+ "(e.g. \"https://docs.example.com\")");
50
+ }
51
+ var parsed;
52
+ try {
53
+ parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
54
+ } catch (e) {
55
+ throw new OpenApiError("openapi/bad-access-control",
56
+ label + ": accessControl.allowOrigin '" + value + "' is not a valid " +
57
+ "http(s) origin: " + ((e && e.message) || String(e)));
58
+ }
59
+ var path = parsed.pathname || "";
60
+ if ((path !== "" && path !== "/") || parsed.search || parsed.hash) {
61
+ throw new OpenApiError("openapi/bad-access-control",
62
+ label + ": accessControl.allowOrigin must be a bare origin " +
63
+ "(scheme://host[:port]) with no path / query / fragment; got '" + value + "'");
64
+ }
65
+ var port = parsed.port;
66
+ return parsed.protocol + "//" + parsed.hostname.toLowerCase() + (port ? ":" + port : "");
67
+ }
68
+
34
69
  var openapiYaml = lazyRequire(function () { return require("../openapi-yaml"); });
35
70
  var audit = lazyRequire(function () { return require("../audit"); });
36
71
 
@@ -45,7 +80,9 @@ var audit = lazyRequire(function () { return require("../audit"); });
45
80
  * else falls through. SHA3-512 ETag enables conditional 304. With
46
81
  * `accessControl: "public"` (default) emits
47
82
  * `Access-Control-Allow-Origin: *` so external doc tooling can
48
- * fetch; `same-origin` omits the CORS header for internal-only docs.
83
+ * fetch; `same-origin` omits the CORS header for internal-only docs;
84
+ * `{ allowOrigin: "https://docs.example.com" }` echoes one validated
85
+ * origin with `Vary: Origin`.
49
86
  *
50
87
  * @opts
51
88
  * {
@@ -84,6 +121,17 @@ function create(opts) {
84
121
  var cacheControl = (typeof opts.cacheControl === "string" && opts.cacheControl.length > 0)
85
122
  ? opts.cacheControl : "public, max-age=300";
86
123
  var accessControl = opts.accessControl || "public";
124
+ // Resolve the Access-Control-Allow-Origin value once at config time.
125
+ // "public" → "*"; an { allowOrigin } object → the canonical origin
126
+ // (validated, throws on a bad value); "same-origin" / anything else →
127
+ // null (no CORS header emitted).
128
+ var allowOriginHeader = null;
129
+ if (accessControl === "public") {
130
+ allowOriginHeader = "*";
131
+ } else if (accessControl && typeof accessControl === "object" &&
132
+ typeof accessControl.allowOrigin === "string") {
133
+ allowOriginHeader = _canonicalAllowOrigin(accessControl.allowOrigin, "openapiServe");
134
+ }
87
135
  var auditOn = opts.audit !== false;
88
136
 
89
137
  if (typeof pathJson !== "string" || pathJson.charAt(0) !== "/") {
@@ -123,8 +171,12 @@ function create(opts) {
123
171
  "Cache-Control": cacheControl,
124
172
  "ETag": etag,
125
173
  };
126
- if (accessControl === "public") {
127
- headers["Access-Control-Allow-Origin"] = "*";
174
+ if (allowOriginHeader !== null) {
175
+ headers["Access-Control-Allow-Origin"] = allowOriginHeader;
176
+ // A specific (non-"*") origin makes the response vary by Origin;
177
+ // advertise it so shared caches don't serve one operator's allowed
178
+ // origin to another's request (Fetch Standard §3.2.1).
179
+ if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
128
180
  }
129
181
  res.writeHead(200, headers); // HTTP 200
130
182
  res.end(body);
@@ -279,10 +279,13 @@ function _readJsonBody(req) {
279
279
  return Promise.resolve(safeJson.parse(req.body.toString("utf8"), { maxBytes: MAX }));
280
280
  }
281
281
  if (req.body && typeof req.body === "object") return Promise.resolve(req.body);
282
- return safeBuffer.boundedChunkCollector(req, MAX, ScimServerError, "middleware/scim-server/body-too-large")
283
- .then(function (buf) {
284
- return safeJson.parse(buf.toString("utf8"), { maxBytes: MAX });
285
- });
282
+ return safeBuffer.collectStream(req, {
283
+ maxBytes: MAX,
284
+ errorClass: ScimServerError,
285
+ sizeCode: "middleware/scim-server/body-too-large",
286
+ }).then(function (buf) {
287
+ return safeJson.parse(buf.toString("utf8"), { maxBytes: MAX });
288
+ });
286
289
  }
287
290
 
288
291
  function _assertSchema(body, expectedSchema) {
@@ -313,7 +313,7 @@ function fromError(err, opts2) {
313
313
 
314
314
  /**
315
315
  * @primitive b.problemDetails.respond
316
- * @signature b.problemDetails.respond(res, problem)
316
+ * @signature b.problemDetails.respond(res, problem, req?)
317
317
  * @since 0.8.84
318
318
  * @status stable
319
319
  * @related b.problemDetails.create, b.problemDetails.fromError
@@ -339,7 +339,7 @@ function fromError(err, opts2) {
339
339
  * // res.body: <JSON-stringified problem>
340
340
  * // res.statusCode: 400
341
341
  */
342
- function respond(res, problem) {
342
+ function respond(res, problem, req) {
343
343
  if (!res || typeof res !== "object" || typeof res.setHeader !== "function" ||
344
344
  typeof res.end !== "function") {
345
345
  throw new ProblemDetailsError("problem-details/bad-res",
@@ -352,8 +352,20 @@ function respond(res, problem) {
352
352
  var status = (typeof problem.status === "number" && Number.isInteger(problem.status) &&
353
353
  problem.status >= 100 && problem.status <= 599) ? problem.status : 500; // HTTP status range + default 500
354
354
  var body = JSON.stringify(problem);
355
+ // Seal the problem body when an encrypted session is active — the
356
+ // encoder is present only after a request body decrypted, so its
357
+ // envelope decrypts identically on the client. Pre-session paths
358
+ // leave req/encoder absent and keep plaintext problem+json. An
359
+ // encryption failure falls back to plaintext rather than crashing.
360
+ var contentType = "application/problem+json";
361
+ if (req && typeof req.apiEncryptEncode === "function") {
362
+ try {
363
+ body = JSON.stringify(req.apiEncryptEncode(problem));
364
+ contentType = "application/json";
365
+ } catch (_e) { /* keep plaintext problem+json */ }
366
+ }
355
367
  res.statusCode = status;
356
- res.setHeader("Content-Type", "application/problem+json");
368
+ res.setHeader("Content-Type", contentType);
357
369
  res.setHeader("Cache-Control", "no-store");
358
370
  res.end(body);
359
371
  }
@@ -28,6 +28,18 @@
28
28
  * before INSERT; lease unseals the leased rows before returning to
29
29
  * the caller. cluster-storage's RETURNING clause hands back sealed
30
30
  * blobs which we run through cryptoField.unsealRow explicitly.
31
+ *
32
+ * Bring-your-own database: the local backend defaults to the
33
+ * framework's main DB (single-node) / external-db (cluster) via
34
+ * cluster-storage, and to the table "_blamejs_jobs". An operator can
35
+ * point the backend at their own store, table, and schema through the
36
+ * protocol config — see create(config). The physical table reference
37
+ * is composed through b.safeSql identifier quoting (never raw string
38
+ * interpolation), so an operator-supplied table/schema cannot smuggle
39
+ * SQL through the identifier slot (SQL identifier injection, CWE-89).
40
+ * Sealing stays keyed off the logical column map registered for
41
+ * "_blamejs_jobs", so payload + lastError remain sealed regardless of
42
+ * which physical table the rows land in.
31
43
  */
32
44
  var cluster = require("./cluster");
33
45
  var clusterStorage = require("./cluster-storage");
@@ -37,11 +49,22 @@ var cryptoField = require("./crypto-field");
37
49
  var lazyRequire = require("./lazy-require");
38
50
  var numericBounds = require("./numeric-bounds");
39
51
  var safeJson = require("./safe-json");
52
+ var safeSql = require("./safe-sql");
40
53
  var scheduler = require("./scheduler");
41
54
  var { QueueError } = require("./framework-error");
42
55
 
43
56
  var _err = QueueError.factory;
44
57
 
58
+ // Logical table name the field-crypto schema is keyed on. This is the
59
+ // COLUMN→seal map registered in db.js's FRAMEWORK_SCHEMA, NOT the
60
+ // physical table the SQL writes to. An operator who points the backend
61
+ // at their own table still seals payload + lastError through this map,
62
+ // so a bring-your-own table inherits the same at-rest protection.
63
+ var SEAL_TABLE = "_blamejs_jobs";
64
+
65
+ // Default physical table for the local backend.
66
+ var DEFAULT_TABLE = "_blamejs_jobs";
67
+
45
68
  // vault is lazy-required because some flows (sealed lastError) only
46
69
  // touch it on retry-with-error paths, and the import order
47
70
  // (queue-local → vault → db → audit → cluster) tolerates the late bind.
@@ -84,7 +107,7 @@ function _shapeLeasedRow(raw) {
84
107
  // raw is a row coming back from RETURNING — payload is sealed if
85
108
  // present. Run through cryptoField's unseal pipeline so the caller
86
109
  // gets cleartext.
87
- var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
110
+ var unsealed = cryptoField.unsealRow(SEAL_TABLE, raw);
88
111
  return {
89
112
  jobId: unsealed._id,
90
113
  queueName: unsealed.queueName,
@@ -102,9 +125,78 @@ function _shapeLeasedRow(raw) {
102
125
  };
103
126
  }
104
127
 
105
- function create(_config) {
106
- // No protocol-level config uses cluster-storage which dispatches
107
- // to the framework's main DB (single-node) or external-db (cluster).
128
+ // Validate a bring-your-own store handle at config time. It must expose
129
+ // the execute / executeOne / executeAll trio that cluster-storage does,
130
+ // since every method here dispatches through that surface.
131
+ var _REQUIRED_STORE_METHODS = ["execute", "executeOne", "executeAll"];
132
+ function _resolveStore(handle) {
133
+ if (handle === undefined || handle === null) return clusterStorage;
134
+ if (typeof handle !== "object") {
135
+ throw _err("INVALID_DB_HANDLE",
136
+ "queue local config.db must be a storage handle exposing execute/executeOne/executeAll, got " +
137
+ typeof handle, true);
138
+ }
139
+ for (var i = 0; i < _REQUIRED_STORE_METHODS.length; i++) {
140
+ var m = _REQUIRED_STORE_METHODS[i];
141
+ if (typeof handle[m] !== "function") {
142
+ throw _err("INVALID_DB_HANDLE",
143
+ "queue local config.db is missing required method '" + m + "()'", true);
144
+ }
145
+ }
146
+ return handle;
147
+ }
148
+
149
+ // Compose the physical table reference from config.table + config.schema,
150
+ // quoting each identifier through b.safeSql so an operator-supplied name
151
+ // cannot interpolate SQL through the identifier slot (CWE-89). Returns
152
+ // the bare default name unquoted when no custom table/schema is given so
153
+ // the framework's cluster-mode table rewrite (resolveTables) still fires
154
+ // on the default jobs table; any custom name is fully validated + quoted.
155
+ function _resolveTableRef(config) {
156
+ var table = config.table !== undefined && config.table !== null
157
+ ? config.table : DEFAULT_TABLE;
158
+ if (typeof table !== "string") {
159
+ throw _err("INVALID_TABLE",
160
+ "queue local config.table must be a string identifier, got " + typeof table, true);
161
+ }
162
+ var schema = config.schema;
163
+ if (schema !== undefined && schema !== null && typeof schema !== "string") {
164
+ throw _err("INVALID_SCHEMA",
165
+ "queue local config.schema must be a string identifier, got " + typeof schema, true);
166
+ }
167
+ var usingDefault = (table === DEFAULT_TABLE) &&
168
+ (schema === undefined || schema === null);
169
+ if (usingDefault) {
170
+ // Byte-identical default SQL — unquoted bare name so cluster-mode
171
+ // resolveTables continues to recognize and rewrite the jobs table.
172
+ return DEFAULT_TABLE;
173
+ }
174
+ // Any custom table/schema is validated + dialect-quoted. validateIdentifier
175
+ // / quoteQualified THROW (SafeSqlError) on a bad identifier; surface that
176
+ // as the queue's config-time error so the operator catches the typo at
177
+ // boot rather than on first enqueue.
178
+ try {
179
+ if (schema !== undefined && schema !== null && schema !== "") {
180
+ return safeSql.quoteQualified([schema, table]);
181
+ }
182
+ return safeSql.quoteIdentifier(table);
183
+ } catch (e) {
184
+ throw _err("INVALID_TABLE",
185
+ "queue local table/schema failed identifier validation: " + e.message, true);
186
+ }
187
+ }
188
+
189
+ function create(config) {
190
+ config = config || {};
191
+ // Bring-your-own store + table. Defaults preserve the prior behavior
192
+ // exactly: cluster-storage dispatch to the framework's main DB
193
+ // (single-node) / external-db (cluster), table "_blamejs_jobs".
194
+ var store = _resolveStore(config.db);
195
+ // qTable holds the physical table reference already validated +
196
+ // dialect-quoted by _resolveTableRef (via safeSql.quoteIdentifier /
197
+ // quoteQualified, or the framework's bare default name). The `q` prefix
198
+ // marks it as a safe-to-interpolate identifier so it is never re-quoted.
199
+ var qTable = _resolveTableRef(config);
108
200
 
109
201
  async function enqueue(queueName, payload, opts) {
110
202
  cluster.requireLeader();
@@ -171,11 +263,11 @@ function create(_config) {
171
263
  flowChildName: flowChildName,
172
264
  dependsOn: dependsOn,
173
265
  };
174
- var sealed = cryptoField.sealRow("_blamejs_jobs", row);
266
+ var sealed = cryptoField.sealRow(SEAL_TABLE, row);
175
267
  var values = JOB_COLS.map(function (c) { return c in sealed ? sealed[c] : null; });
176
268
 
177
- await clusterStorage.execute(
178
- "INSERT INTO _blamejs_jobs (" + _quotedList(JOB_COLS) + ") " +
269
+ await store.execute(
270
+ "INSERT INTO " + qTable + " (" + _quotedList(JOB_COLS) + ") " +
179
271
  "VALUES (" + _placeholders(JOB_COLS) + ")",
180
272
  values
181
273
  );
@@ -201,16 +293,16 @@ function create(_config) {
201
293
  // can't be picked twice). RETURNING hands back the leased columns
202
294
  // so we don't need a separate SELECT after the UPDATE.
203
295
  var sql =
204
- "UPDATE _blamejs_jobs " +
296
+ "UPDATE " + qTable + " " +
205
297
  "SET status = 'inflight', leasedAt = ?, leaseExpiresAt = ?, attempts = attempts + 1 " +
206
298
  "WHERE _id IN (" +
207
- " SELECT _id FROM _blamejs_jobs " +
299
+ " SELECT _id FROM " + qTable + " " +
208
300
  " WHERE queueName = ? AND status = 'pending' AND availableAt <= ? " +
209
301
  " ORDER BY priority DESC, availableAt ASC, enqueuedAt ASC " +
210
302
  " LIMIT ?" +
211
303
  ") " +
212
304
  "RETURNING " + _quotedList(LEASE_RETURN_COLS);
213
- var result = await clusterStorage.execute(
305
+ var result = await store.execute(
214
306
  sql,
215
307
  [nowMs, leaseExpiresAt, queueName, nowMs, maxRows]
216
308
  );
@@ -232,8 +324,8 @@ function create(_config) {
232
324
  "extendLease: additionalMs must be a positive number", true);
233
325
  }
234
326
  var newExpiry = Date.now() + additionalMs;
235
- var result = await clusterStorage.execute(
236
- "UPDATE _blamejs_jobs SET leaseExpiresAt = ? " +
327
+ var result = await store.execute(
328
+ "UPDATE " + qTable + " SET leaseExpiresAt = ? " +
237
329
  "WHERE _id = ? AND status = 'inflight'",
238
330
  [newExpiry, jobId]
239
331
  );
@@ -247,16 +339,16 @@ function create(_config) {
247
339
  // the status flip. Single SELECT + UPDATE pair under the same
248
340
  // jobId — race-free under SQLite (single-writer); cluster-storage
249
341
  // dispatches both calls to the same backend.
250
- var rowRes = await clusterStorage.execute(
342
+ var rowRes = await store.execute(
251
343
  "SELECT _id, queueName, payload, repeatCron, repeatTimezone, " +
252
344
  " flowId, flowChildName, priority, classification, traceId " +
253
- "FROM _blamejs_jobs WHERE _id = ?",
345
+ "FROM " + qTable + " WHERE _id = ?",
254
346
  [jobId]
255
347
  );
256
348
  var row = (rowRes && rowRes.rows && rowRes.rows[0]) || null;
257
349
 
258
- await clusterStorage.execute(
259
- "UPDATE _blamejs_jobs SET status = 'done', finishedAt = ?, leaseExpiresAt = NULL " +
350
+ await store.execute(
351
+ "UPDATE " + qTable + " SET status = 'done', finishedAt = ?, leaseExpiresAt = NULL " +
260
352
  "WHERE _id = ? AND status = 'inflight'",
261
353
  [nowMs, jobId]
262
354
  );
@@ -266,7 +358,7 @@ function create(_config) {
266
358
  // re-enqueue — operators investigate before the cron resumes.
267
359
  if (row && row.repeatCron) {
268
360
  try {
269
- var unsealedRow = cryptoField.unsealRow("_blamejs_jobs", row);
361
+ var unsealedRow = cryptoField.unsealRow(SEAL_TABLE, row);
270
362
  var cron = scheduler.parseCron(unsealedRow.repeatCron);
271
363
  var nextMs = scheduler.nextCronFire(cron, new Date(nowMs), unsealedRow.repeatTimezone || null);
272
364
  await enqueue(unsealedRow.queueName,
@@ -296,8 +388,8 @@ function create(_config) {
296
388
  }
297
389
 
298
390
  async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
299
- var siblingsRes = await clusterStorage.execute(
300
- "SELECT _id, dependsOn, flowChildName, status, availableAt FROM _blamejs_jobs " +
391
+ var siblingsRes = await store.execute(
392
+ "SELECT _id, dependsOn, flowChildName, status, availableAt FROM " + qTable + " " +
301
393
  "WHERE flowId = ? AND status = 'pending' AND availableAt > ?",
302
394
  [flowId, nowMs]
303
395
  );
@@ -317,16 +409,16 @@ function create(_config) {
317
409
  // Quick path: just-completed job matches by id or child name.
318
410
  if (dep === completedJobId || (completedChildName && dep === completedChildName)) continue;
319
411
  // Otherwise SELECT to confirm done.
320
- var depRes = await clusterStorage.execute(
321
- "SELECT 1 FROM _blamejs_jobs WHERE flowId = ? AND status = 'done' AND " +
412
+ var depRes = await store.execute(
413
+ "SELECT 1 FROM " + qTable + " WHERE flowId = ? AND status = 'done' AND " +
322
414
  " (_id = ? OR flowChildName = ?) LIMIT 1",
323
415
  [flowId, dep, dep]
324
416
  );
325
417
  if (!depRes || !depRes.rows || depRes.rows.length === 0) { allDone = false; break; }
326
418
  }
327
419
  if (allDone) {
328
- await clusterStorage.execute(
329
- "UPDATE _blamejs_jobs SET availableAt = ? WHERE _id = ?",
420
+ await store.execute(
421
+ "UPDATE " + qTable + " SET availableAt = ? WHERE _id = ?",
330
422
  [nowMs, sib._id]
331
423
  );
332
424
  }
@@ -345,8 +437,8 @@ function create(_config) {
345
437
  // status / availableAt / finishedAt updates per branch — same
346
438
  // semantics as the previous SELECT-then-UPDATE-in-transaction
347
439
  // path, but no cross-dialect transaction primitive needed.
348
- await clusterStorage.execute(
349
- "UPDATE _blamejs_jobs SET " +
440
+ await store.execute(
441
+ "UPDATE " + qTable + " SET " +
350
442
  " status = CASE WHEN attempts < maxAttempts THEN 'pending' ELSE 'failed' END, " +
351
443
  " lastError = ?, " +
352
444
  " leaseExpiresAt = NULL, " +
@@ -360,8 +452,8 @@ function create(_config) {
360
452
 
361
453
  async function sweepExpired() {
362
454
  cluster.requireLeader();
363
- var result = await clusterStorage.execute(
364
- "UPDATE _blamejs_jobs SET status = 'pending', leaseExpiresAt = NULL " +
455
+ var result = await store.execute(
456
+ "UPDATE " + qTable + " SET status = 'pending', leaseExpiresAt = NULL " +
365
457
  "WHERE status = 'inflight' AND leaseExpiresAt < ?",
366
458
  [Date.now()]
367
459
  );
@@ -369,8 +461,8 @@ function create(_config) {
369
461
  }
370
462
 
371
463
  async function size(queueName) {
372
- var row = await clusterStorage.executeOne(
373
- "SELECT COUNT(*) AS n FROM _blamejs_jobs " +
464
+ var row = await store.executeOne(
465
+ "SELECT COUNT(*) AS n FROM " + qTable + " " +
374
466
  "WHERE queueName = ? AND (status = 'pending' OR status = 'inflight')",
375
467
  [queueName]
376
468
  );
@@ -395,16 +487,16 @@ function create(_config) {
395
487
  }
396
488
  limit = opts.limit;
397
489
  }
398
- var rows = await clusterStorage.executeAll(
490
+ var rows = await store.executeAll(
399
491
  "SELECT _id, queueName, payload, status, enqueuedAt, finishedAt, " +
400
492
  " attempts, maxAttempts, lastError, traceId, classification " +
401
- "FROM _blamejs_jobs " +
493
+ "FROM " + qTable + " " +
402
494
  "WHERE queueName = ? AND status = 'failed' " +
403
495
  "ORDER BY finishedAt DESC LIMIT ?",
404
496
  [queueName, limit]
405
497
  );
406
498
  return rows.map(function (row) {
407
- var unsealed = cryptoField.unsealRow("_blamejs_jobs", row);
499
+ var unsealed = cryptoField.unsealRow(SEAL_TABLE, row);
408
500
  return {
409
501
  jobId: row._id,
410
502
  queueName: row.queueName,
@@ -424,8 +516,8 @@ function create(_config) {
424
516
  async function dlqRetry(jobId) {
425
517
  cluster.requireLeader();
426
518
  var nowMs = Date.now();
427
- var result = await clusterStorage.execute(
428
- "UPDATE _blamejs_jobs SET " +
519
+ var result = await store.execute(
520
+ "UPDATE " + qTable + " SET " +
429
521
  " status = 'pending', " +
430
522
  " attempts = 0, " +
431
523
  " availableAt = ?, " +
@@ -440,8 +532,8 @@ function create(_config) {
440
532
  }
441
533
 
442
534
  async function dlqSize(queueName) {
443
- var row = await clusterStorage.executeOne(
444
- "SELECT COUNT(*) AS n FROM _blamejs_jobs " +
535
+ var row = await store.executeOne(
536
+ "SELECT COUNT(*) AS n FROM " + qTable + " " +
445
537
  "WHERE queueName = ? AND status = 'failed'",
446
538
  [queueName]
447
539
  );
@@ -450,13 +542,30 @@ function create(_config) {
450
542
 
451
543
  async function purge(queueName) {
452
544
  cluster.requireLeader();
453
- var result = await clusterStorage.execute(
454
- "DELETE FROM _blamejs_jobs WHERE queueName = ?",
545
+ var result = await store.execute(
546
+ "DELETE FROM " + qTable + " WHERE queueName = ?",
455
547
  [queueName]
456
548
  );
457
549
  return result.rowCount || 0;
458
550
  }
459
551
 
552
+ // patchFlowDeps — the second pass of enqueueFlow. Writes the resolved
553
+ // dependsOn jobIds and parks availableAt at MAX_SAFE_INTEGER for a flow
554
+ // child that has dependencies. Lives on the backend (not in queue.js)
555
+ // so it targets THIS backend's configured store + table — a
556
+ // bring-your-own table receives the flow graph the same way the
557
+ // first-pass enqueue did, instead of the dispatcher writing to the
558
+ // default jobs table behind the backend's back. depIds is serialized
559
+ // to JSON for the dependsOn column.
560
+ async function patchFlowDeps(jobId, depIds) {
561
+ cluster.requireLeader();
562
+ var result = await store.execute(
563
+ "UPDATE " + qTable + " SET dependsOn = ?, availableAt = ? WHERE _id = ?",
564
+ [JSON.stringify(depIds), FLOW_BLOCKED_AVAILABLE_AT, jobId]
565
+ );
566
+ return (result.rowCount || 0) > 0;
567
+ }
568
+
460
569
  return {
461
570
  protocol: "local",
462
571
  enqueue: enqueue,
@@ -470,6 +579,7 @@ function create(_config) {
470
579
  dlqList: dlqList,
471
580
  dlqRetry: dlqRetry,
472
581
  dlqSize: dlqSize,
582
+ patchFlowDeps: patchFlowDeps,
473
583
  };
474
584
  }
475
585
 
package/lib/queue.js CHANGED
@@ -13,7 +13,9 @@
13
13
  * backend declares a `protocol` plus protocol-specific options. The
14
14
  * built-in `local` protocol is SQLite-backed (rows live in the
15
15
  * framework's main DB so persistence survives crashes / restarts
16
- * without external infrastructure). `redis` and `sqs` ship; `amqp`
16
+ * without external infrastructure), and can be pointed at an
17
+ * operator's own database handle, table, and schema via the `local`
18
+ * config (`db` / `table` / `schema`). `redis` and `sqs` ship; `amqp`
17
19
  * and `nats` are listed as deferred and surface a clear error if
18
20
  * selected.
19
21
  *
@@ -54,7 +56,6 @@
54
56
  * Durable, pluggable job queue with priority-aware leasing, retry + deterministic backoff, graceful shutdown, parent/child flows, and a dead-letter surface for jobs that exhaust their retries.
55
57
  */
56
58
  var C = require("./constants");
57
- var clusterStorage = require("./cluster-storage");
58
59
  var bCrypto = require("./crypto");
59
60
  var lazyRequire = require("./lazy-require");
60
61
  var { boot } = require("./log");
@@ -107,13 +108,29 @@ var sweepTimer = null;
107
108
  * Throws when `opts.backends` is missing — operators catch the typo
108
109
  * at boot rather than discovering it on first enqueue.
109
110
  *
111
+ * The `local` protocol defaults to the framework's own database (the
112
+ * main SQLite in single-node mode, the operator-supplied external DB in
113
+ * cluster mode) and the `_blamejs_jobs` table. An operator who wants the
114
+ * queue rows to live in their own database, table, or schema supplies
115
+ * `db` / `table` / `schema` in the `local` backend config. The `db`
116
+ * handle must expose the same `execute` / `executeOne` / `executeAll`
117
+ * surface as `b.clusterStorage`; `table` / `schema` are validated as SQL
118
+ * identifiers and quoted through `b.safeSql` (an identifier that isn't a
119
+ * safe name is refused at `init` time, not interpolated into SQL).
120
+ * Sealed columns (`payload`, `lastError`) stay sealed regardless of
121
+ * where the rows land.
122
+ *
110
123
  * @opts
111
124
  * backends: {
112
125
  * [name: string]: {
113
126
  * protocol: "local" | "redis" | "sqs",
114
127
  * breaker?: { ... }, // see b.retry.CircuitBreaker opts
115
128
  * retry?: { ... }, // see b.retry.withRetry opts
116
- * // ...protocol-specific opts (e.g. redis url, sqs queueUrl)
129
+ * // local protocol — bring-your-own database (all optional):
130
+ * db?: object, // store handle (execute/executeOne/executeAll); default cluster-storage
131
+ * table?: string, // table name (validated + quoted); default "_blamejs_jobs"
132
+ * schema?: string, // schema/namespace qualifier (validated + quoted)
133
+ * // ...other protocol-specific opts (e.g. redis url, sqs queueUrl)
117
134
  * },
118
135
  * },
119
136
  * defaultBackend?: string, // name to use when enqueue/consume omit { backend }
@@ -122,11 +139,12 @@ var sweepTimer = null;
122
139
  * b.queue.init({
123
140
  * backends: {
124
141
  * primary: { protocol: "local" },
142
+ * app: { protocol: "local", table: "app_jobs", schema: "work" },
125
143
  * },
126
144
  * defaultBackend: "primary",
127
145
  * });
128
146
  * b.queue.listBackends();
129
- * // → [{ name: "primary", protocol: "local", breakerState: "closed" }]
147
+ * // → [{ name: "primary", protocol: "local", breakerState: "closed" }, ...]
130
148
  */
131
149
  function init(opts) {
132
150
  if (initialized) return;
@@ -183,6 +201,7 @@ function init(opts) {
183
201
  dlqList: raw.dlqList ? wrapWithRetry(raw.dlqList) : null,
184
202
  dlqRetry: raw.dlqRetry ? wrapWithRetry(raw.dlqRetry) : null,
185
203
  dlqSize: raw.dlqSize ? wrapWithRetry(raw.dlqSize) : null,
204
+ patchFlowDeps: raw.patchFlowDeps ? wrapWithRetry(raw.patchFlowDeps) : null,
186
205
  };
187
206
  });
188
207
 
@@ -897,6 +916,17 @@ function enqueueFlow(spec) {
897
916
 
898
917
  var flowId = "flow-" + bCrypto.generateToken(C.BYTES.bytes(8));
899
918
 
919
+ // Resolve the backend up front so the second-pass dependsOn patch
920
+ // targets the SAME backend (and its configured store + table) that the
921
+ // first-pass enqueue wrote to. A backend pointed at a bring-your-own
922
+ // table must receive the flow graph through its own writer, not a
923
+ // dispatcher-level write to the default jobs table.
924
+ var flowBackend = _backendFor(spec);
925
+ if (typeof flowBackend.patchFlowDeps !== "function") {
926
+ return Promise.reject(_err("FLOW_UNSUPPORTED",
927
+ "queue backend '" + flowBackend.name + "' does not support enqueueFlow", true));
928
+ }
929
+
900
930
  return observability.tap("queue.enqueueFlow",
901
931
  { queueName: spec.queueName, flowId: flowId, childCount: spec.children.length },
902
932
  async function () {
@@ -910,6 +940,7 @@ function enqueueFlow(spec) {
910
940
  var ch = spec.children[p];
911
941
  // Hold off setting dependsOn until we know all sibling jobIds.
912
942
  var enqOpts = {
943
+ backend: flowBackend.name,
913
944
  flowId: flowId,
914
945
  flowChildName: ch.name,
915
946
  priority: ch.priority || 0,
@@ -917,9 +948,9 @@ function enqueueFlow(spec) {
917
948
  traceId: ch.traceId || null,
918
949
  maxAttempts: ch.maxAttempts,
919
950
  // dependsOn intentionally omitted on first pass — will be patched
920
- // in via direct UPDATE after all jobIds are known. This means
921
- // root children (no deps) are immediately leaseable; deps-bearing
922
- // children get patched to MAX_SAFE_INTEGER via second pass.
951
+ // in via the backend's patchFlowDeps after all jobIds are known.
952
+ // Root children (no deps) are immediately leaseable; deps-bearing
953
+ // children get parked at MAX_SAFE_INTEGER via the second pass.
923
954
  };
924
955
  var result = await enqueue(spec.queueName, ch.payload, enqOpts);
925
956
  nameToJobId[ch.name] = result.jobId;
@@ -927,14 +958,13 @@ function enqueueFlow(spec) {
927
958
  }
928
959
  // Second pass: write dependsOn (translated to jobIds) for children
929
960
  // that need it, and parking-lot their availableAt to MAX_SAFE_INTEGER.
961
+ // Routed through the backend's writer so it lands in the backend's
962
+ // configured store + table (bring-your-own DB safe).
930
963
  for (var q = 0; q < jobs.length; q++) {
931
964
  var j = jobs[q];
932
965
  if (j.dependsOn.length === 0) continue;
933
966
  var depIds = j.dependsOn.map(function (n2) { return nameToJobId[n2]; });
934
- await clusterStorage.execute(
935
- "UPDATE _blamejs_jobs SET dependsOn = ?, availableAt = ? WHERE _id = ?",
936
- [JSON.stringify(depIds), Number.MAX_SAFE_INTEGER, j.jobId]
937
- );
967
+ await flowBackend.patchFlowDeps(j.jobId, depIds);
938
968
  }
939
969
  _emit("system.queue.flow.enqueue", {
940
970
  metadata: {