@blamejs/core 0.4.18 → 0.4.20

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - **0.4.19** (2026-04-30) — b.router: schema-validated routes + OpenAPI gen
12
+ - **0.4.18** (2026-04-30) — cookieJar forensic-test strengthening (real crypto, replay, nonce)
11
13
  - **0.4.17** (2026-04-30) — b.httpClient.cookieJar (encrypted) + wiki catch-up sweep
12
14
  - **0.4.16** (2026-04-30) — b.httpClient: interceptors + progress events
13
15
  - **0.4.15** (2026-04-30) — b.httpClient: redirect-following + outbound multipart
package/lib/db.js CHANGED
@@ -350,9 +350,13 @@ var FRAMEWORK_SCHEMA = [
350
350
  finishedAt: "INTEGER",
351
351
  traceId: "TEXT",
352
352
  classification: "TEXT",
353
+ priority: "INTEGER NOT NULL DEFAULT 0",
353
354
  },
354
355
  indexes: [
355
356
  { 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
+ { name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
356
360
  "leaseExpiresAt",
357
361
  "finishedAt",
358
362
  ],
@@ -476,10 +476,12 @@ function _jobsDDL(dialect) {
476
476
  " lastError TEXT," +
477
477
  " finishedAt " + t.INT + "," +
478
478
  " traceId TEXT," +
479
- " classification TEXT" +
479
+ " classification TEXT," +
480
+ " priority " + t.INT + " NOT NULL DEFAULT 0" +
480
481
  ")",
481
482
  indexes: [
482
483
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_lease ON " + name + " (queueName, status, availableAt)",
484
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_priority ON " + name + " (queueName, status, priority, availableAt)",
483
485
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_leaseExpiresAt ON " + name + " (leaseExpiresAt)",
484
486
  "CREATE INDEX IF NOT EXISTS idx_" + name + "_finishedAt ON " + name + " (finishedAt)",
485
487
  ],
@@ -50,7 +50,7 @@ var JOB_COLS = [
50
50
  "_id", "queueName", "payload", "status",
51
51
  "enqueuedAt", "availableAt", "leasedAt", "leaseExpiresAt",
52
52
  "attempts", "maxAttempts", "lastError", "finishedAt",
53
- "traceId", "classification",
53
+ "traceId", "classification", "priority",
54
54
  ];
55
55
 
56
56
  // Columns returned by lease() / used by RETURNING. Subset of JOB_COLS
@@ -97,6 +97,8 @@ function create(_config) {
97
97
  var nowMs = Date.now();
98
98
  var availableAt = nowMs + (opts.delaySeconds ? opts.delaySeconds * 1000 : 0);
99
99
 
100
+ var priority = (typeof opts.priority === "number" && isFinite(opts.priority))
101
+ ? Math.floor(opts.priority) : 0;
100
102
  var row = {
101
103
  _id: generateToken(16),
102
104
  queueName: queueName,
@@ -112,6 +114,7 @@ function create(_config) {
112
114
  finishedAt: null,
113
115
  traceId: opts.traceId || null,
114
116
  classification: opts.classification || null,
117
+ priority: priority,
115
118
  };
116
119
  var sealed = cryptoField.sealRow("_blamejs_jobs", row);
117
120
  var values = JOB_COLS.map(function (c) { return c in sealed ? sealed[c] : null; });
@@ -148,7 +151,7 @@ function create(_config) {
148
151
  "WHERE _id IN (" +
149
152
  " SELECT _id FROM _blamejs_jobs " +
150
153
  " WHERE queueName = ? AND status = 'pending' AND availableAt <= ? " +
151
- " ORDER BY availableAt ASC, enqueuedAt ASC " +
154
+ " ORDER BY priority DESC, availableAt ASC, enqueuedAt ASC " +
152
155
  " LIMIT ?" +
153
156
  ") " +
154
157
  "RETURNING " + _quotedList(LEASE_RETURN_COLS);
package/lib/queue.js CHANGED
@@ -177,6 +177,42 @@ function consume(queueName, handler, opts) {
177
177
  var pollIntervalMs = opts.pollIntervalMs || 1000;
178
178
  var fastPollMs = opts.fastPollMs || 50;
179
179
 
180
+ // Rate-limit: { max, perSeconds } caps how many handler INVOCATIONS
181
+ // start within any rolling perSeconds window. Token-bucket-style
182
+ // accounting keeps it cheap (just a sliding deque of timestamps).
183
+ var rateLimit = null;
184
+ if (opts.rateLimit) {
185
+ if (!opts.rateLimit.max || !opts.rateLimit.perSeconds ||
186
+ typeof opts.rateLimit.max !== "number" ||
187
+ typeof opts.rateLimit.perSeconds !== "number") {
188
+ throw _err("BAD_RATE_LIMIT",
189
+ "consume({ rateLimit }): expected { max: number, perSeconds: number }, got " +
190
+ JSON.stringify(opts.rateLimit), true);
191
+ }
192
+ rateLimit = {
193
+ max: opts.rateLimit.max,
194
+ windowMs: opts.rateLimit.perSeconds * 1000,
195
+ timestamps: [],
196
+ };
197
+ }
198
+ function _rateLimitWaitMs() {
199
+ if (!rateLimit) return 0;
200
+ var now = Date.now();
201
+ var cutoff = now - rateLimit.windowMs;
202
+ while (rateLimit.timestamps.length > 0 && rateLimit.timestamps[0] <= cutoff) {
203
+ rateLimit.timestamps.shift();
204
+ }
205
+ if (rateLimit.timestamps.length < rateLimit.max) return 0;
206
+ return rateLimit.timestamps[0] + rateLimit.windowMs - now + 1;
207
+ }
208
+ function _rateLimitConsume() {
209
+ if (rateLimit) rateLimit.timestamps.push(Date.now());
210
+ }
211
+
212
+ // Progress audit-emit rate-limit — protect the audit chain from a
213
+ // chatty handler that calls progress() every loop iteration.
214
+ var PROGRESS_MIN_INTERVAL_MS = 250;
215
+
180
216
  // Each consumer has its own AbortController so cancel() unblocks any
181
217
  // in-flight poll-sleep immediately rather than waiting up to
182
218
  // pollIntervalMs (default 1s) for the next while-loop iteration.
@@ -209,6 +245,18 @@ function consume(queueName, handler, opts) {
209
245
  await _pollSleep(fastPollMs);
210
246
  continue;
211
247
  }
248
+ // If rate-limited and we'd exceed the budget, sleep until the
249
+ // next slot opens. We lease at most `max - currentTokens` jobs to
250
+ // stay under the cap.
251
+ if (rateLimit) {
252
+ var wait = _rateLimitWaitMs();
253
+ if (wait > 0) {
254
+ await _pollSleep(Math.min(wait, pollIntervalMs));
255
+ continue;
256
+ }
257
+ var remainingTokens = rateLimit.max - rateLimit.timestamps.length;
258
+ if (remainingTokens < slots) slots = Math.max(1, remainingTokens);
259
+ }
212
260
  var jobs;
213
261
  try { jobs = await b.lease(queueName, leaseDurationMs, slots); }
214
262
  catch {
@@ -227,9 +275,17 @@ function consume(queueName, handler, opts) {
227
275
  _emit("system.queue.consume.start", {
228
276
  metadata: { queue: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
229
277
  });
278
+ // Consume a rate-limit slot at handler-start so the budget
279
+ // tracks invocation rate, not lease rate (a single lease that
280
+ // splits work across many sub-units doesn't double-count).
281
+ _rateLimitConsume();
282
+
230
283
  // Handler context — second arg to handler. Carries
231
- // ctx.extendLease(ms) for long-running handlers that need to
232
- // bump their own lease before the sweeper reclaims the job.
284
+ // ctx.extendLease(ms) for long-running handlers and
285
+ // ctx.progress(0..100) for surfacing job progress to the
286
+ // audit chain (rate-limited so chatty handlers don't drown it).
287
+ var lastProgressEmitAt = 0;
288
+ var lastProgressValue = -1;
233
289
  var ctx = {
234
290
  extendLease: function (additionalMs) {
235
291
  if (typeof b.extendLease !== "function") {
@@ -246,6 +302,25 @@ function consume(queueName, handler, opts) {
246
302
  return ok;
247
303
  });
248
304
  },
305
+ progress: function (pct) {
306
+ if (typeof pct !== "number" || !isFinite(pct)) return;
307
+ var clamped = Math.max(0, Math.min(100, Math.floor(pct)));
308
+ var now = Date.now();
309
+ // Always emit 0 and 100 (start/done markers); throttle the rest.
310
+ var isMarker = clamped === 0 || clamped === 100;
311
+ if (!isMarker && (now - lastProgressEmitAt) < PROGRESS_MIN_INTERVAL_MS) return;
312
+ if (clamped === lastProgressValue && !isMarker) return;
313
+ lastProgressEmitAt = now;
314
+ lastProgressValue = clamped;
315
+ observability.event("queue.progress", clamped, { queueName: queueName });
316
+ _emit("system.queue.progress", {
317
+ metadata: {
318
+ queue: queueName, backend: b.name, jobId: job.jobId,
319
+ attempt: job.attempts, traceId: job.traceId,
320
+ percent: clamped,
321
+ },
322
+ });
323
+ },
249
324
  };
250
325
  observability.tap("queue.consume",
251
326
  { queueName: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts },
package/lib/router.js CHANGED
@@ -30,6 +30,114 @@ var { boot } = require("./log");
30
30
 
31
31
  var log = boot("router");
32
32
 
33
+ // ---- Schema-spec helpers (route-level body/query/params validation) ----
34
+
35
+ var ALLOWED_SPEC_KEYS = [
36
+ "body", "query", "params", "response",
37
+ "bodyJsonSchema", "queryJsonSchema", "paramsJsonSchema", "responseJsonSchema",
38
+ "description", "summary", "tags", "validateResponse",
39
+ ];
40
+
41
+ function _validateRouteSpec(spec, method, pattern) {
42
+ var keys = Object.keys(spec);
43
+ for (var i = 0; i < keys.length; i++) {
44
+ if (ALLOWED_SPEC_KEYS.indexOf(keys[i]) === -1) {
45
+ throw new Error("router." + method.toLowerCase() + "(" + pattern +
46
+ "): unknown spec key '" + keys[i] + "'. Allowed: " +
47
+ ALLOWED_SPEC_KEYS.slice().sort().join(", "));
48
+ }
49
+ }
50
+ function _checkSchema(name) {
51
+ var s = spec[name];
52
+ if (s === undefined) return;
53
+ if (!s || typeof s !== "object" || typeof s.safeParse !== "function") {
54
+ throw new Error("router." + method.toLowerCase() + "(" + pattern +
55
+ "): spec." + name + " must be a b.safeSchema-shaped schema (with safeParse)");
56
+ }
57
+ }
58
+ _checkSchema("body");
59
+ _checkSchema("query");
60
+ _checkSchema("params");
61
+ _checkSchema("response");
62
+ if (spec.tags !== undefined) {
63
+ if (!Array.isArray(spec.tags) || !spec.tags.every(function (t) { return typeof t === "string"; })) {
64
+ throw new Error("router." + method.toLowerCase() + "(" + pattern +
65
+ "): spec.tags must be an array of strings");
66
+ }
67
+ }
68
+ }
69
+
70
+ function _writeValidationError(res, where, errors) {
71
+ if (res.writableEnded || res.headersSent) return;
72
+ var body = JSON.stringify({
73
+ error: "validation",
74
+ where: where,
75
+ issues: errors,
76
+ });
77
+ res.writeHead(400, {
78
+ "Content-Type": "application/json; charset=utf-8",
79
+ "Content-Length": Buffer.byteLength(body),
80
+ });
81
+ res.end(body);
82
+ }
83
+
84
+ function _makeSchemaValidator(spec) {
85
+ // 3-arg signature → router treats as middleware, chains via next().
86
+ return function schemaValidator(req, res, next) {
87
+ if (spec.params && req.params !== undefined) {
88
+ var pp = spec.params.safeParse(req.params);
89
+ if (!pp.ok) return _writeValidationError(res, "params", pp.errors);
90
+ req.params = pp.value;
91
+ }
92
+ if (spec.query && req.query !== undefined) {
93
+ var qq = spec.query.safeParse(req.query);
94
+ if (!qq.ok) return _writeValidationError(res, "query", qq.errors);
95
+ req.query = qq.value;
96
+ }
97
+ if (spec.body && req.body !== undefined) {
98
+ var bb = spec.body.safeParse(req.body);
99
+ if (!bb.ok) return _writeValidationError(res, "body", bb.errors);
100
+ req.body = bb.value;
101
+ }
102
+ next();
103
+ };
104
+ }
105
+
106
+ function _makeResponseValidator(spec) {
107
+ // Wraps res.json (and res.end when called with a JSON-shaped buffer)
108
+ // to validate the response body against spec.response. Mode:
109
+ // - BLAMEJS_VALIDATE_RESPONSES=throw (or per-route validateResponse: "throw")
110
+ // → throw a SafeSchemaError-shaped error; route handler's caller sees a 500.
111
+ // - BLAMEJS_VALIDATE_RESPONSES=warn (or per-route validateResponse: "warn")
112
+ // → log a warning; ship the response as-is (prod-safe).
113
+ var perRoute = spec.validateResponse;
114
+ var globalMode = process.env.BLAMEJS_VALIDATE_RESPONSES;
115
+ var mode = (perRoute === "throw" || perRoute === "warn") ? perRoute :
116
+ (globalMode === "throw" || globalMode === "warn") ? globalMode : null;
117
+ if (!mode) return function passthrough(_req, _res, next) { next(); };
118
+
119
+ return function responseValidator(req, res, next) {
120
+ var origJson = typeof res.json === "function" ? res.json.bind(res) : null;
121
+ if (origJson) {
122
+ res.json = function (value) {
123
+ var rr = spec.response.safeParse(value);
124
+ if (!rr.ok) {
125
+ if (mode === "throw") {
126
+ throw new Error("router response-validation failed for " +
127
+ (req.method + " " + req.routePattern) + ": " +
128
+ JSON.stringify(rr.errors));
129
+ }
130
+ // warn mode
131
+ log.warn("response-validation drift on " + req.method + " " + req.routePattern +
132
+ ": " + JSON.stringify(rr.errors).slice(0, 500));
133
+ }
134
+ return origJson(value);
135
+ };
136
+ }
137
+ next();
138
+ };
139
+ }
140
+
33
141
  function compilePattern(pattern) {
34
142
  var keys = [];
35
143
  var regexStr = pattern
@@ -128,24 +236,154 @@ class Router {
128
236
  this.middleware.push(fn);
129
237
  }
130
238
 
131
- get(pattern, ...handlers) {
132
- this.routes.push({ method: "GET", ...compilePattern(pattern), handlers });
239
+ // Internal: split a route registration's args into { spec, handlers }.
240
+ // The first non-pattern arg is the schema spec when it's a plain object
241
+ // (not a function); subsequent args are handler middlewares. Operators
242
+ // who never pass a spec keep the existing two-arg shape working.
243
+ _splitArgs(args) {
244
+ if (args.length > 0 && args[0] && typeof args[0] === "object" &&
245
+ !Array.isArray(args[0]) && typeof args[0] !== "function") {
246
+ return { spec: args[0], handlers: args.slice(1) };
247
+ }
248
+ return { spec: null, handlers: args };
249
+ }
250
+
251
+ _registerRoute(method, pattern, args) {
252
+ var split = this._splitArgs(args);
253
+ if (split.spec) _validateRouteSpec(split.spec, method, pattern);
254
+ var handlers = split.handlers;
255
+ if (split.spec) {
256
+ // Pre-handler validates body / query / params. Runs after the
257
+ // global middleware chain (bodyParser populates req.body before
258
+ // route dispatch) but before any route-specific handler.
259
+ handlers = [_makeSchemaValidator(split.spec)].concat(handlers);
260
+ // Response validation (dev/opt-in via env or per-route opt).
261
+ if (split.spec.response &&
262
+ (process.env.BLAMEJS_VALIDATE_RESPONSES === "throw" ||
263
+ process.env.BLAMEJS_VALIDATE_RESPONSES === "warn" ||
264
+ split.spec.validateResponse)) {
265
+ handlers = [_makeResponseValidator(split.spec)].concat(handlers);
266
+ }
267
+ }
268
+ this.routes.push(Object.assign(
269
+ { method: method, handlers: handlers, spec: split.spec || null },
270
+ compilePattern(pattern)
271
+ ));
272
+ }
273
+
274
+ get(pattern, ...args) {
275
+ this._registerRoute("GET", pattern, args);
276
+ }
277
+
278
+ post(pattern, ...args) {
279
+ this._registerRoute("POST", pattern, args);
280
+ }
281
+
282
+ put(pattern, ...args) {
283
+ this._registerRoute("PUT", pattern, args);
133
284
  }
134
285
 
135
- post(pattern, ...handlers) {
136
- this.routes.push({ method: "POST", ...compilePattern(pattern), handlers });
286
+ patch(pattern, ...args) {
287
+ this._registerRoute("PATCH", pattern, args);
137
288
  }
138
289
 
139
- put(pattern, ...handlers) {
140
- this.routes.push({ method: "PUT", ...compilePattern(pattern), handlers });
290
+ delete(pattern, ...args) {
291
+ this._registerRoute("DELETE", pattern, args);
141
292
  }
142
293
 
143
- patch(pattern, ...handlers) {
144
- this.routes.push({ method: "PATCH", ...compilePattern(pattern), handlers });
294
+ // Operator-facing introspection — returns a copy of the route table
295
+ // with each entry's method, pattern, description, and (when provided)
296
+ // operator-supplied jsonSchema bodies for OpenAPI publication.
297
+ inspectRoutes() {
298
+ return this.routes
299
+ .filter(function (r) { return typeof r.method === "string"; })
300
+ .map(function (r) {
301
+ return {
302
+ method: r.method,
303
+ pattern: r.pattern,
304
+ description: r.spec ? r.spec.description || null : null,
305
+ spec: r.spec ? {
306
+ hasBodySchema: !!r.spec.body,
307
+ hasQuerySchema: !!r.spec.query,
308
+ hasParamsSchema: !!r.spec.params,
309
+ hasResponseSchema: !!r.spec.response,
310
+ bodyJsonSchema: r.spec.bodyJsonSchema || null,
311
+ queryJsonSchema: r.spec.queryJsonSchema || null,
312
+ paramsJsonSchema: r.spec.paramsJsonSchema || null,
313
+ responseJsonSchema: r.spec.responseJsonSchema || null,
314
+ tags: Array.isArray(r.spec.tags) ? r.spec.tags.slice() : [],
315
+ summary: r.spec.summary || null,
316
+ } : null,
317
+ };
318
+ });
145
319
  }
146
320
 
147
- delete(pattern, ...handlers) {
148
- this.routes.push({ method: "DELETE", ...compilePattern(pattern), handlers });
321
+ // openapi(opts?) → minimal Swagger 3.0 document covering every
322
+ // schema-spec'd route. Body / query / params show up as parameter
323
+ // entries when the operator supplies bodyJsonSchema / etc. (a
324
+ // safeSchema → JSON Schema converter is its own primitive — operators
325
+ // who want full schema bodies in the OpenAPI doc supply the JSON
326
+ // Schema alongside the safeSchema today).
327
+ openapi(opts) {
328
+ opts = opts || {};
329
+ var info = opts.info || { title: "blamejs app", version: "0.0.0" };
330
+ var paths = {};
331
+ var routes = this.inspectRoutes();
332
+ for (var i = 0; i < routes.length; i++) {
333
+ var r = routes[i];
334
+ var openapiPath = r.pattern.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
335
+ if (!paths[openapiPath]) paths[openapiPath] = {};
336
+ var op = {
337
+ summary: r.spec ? r.spec.summary || r.description || (r.method + " " + r.pattern) :
338
+ (r.method + " " + r.pattern),
339
+ description: r.description || null,
340
+ };
341
+ if (r.spec) {
342
+ op.tags = r.spec.tags;
343
+ var params = [];
344
+ // Path params from pattern
345
+ var pathParams = (r.pattern.match(/:[a-zA-Z0-9_]+/g) || [])
346
+ .map(function (s) { return s.slice(1); });
347
+ for (var pp = 0; pp < pathParams.length; pp++) {
348
+ params.push({ name: pathParams[pp], in: "path", required: true,
349
+ schema: { type: "string" } });
350
+ }
351
+ if (r.spec.queryJsonSchema && r.spec.queryJsonSchema.properties) {
352
+ var qprops = r.spec.queryJsonSchema.properties;
353
+ var qreq = r.spec.queryJsonSchema.required || [];
354
+ var qkeys = Object.keys(qprops);
355
+ for (var qi = 0; qi < qkeys.length; qi++) {
356
+ params.push({ name: qkeys[qi], in: "query",
357
+ required: qreq.indexOf(qkeys[qi]) !== -1,
358
+ schema: qprops[qkeys[qi]] });
359
+ }
360
+ }
361
+ if (params.length > 0) op.parameters = params;
362
+ if (r.spec.bodyJsonSchema) {
363
+ op.requestBody = {
364
+ required: true,
365
+ content: { "application/json": { schema: r.spec.bodyJsonSchema } },
366
+ };
367
+ } else if (r.spec.hasBodySchema) {
368
+ // Operator validates via safeSchema but didn't supply JSON Schema.
369
+ op["x-blamejs-body-validation"] = "safe-schema (json schema not provided)";
370
+ }
371
+ if (r.spec.responseJsonSchema) {
372
+ op.responses = {
373
+ "200": {
374
+ description: "OK",
375
+ content: { "application/json": { schema: r.spec.responseJsonSchema } },
376
+ },
377
+ };
378
+ }
379
+ }
380
+ paths[openapiPath][r.method.toLowerCase()] = op;
381
+ }
382
+ return {
383
+ openapi: "3.0.3",
384
+ info: info,
385
+ paths: paths,
386
+ };
149
387
  }
150
388
 
151
389
  // ---- WebSocket route registration ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",