@blamejs/core 0.4.20 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - **0.4.20** (2026-04-30) — b.queue + b.jobs: priority, rate-limit, progress
11
12
  - **0.4.19** (2026-04-30) — b.router: schema-validated routes + OpenAPI gen
12
13
  - **0.4.18** (2026-04-30) — cookieJar forensic-test strengthening (real crypto, replay, nonce)
13
14
  - **0.4.17** (2026-04-30) — b.httpClient.cookieJar (encrypted) + wiki catch-up sweep
package/lib/db.js CHANGED
@@ -351,12 +351,22 @@ var FRAMEWORK_SCHEMA = [
351
351
  traceId: "TEXT",
352
352
  classification: "TEXT",
353
353
  priority: "INTEGER NOT NULL DEFAULT 0",
354
+ // Repeat-in-queue: cron-shaped recurring jobs re-enqueue themselves
355
+ // after each successful completion. NULL = one-shot (no repeat).
356
+ repeatCron: "TEXT",
357
+ repeatTimezone: "TEXT",
358
+ // Flows: parent-child job graphs with dependency edges.
359
+ // flowId groups jobs in the same flow; dependsOn is a JSON array
360
+ // of jobIds this row waits for; flowChildName is the human-readable
361
+ // label inside the flow used by dependsOn resolution.
362
+ flowId: "TEXT",
363
+ flowChildName: "TEXT",
364
+ dependsOn: "TEXT",
354
365
  },
355
366
  indexes: [
356
367
  { name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
357
- // Priority lease index — pickers walk this when ORDER BY priority DESC,
358
- // availableAt ASC, enqueuedAt ASC; matches the queue.lease ordering.
359
368
  { name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
369
+ { name: "idx_jobs_flow", columns: ["flowId"] },
360
370
  "leaseExpiresAt",
361
371
  "finishedAt",
362
372
  ],
@@ -477,11 +477,17 @@ function _jobsDDL(dialect) {
477
477
  " finishedAt " + t.INT + "," +
478
478
  " traceId TEXT," +
479
479
  " classification TEXT," +
480
- " priority " + t.INT + " NOT NULL DEFAULT 0" +
480
+ " priority " + t.INT + " NOT NULL DEFAULT 0," +
481
+ " repeatCron TEXT," +
482
+ " repeatTimezone TEXT," +
483
+ " flowId TEXT," +
484
+ " flowChildName TEXT," +
485
+ " dependsOn TEXT" +
481
486
  ")",
482
487
  indexes: [
483
488
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_lease ON " + name + " (queueName, status, availableAt)",
484
489
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_priority ON " + name + " (queueName, status, priority, availableAt)",
490
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_flow ON " + name + " (flowId)",
485
491
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_leaseExpiresAt ON " + name + " (leaseExpiresAt)",
486
492
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_finishedAt ON " + name + " (finishedAt)",
487
493
  ],
@@ -51,14 +51,22 @@ var JOB_COLS = [
51
51
  "enqueuedAt", "availableAt", "leasedAt", "leaseExpiresAt",
52
52
  "attempts", "maxAttempts", "lastError", "finishedAt",
53
53
  "traceId", "classification", "priority",
54
+ "repeatCron", "repeatTimezone",
55
+ "flowId", "flowChildName", "dependsOn",
54
56
  ];
55
57
 
58
+ // Sentinel availableAt for flow children that haven't yet had their
59
+ // dependencies satisfied — far future so the lease query never picks
60
+ // them. Parent-completion sets a real availableAt when all deps complete.
61
+ var FLOW_BLOCKED_AVAILABLE_AT = Number.MAX_SAFE_INTEGER;
62
+
56
63
  // Columns returned by lease() / used by RETURNING. Subset of JOB_COLS
57
64
  // — only what callers need; fewer bytes over the wire in cluster mode.
58
65
  var LEASE_RETURN_COLS = [
59
66
  "_id", "queueName", "payload",
60
67
  "attempts", "maxAttempts", "traceId", "classification",
61
68
  "enqueuedAt", "leaseExpiresAt",
69
+ "repeatCron", "repeatTimezone", "flowId", "flowChildName",
62
70
  ];
63
71
 
64
72
  function _quotedList(cols) {
@@ -84,6 +92,10 @@ function _shapeLeasedRow(raw) {
84
92
  classification: unsealed.classification,
85
93
  enqueuedAt: Number(unsealed.enqueuedAt),
86
94
  leaseExpiresAt: Number(unsealed.leaseExpiresAt),
95
+ repeatCron: unsealed.repeatCron || null,
96
+ repeatTimezone: unsealed.repeatTimezone || null,
97
+ flowId: unsealed.flowId || null,
98
+ flowChildName: unsealed.flowChildName || null,
87
99
  };
88
100
  }
89
101
 
@@ -99,13 +111,25 @@ function create(_config) {
99
111
 
100
112
  var priority = (typeof opts.priority === "number" && isFinite(opts.priority))
101
113
  ? Math.floor(opts.priority) : 0;
114
+ var repeatCron = opts.repeat && typeof opts.repeat.cron === "string"
115
+ ? opts.repeat.cron : null;
116
+ var repeatTimezone = opts.repeat && typeof opts.repeat.timezone === "string"
117
+ ? opts.repeat.timezone : null;
118
+ var flowId = typeof opts.flowId === "string" ? opts.flowId : null;
119
+ var flowChildName = typeof opts.flowChildName === "string" ? opts.flowChildName : null;
120
+ var dependsOn = Array.isArray(opts.dependsOn) && opts.dependsOn.length > 0
121
+ ? JSON.stringify(opts.dependsOn) : null;
122
+ // Flow children with deps wait at MAX_SAFE_INTEGER until parent
123
+ // completion bumps availableAt — keeps them out of the lease index.
124
+ var effectiveAvailableAt = (dependsOn ? FLOW_BLOCKED_AVAILABLE_AT : availableAt);
125
+
102
126
  var row = {
103
127
  _id: generateToken(16),
104
128
  queueName: queueName,
105
129
  payload: payload === undefined ? null : JSON.stringify(payload),
106
130
  status: "pending",
107
131
  enqueuedAt: nowMs,
108
- availableAt: availableAt,
132
+ availableAt: effectiveAvailableAt,
109
133
  leasedAt: null,
110
134
  leaseExpiresAt: null,
111
135
  attempts: 0,
@@ -115,6 +139,11 @@ function create(_config) {
115
139
  traceId: opts.traceId || null,
116
140
  classification: opts.classification || null,
117
141
  priority: priority,
142
+ repeatCron: repeatCron,
143
+ repeatTimezone: repeatTimezone,
144
+ flowId: flowId,
145
+ flowChildName: flowChildName,
146
+ dependsOn: dependsOn,
118
147
  };
119
148
  var sealed = cryptoField.sealRow("_blamejs_jobs", row);
120
149
  var values = JOB_COLS.map(function (c) { return c in sealed ? sealed[c] : null; });
@@ -187,14 +216,94 @@ function create(_config) {
187
216
 
188
217
  async function complete(jobId) {
189
218
  cluster.requireLeader();
219
+ var nowMs = Date.now();
220
+ // Read the row first so we can act on repeat / flow metadata after
221
+ // the status flip. Single SELECT + UPDATE pair under the same
222
+ // jobId — race-free under SQLite (single-writer); cluster-storage
223
+ // dispatches both calls to the same backend.
224
+ var rowRes = await clusterStorage.execute(
225
+ "SELECT _id, queueName, payload, repeatCron, repeatTimezone, " +
226
+ " flowId, flowChildName, priority, classification, traceId " +
227
+ "FROM _blamejs_jobs WHERE _id = ?",
228
+ [jobId]
229
+ );
230
+ var row = (rowRes && rowRes.rows && rowRes.rows[0]) || null;
231
+
190
232
  await clusterStorage.execute(
191
233
  "UPDATE _blamejs_jobs SET status = 'done', finishedAt = ?, leaseExpiresAt = NULL " +
192
234
  "WHERE _id = ? AND status = 'inflight'",
193
- [Date.now(), jobId]
235
+ [nowMs, jobId]
194
236
  );
237
+
238
+ // Repeat-in-queue: cron-recurring job re-enqueues itself for the
239
+ // next firing time. Failures (which take the fail() path) don't
240
+ // re-enqueue — operators investigate before the cron resumes.
241
+ if (row && row.repeatCron) {
242
+ try {
243
+ var unsealedRow = cryptoField.unsealRow("_blamejs_jobs", row);
244
+ var scheduler = require("./scheduler");
245
+ var cron = scheduler.parseCron(unsealedRow.repeatCron);
246
+ var nextMs = scheduler.nextCronFire(cron, new Date(nowMs), unsealedRow.repeatTimezone || null);
247
+ await enqueue(unsealedRow.queueName,
248
+ unsealedRow.payload ? safeJson.parse(unsealedRow.payload) : null,
249
+ {
250
+ availableAt: nextMs,
251
+ delaySeconds: Math.max(0, Math.floor((nextMs - nowMs) / 1000)),
252
+ repeat: { cron: unsealedRow.repeatCron, timezone: unsealedRow.repeatTimezone },
253
+ priority: Number(unsealedRow.priority) || 0,
254
+ classification: unsealedRow.classification || null,
255
+ traceId: unsealedRow.traceId || null,
256
+ });
257
+ } catch (_e) { /* repeat re-enqueue best-effort — cron resumes next tick if op fixes the issue */ }
258
+ }
259
+
260
+ // Flow propagation: walk siblings whose dependsOn includes this
261
+ // jobId (or this job's flowChildName) and bump availableAt to now
262
+ // if ALL their deps are now complete.
263
+ if (row && row.flowId) {
264
+ await _maybeReleaseFlowChildren(row.flowId, jobId, row.flowChildName, nowMs);
265
+ }
195
266
  return true;
196
267
  }
197
268
 
269
+ async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
270
+ var siblingsRes = await clusterStorage.execute(
271
+ "SELECT _id, dependsOn, flowChildName, status, availableAt FROM _blamejs_jobs " +
272
+ "WHERE flowId = ? AND status = 'pending' AND availableAt > ?",
273
+ [flowId, nowMs]
274
+ );
275
+ var siblings = (siblingsRes && siblingsRes.rows) || [];
276
+ for (var i = 0; i < siblings.length; i++) {
277
+ var sib = siblings[i];
278
+ if (!sib.dependsOn) continue;
279
+ var deps;
280
+ try { deps = JSON.parse(sib.dependsOn); }
281
+ catch (_e) { continue; }
282
+ if (!Array.isArray(deps) || deps.length === 0) continue;
283
+ // Resolve which deps are satisfied. Each dep is either a jobId
284
+ // or a flowChildName; we accept both shapes against the flow.
285
+ var allDone = true;
286
+ for (var d = 0; d < deps.length; d++) {
287
+ var dep = deps[d];
288
+ // Quick path: just-completed job matches by id or child name.
289
+ if (dep === completedJobId || (completedChildName && dep === completedChildName)) continue;
290
+ // Otherwise SELECT to confirm done.
291
+ var depRes = await clusterStorage.execute(
292
+ "SELECT 1 FROM _blamejs_jobs WHERE flowId = ? AND status = 'done' AND " +
293
+ " (_id = ? OR flowChildName = ?) LIMIT 1",
294
+ [flowId, dep, dep]
295
+ );
296
+ if (!depRes || !depRes.rows || depRes.rows.length === 0) { allDone = false; break; }
297
+ }
298
+ if (allDone) {
299
+ await clusterStorage.execute(
300
+ "UPDATE _blamejs_jobs SET availableAt = ? WHERE _id = ?",
301
+ [nowMs, sib._id]
302
+ );
303
+ }
304
+ }
305
+ }
306
+
198
307
  async function fail(jobId, errorMessage, opts) {
199
308
  cluster.requireLeader();
200
309
  opts = opts || {};
package/lib/queue.js CHANGED
@@ -488,9 +488,142 @@ function _resetForTest() {
488
488
  audit.reset();
489
489
  }
490
490
 
491
+ // enqueueFlow — atomic registration of a parent-child job graph.
492
+ //
493
+ // await b.queue.enqueueFlow({
494
+ // queueName: "ingest",
495
+ // children: [
496
+ // { name: "fetch", payload: { url } },
497
+ // { name: "transform", payload: { ... }, dependsOn: ["fetch"] },
498
+ // { name: "publish", payload: { ... }, dependsOn: ["transform"] },
499
+ // ],
500
+ // });
501
+ //
502
+ // Cycle detection runs at registration (Tier-A throw). Each child enters
503
+ // the queue with availableAt = MAX_SAFE_INTEGER until parent completion
504
+ // bumps it. Returns { flowId, jobs: [{ name, jobId }, ...] }.
505
+ function enqueueFlow(spec) {
506
+ _requireInit();
507
+ if (!spec || typeof spec !== "object") {
508
+ return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires an opts object", true));
509
+ }
510
+ if (typeof spec.queueName !== "string" || !spec.queueName) {
511
+ return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires queueName", true));
512
+ }
513
+ if (!Array.isArray(spec.children) || spec.children.length === 0) {
514
+ return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires children: [...]", true));
515
+ }
516
+ // Validate each child's shape.
517
+ var byName = {};
518
+ for (var i = 0; i < spec.children.length; i++) {
519
+ var c = spec.children[i];
520
+ if (!c || typeof c !== "object") {
521
+ return Promise.reject(_err("BAD_FLOW", "children[" + i + "] must be an object", true));
522
+ }
523
+ if (typeof c.name !== "string" || !c.name) {
524
+ return Promise.reject(_err("BAD_FLOW", "children[" + i + "].name must be a non-empty string", true));
525
+ }
526
+ if (byName[c.name]) {
527
+ return Promise.reject(_err("BAD_FLOW", "duplicate child name '" + c.name + "'", true));
528
+ }
529
+ byName[c.name] = c;
530
+ if (c.dependsOn !== undefined) {
531
+ if (!Array.isArray(c.dependsOn)) {
532
+ return Promise.reject(_err("BAD_FLOW",
533
+ "children[" + i + "].dependsOn must be an array of names", true));
534
+ }
535
+ for (var di = 0; di < c.dependsOn.length; di++) {
536
+ if (typeof c.dependsOn[di] !== "string") {
537
+ return Promise.reject(_err("BAD_FLOW",
538
+ "children[" + i + "].dependsOn[" + di + "] must be a string name", true));
539
+ }
540
+ }
541
+ }
542
+ }
543
+ // Cycle detection — depth-first traversal with visited set.
544
+ function _visit(name, stack) {
545
+ if (stack.indexOf(name) !== -1) {
546
+ throw _err("FLOW_CYCLE", "flow cycle detected: " +
547
+ stack.concat([name]).join(" → "), true);
548
+ }
549
+ var child = byName[name];
550
+ if (!child || !child.dependsOn) return;
551
+ var nextStack = stack.concat([name]);
552
+ for (var k = 0; k < child.dependsOn.length; k++) {
553
+ var dep = child.dependsOn[k];
554
+ if (!byName[dep]) {
555
+ throw _err("FLOW_UNKNOWN_DEP",
556
+ "child '" + name + "' dependsOn unknown name '" + dep + "'", true);
557
+ }
558
+ _visit(dep, nextStack);
559
+ }
560
+ }
561
+ try {
562
+ var names = Object.keys(byName);
563
+ for (var n = 0; n < names.length; n++) _visit(names[n], []);
564
+ } catch (e) {
565
+ return Promise.reject(e);
566
+ }
567
+
568
+ var nodeCrypto = require("node:crypto");
569
+ var flowId = "flow-" + nodeCrypto.randomBytes(8).toString("hex");
570
+
571
+ return observability.tap("queue.enqueueFlow",
572
+ { queueName: spec.queueName, flowId: flowId, childCount: spec.children.length },
573
+ async function () {
574
+ var jobs = [];
575
+ // Two-pass insert: first pass enqueues all children with their
576
+ // names attached so the second pass can write dependsOn jobIds
577
+ // resolved by name. Children with deps land at MAX_SAFE_INTEGER
578
+ // availableAt automatically (see queue-local enqueue logic).
579
+ var nameToJobId = {};
580
+ for (var p = 0; p < spec.children.length; p++) {
581
+ var ch = spec.children[p];
582
+ // Hold off setting dependsOn until we know all sibling jobIds.
583
+ var enqOpts = {
584
+ flowId: flowId,
585
+ flowChildName: ch.name,
586
+ priority: ch.priority || 0,
587
+ classification: ch.classification || null,
588
+ traceId: ch.traceId || null,
589
+ maxAttempts: ch.maxAttempts,
590
+ // dependsOn intentionally omitted on first pass — will be patched
591
+ // in via direct UPDATE after all jobIds are known. This means
592
+ // root children (no deps) are immediately leaseable; deps-bearing
593
+ // children get patched to MAX_SAFE_INTEGER via second pass.
594
+ };
595
+ var result = await enqueue(spec.queueName, ch.payload, enqOpts);
596
+ nameToJobId[ch.name] = result.jobId;
597
+ jobs.push({ name: ch.name, jobId: result.jobId, dependsOn: ch.dependsOn || [] });
598
+ }
599
+ // Second pass: write dependsOn (translated to jobIds) for children
600
+ // that need it, and parking-lot their availableAt to MAX_SAFE_INTEGER.
601
+ var clusterStorage = require("./cluster-storage");
602
+ for (var q = 0; q < jobs.length; q++) {
603
+ var j = jobs[q];
604
+ if (j.dependsOn.length === 0) continue;
605
+ var depIds = j.dependsOn.map(function (n2) { return nameToJobId[n2]; });
606
+ await clusterStorage.execute(
607
+ "UPDATE _blamejs_jobs SET dependsOn = ?, availableAt = ? WHERE _id = ?",
608
+ [JSON.stringify(depIds), Number.MAX_SAFE_INTEGER, j.jobId]
609
+ );
610
+ }
611
+ _emit("system.queue.flow.enqueue", {
612
+ metadata: {
613
+ queue: spec.queueName,
614
+ flowId: flowId,
615
+ childCount: spec.children.length,
616
+ },
617
+ });
618
+ return { flowId: flowId, jobs: jobs.map(function (j) { return { name: j.name, jobId: j.jobId }; }) };
619
+ }
620
+ );
621
+ }
622
+
491
623
  module.exports = {
492
624
  init: init,
493
625
  enqueue: enqueue,
626
+ enqueueFlow: enqueueFlow,
494
627
  consume: consume,
495
628
  size: size,
496
629
  purge: purge,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",