@blamejs/core 0.7.88 → 0.7.90

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/lib/outbox.js ADDED
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ /**
3
+ * Transactional outbox — at-least-once event publication without
4
+ * distributed transactions.
5
+ *
6
+ * Pattern: when handling a request, write the business state change
7
+ * AND an outbox row in the SAME database transaction. A separate
8
+ * publisher worker reads the outbox table, publishes events to the
9
+ * message bus, and marks each row as published. Crashes between the
10
+ * commit and the publish leave the row pending — the worker retries
11
+ * after restart, so every event is delivered at least once.
12
+ *
13
+ * var outbox = b.outbox.create({
14
+ * externalDb: b.externalDb,
15
+ * table: "outbox_events",
16
+ * publisher: async function (event) { await myBus.publish(event); },
17
+ * pollIntervalMs: C.TIME.seconds(1),
18
+ * batchSize: 100,
19
+ * retryBackoff: { initialMs: C.TIME.seconds(1),
20
+ * maxMs: C.TIME.minutes(5), factor: 2 },
21
+ * maxAttempts: 10,
22
+ * audit: true,
23
+ * });
24
+ *
25
+ * // 1. Inside the operator's transaction
26
+ * await b.externalDb.transaction(async function (xdb) {
27
+ * await xdb.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2",
28
+ * [amount, accountId]);
29
+ * await outbox.enqueue({
30
+ * topic: "account.debited",
31
+ * payload: { accountId: accountId, amount: amount },
32
+ * key: accountId,
33
+ * headers: { "trace-id": traceId },
34
+ * }, xdb);
35
+ * });
36
+ *
37
+ * // 2. Publisher worker (poll + dispatch + mark published)
38
+ * await outbox.start();
39
+ *
40
+ * // 3. Graceful shutdown
41
+ * await outbox.stop();
42
+ *
43
+ * Schema:
44
+ * outbox.declareSchema(externalDb) — runs idempotent CREATE TABLE +
45
+ * index DDL on the operator's
46
+ * backend. Operators that prefer
47
+ * to manage migrations themselves
48
+ * skip this and write the table
49
+ * in their own migration file.
50
+ *
51
+ * Picking semantics:
52
+ * - Postgres backends: SELECT ... FOR UPDATE SKIP LOCKED. Multiple
53
+ * publishers compete cooperatively without deadlocking each other.
54
+ * - SQLite (single-writer): plain SELECT inside a transaction.
55
+ *
56
+ * Failure:
57
+ * - publisher throws → row stays pending, attempts++, next_attempt_at
58
+ * advances by retry-backoff curve.
59
+ * - attempts > maxAttempts → row marked as 'dead', moved out of the
60
+ * pending pool, audit emission "outbox.dead-letter".
61
+ *
62
+ * Drop-silent posture:
63
+ * - Polling failures (DB transiently unreachable) are logged once per
64
+ * 30s and swallowed — the worker keeps polling.
65
+ * - Publisher exceptions are caught, attempts++, normal retry path.
66
+ */
67
+
68
+ var C = require("./constants");
69
+ var lazyRequire = require("./lazy-require");
70
+ var safeAsync = require("./safe-async");
71
+ var safeJson = require("./safe-json");
72
+ var safeSql = require("./safe-sql");
73
+ var validateOpts = require("./validate-opts");
74
+ var { defineClass } = require("./framework-error");
75
+
76
+ var OutboxError = defineClass("OutboxError", { alwaysPermanent: true });
77
+
78
+ var audit = lazyRequire(function () { return require("./audit"); });
79
+ var observability = lazyRequire(function () { return require("./observability"); });
80
+
81
+ var DEFAULT_POLL_MS = C.TIME.seconds(1);
82
+ var DEFAULT_BATCH_SIZE = 100; // allow:raw-byte-literal — row count, not bytes
83
+ var DEFAULT_MAX_ATTEMPTS = 10; // allow:raw-byte-literal — attempt count, not bytes
84
+ var DEFAULT_BACKOFF_INITIAL = C.TIME.seconds(1);
85
+ var DEFAULT_BACKOFF_MAX = C.TIME.minutes(5);
86
+ var DEFAULT_BACKOFF_FACTOR = 2; // allow:raw-byte-literal — multiplier, not bytes
87
+ var TOPIC_MAX_LEN = C.BYTES.bytes(255);
88
+ var KEY_MAX_LEN = C.BYTES.bytes(255);
89
+
90
+ function _validateTableName(name) {
91
+ // SQL identifier — quoteIdentifier rejects anything with embedded
92
+ // quotes, schema-qualified names valid via dot-separated parts.
93
+ return safeSql.quoteIdentifier(name);
94
+ }
95
+
96
+ function _utcNowExpr(externalDb) {
97
+ // The framework's externalDb backends wrap Postgres + SQLite. Both
98
+ // accept a parameterized timestamp via JS Date → ISO string for
99
+ // most uses, but for the next_attempt_at advance we need an absolute
100
+ // moment computed in JS land so the publisher's clock is the source
101
+ // of truth (DB clock skew is a recurring outbox bug).
102
+ return new Date();
103
+ }
104
+
105
+ function create(opts) {
106
+ validateOpts.requireObject(opts, "outbox", OutboxError);
107
+ validateOpts(opts, [
108
+ "externalDb", "table", "publisher",
109
+ "pollIntervalMs", "batchSize", "maxAttempts",
110
+ "retryBackoff", "audit", "name",
111
+ ], "outbox.create");
112
+
113
+ if (!opts.externalDb || typeof opts.externalDb.transaction !== "function") {
114
+ throw new OutboxError("outbox/bad-externaldb",
115
+ "outbox.create: externalDb must be the b.externalDb namespace (with transaction/query)");
116
+ }
117
+ validateOpts.requireNonEmptyString(opts.table,
118
+ "outbox.create: table", OutboxError, "outbox/bad-table");
119
+ var quotedTable = _validateTableName(opts.table);
120
+
121
+ if (typeof opts.publisher !== "function") {
122
+ throw new OutboxError("outbox/bad-publisher",
123
+ "outbox.create: publisher must be an async function (event) → void");
124
+ }
125
+ validateOpts.optionalPositiveFinite(opts.pollIntervalMs,
126
+ "outbox.create: pollIntervalMs", OutboxError, "outbox/bad-opts");
127
+ validateOpts.optionalPositiveFinite(opts.batchSize,
128
+ "outbox.create: batchSize", OutboxError, "outbox/bad-opts");
129
+ validateOpts.optionalPositiveFinite(opts.maxAttempts,
130
+ "outbox.create: maxAttempts", OutboxError, "outbox/bad-opts");
131
+
132
+ var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_MS;
133
+ var batchSize = opts.batchSize || DEFAULT_BATCH_SIZE;
134
+ var maxAttempts = opts.maxAttempts || DEFAULT_MAX_ATTEMPTS;
135
+ var name = opts.name || "outbox";
136
+
137
+ var backoff = opts.retryBackoff || {};
138
+ validateOpts.optionalPositiveFinite(backoff.initialMs,
139
+ "outbox.create: retryBackoff.initialMs", OutboxError, "outbox/bad-opts");
140
+ validateOpts.optionalPositiveFinite(backoff.maxMs,
141
+ "outbox.create: retryBackoff.maxMs", OutboxError, "outbox/bad-opts");
142
+ validateOpts.optionalPositiveFinite(backoff.factor,
143
+ "outbox.create: retryBackoff.factor", OutboxError, "outbox/bad-opts");
144
+ var backoffInitial = backoff.initialMs || DEFAULT_BACKOFF_INITIAL;
145
+ var backoffMax = backoff.maxMs || DEFAULT_BACKOFF_MAX;
146
+ var backoffFactor = backoff.factor || DEFAULT_BACKOFF_FACTOR;
147
+
148
+ var auditOn = opts.audit !== false;
149
+ var externalDb = opts.externalDb;
150
+ var publisher = opts.publisher;
151
+
152
+ function _backoffMs(attempts) {
153
+ var ms = backoffInitial * Math.pow(backoffFactor, Math.max(0, attempts - 1));
154
+ if (ms > backoffMax) ms = backoffMax;
155
+ return Math.floor(ms);
156
+ }
157
+
158
+ function _emitMetric(verb, n) {
159
+ try { observability().safeEvent("outbox." + verb, n || 1, {}); }
160
+ catch (_e) { /* drop-silent */ }
161
+ }
162
+ function _emitAudit(action, outcome, metadata) {
163
+ if (!auditOn) return;
164
+ try {
165
+ audit().safeEmit({
166
+ action: action,
167
+ outcome: outcome,
168
+ metadata: metadata || {},
169
+ });
170
+ } catch (_e) { /* drop-silent */ }
171
+ }
172
+
173
+ async function enqueue(event, txn) {
174
+ if (!txn || typeof txn.query !== "function") {
175
+ throw new OutboxError("outbox/bad-txn",
176
+ "outbox.enqueue: txn must be the txClient from externalDb.transaction()");
177
+ }
178
+ if (!event || typeof event !== "object") {
179
+ throw new OutboxError("outbox/bad-event",
180
+ "outbox.enqueue: event must be a non-null object");
181
+ }
182
+ if (typeof event.topic !== "string" || event.topic.length === 0 ||
183
+ event.topic.length > TOPIC_MAX_LEN) {
184
+ throw new OutboxError("outbox/bad-event",
185
+ "outbox.enqueue: event.topic must be a non-empty string ≤ " +
186
+ TOPIC_MAX_LEN + " chars");
187
+ }
188
+ if (event.payload === undefined) {
189
+ throw new OutboxError("outbox/bad-event",
190
+ "outbox.enqueue: event.payload is required (JSON-serializable)");
191
+ }
192
+ if (event.key !== undefined && event.key !== null) {
193
+ if (typeof event.key !== "string" || event.key.length > KEY_MAX_LEN) {
194
+ throw new OutboxError("outbox/bad-event",
195
+ "outbox.enqueue: event.key must be a string ≤ " + KEY_MAX_LEN + " chars");
196
+ }
197
+ }
198
+ var headers = event.headers || null;
199
+ if (headers !== null && (typeof headers !== "object" || Array.isArray(headers))) {
200
+ throw new OutboxError("outbox/bad-event",
201
+ "outbox.enqueue: event.headers must be a plain object or null");
202
+ }
203
+
204
+ var payloadJson;
205
+ var headersJson;
206
+ try {
207
+ payloadJson = safeJson.stringify(event.payload);
208
+ headersJson = headers ? safeJson.stringify(headers) : null;
209
+ } catch (e) {
210
+ throw new OutboxError("outbox/bad-event",
211
+ "outbox.enqueue: payload/headers must be JSON-serializable: " + e.message);
212
+ }
213
+
214
+ var sql = "INSERT INTO " + quotedTable +
215
+ " (topic, payload, key, headers, enqueued_at, next_attempt_at, attempts, status)" +
216
+ " VALUES ($1, $2, $3, $4, $5, $5, 0, 'pending')";
217
+ var now = _utcNowExpr(externalDb);
218
+ await txn.query(sql, [
219
+ event.topic, payloadJson, event.key || null, headersJson, now,
220
+ ]);
221
+ _emitMetric("enqueued", 1);
222
+ }
223
+
224
+ async function declareSchema(xdb) {
225
+ var target = xdb || externalDb;
226
+ var ddl =
227
+ "CREATE TABLE IF NOT EXISTS " + quotedTable + " (" +
228
+ "id BIGSERIAL PRIMARY KEY, " +
229
+ "topic VARCHAR(255) NOT NULL, " +
230
+ "payload TEXT NOT NULL, " +
231
+ "key VARCHAR(255), " +
232
+ "headers TEXT, " +
233
+ "enqueued_at TIMESTAMPTZ NOT NULL, " +
234
+ "next_attempt_at TIMESTAMPTZ NOT NULL, " +
235
+ "published_at TIMESTAMPTZ, " +
236
+ "attempts INTEGER NOT NULL DEFAULT 0, " +
237
+ "last_error TEXT, " +
238
+ "status VARCHAR(16) NOT NULL DEFAULT 'pending'" +
239
+ ")";
240
+ var idxName = _validateTableName(opts.table + "_pending_idx");
241
+ var idx =
242
+ "CREATE INDEX IF NOT EXISTS " + idxName + " ON " + quotedTable +
243
+ " (next_attempt_at) WHERE status = 'pending'";
244
+ await target.query(ddl, []);
245
+ await target.query(idx, []);
246
+ }
247
+
248
+ // ---- Publisher worker ----
249
+
250
+ var workerHandle = null;
251
+ var stopping = false;
252
+ var inFlight = null;
253
+
254
+ async function _claimBatch() {
255
+ return await externalDb.transaction(async function (xdb) {
256
+ var nowExpr = _utcNowExpr(externalDb);
257
+ var rows = await xdb.query(
258
+ "SELECT id, topic, payload, key, headers, attempts" +
259
+ " FROM " + quotedTable +
260
+ " WHERE status = 'pending' AND next_attempt_at <= $1" +
261
+ " ORDER BY next_attempt_at" +
262
+ " LIMIT $2" +
263
+ " FOR UPDATE SKIP LOCKED",
264
+ [nowExpr, batchSize]
265
+ );
266
+ if (!rows || !rows.rows || rows.rows.length === 0) return [];
267
+ var ids = rows.rows.map(function (r) { return r.id; });
268
+ // Mark as 'in-flight' so a parallel publisher won't re-claim them
269
+ // when the row lock releases (after the SELECT-for-update txn).
270
+ await xdb.query(
271
+ "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
272
+ [ids]
273
+ );
274
+ return rows.rows.map(function (r) {
275
+ return {
276
+ id: r.id,
277
+ topic: r.topic,
278
+ payload: r.payload,
279
+ key: r.key,
280
+ headers: r.headers,
281
+ attempts: r.attempts,
282
+ };
283
+ });
284
+ });
285
+ }
286
+
287
+ async function _markPublished(id) {
288
+ await externalDb.query(
289
+ "UPDATE " + quotedTable +
290
+ " SET status = 'published', published_at = $1 WHERE id = $2",
291
+ [_utcNowExpr(externalDb), id]
292
+ );
293
+ }
294
+
295
+ async function _markRetry(id, attempts, errMsg) {
296
+ var nextAt = new Date(Date.now() + _backoffMs(attempts + 1));
297
+ await externalDb.query(
298
+ "UPDATE " + quotedTable +
299
+ " SET status = 'pending', attempts = $1, last_error = $2, next_attempt_at = $3" +
300
+ " WHERE id = $4",
301
+ [attempts + 1, String(errMsg).slice(0, 1024), nextAt, id] // allow:raw-byte-literal — error-message char cap
302
+ );
303
+ }
304
+
305
+ async function _markDead(id, attempts, errMsg) {
306
+ await externalDb.query(
307
+ "UPDATE " + quotedTable +
308
+ " SET status = 'dead', attempts = $1, last_error = $2 WHERE id = $3",
309
+ [attempts + 1, String(errMsg).slice(0, 1024), id] // allow:raw-byte-literal — error-message char cap
310
+ );
311
+ _emitAudit("system.outbox.deadletter", "fail", { id: id, attempts: attempts + 1 });
312
+ _emitMetric("dead-letter", 1);
313
+ }
314
+
315
+ async function _processOnce() {
316
+ var batch = await _claimBatch();
317
+ if (batch.length === 0) return 0;
318
+ for (var i = 0; i < batch.length; i++) {
319
+ var row = batch[i];
320
+ try {
321
+ var event = {
322
+ id: row.id,
323
+ topic: row.topic,
324
+ payload: row.payload ? safeJson.parse(row.payload, { maxBytes: C.BYTES.mib(8) }) : null,
325
+ key: row.key,
326
+ headers: row.headers ? safeJson.parse(row.headers, { maxBytes: C.BYTES.mib(1) }) : null,
327
+ attempts: row.attempts,
328
+ };
329
+ await publisher(event);
330
+ await _markPublished(row.id);
331
+ _emitMetric("published", 1);
332
+ } catch (e) {
333
+ var nextAttempts = row.attempts + 1;
334
+ if (nextAttempts >= maxAttempts) {
335
+ try { await _markDead(row.id, row.attempts, (e && e.message) || String(e)); }
336
+ catch (_e) { /* drop-silent — worker keeps moving */ }
337
+ } else {
338
+ try { await _markRetry(row.id, row.attempts, (e && e.message) || String(e)); }
339
+ catch (_e) { /* drop-silent — worker keeps moving */ }
340
+ }
341
+ _emitMetric("publish-failed", 1);
342
+ }
343
+ }
344
+ return batch.length;
345
+ }
346
+
347
+ function start() {
348
+ if (workerHandle) return;
349
+ stopping = false;
350
+ workerHandle = safeAsync.repeating(async function () {
351
+ if (stopping || inFlight) return;
352
+ inFlight = _processOnce()
353
+ .catch(function () { /* drop-silent — see _processOnce */ })
354
+ .finally(function () { inFlight = null; });
355
+ }, pollIntervalMs, { name: name + "-publisher" });
356
+ _emitAudit("system.outbox.started", "ok", { name: name });
357
+ }
358
+
359
+ async function stop() {
360
+ stopping = true;
361
+ if (workerHandle) {
362
+ workerHandle.stop();
363
+ workerHandle = null;
364
+ }
365
+ if (inFlight) {
366
+ try { await inFlight; } catch (_e) { /* drop-silent */ }
367
+ }
368
+ _emitAudit("system.outbox.stopped", "ok", { name: name });
369
+ }
370
+
371
+ async function pendingCount() {
372
+ var res = await externalDb.query(
373
+ "SELECT COUNT(*) AS n FROM " + quotedTable + " WHERE status = 'pending'", []
374
+ );
375
+ return Number((res && res.rows && res.rows[0] && res.rows[0].n) || 0);
376
+ }
377
+
378
+ async function deadCount() {
379
+ var res = await externalDb.query(
380
+ "SELECT COUNT(*) AS n FROM " + quotedTable + " WHERE status = 'dead'", []
381
+ );
382
+ return Number((res && res.rows && res.rows[0] && res.rows[0].n) || 0);
383
+ }
384
+
385
+ return {
386
+ enqueue: enqueue,
387
+ declareSchema: declareSchema,
388
+ start: start,
389
+ stop: stop,
390
+ pendingCount: pendingCount,
391
+ deadCount: deadCount,
392
+ _processOnce: _processOnce, // test hook — drive a single poll deterministically
393
+ };
394
+ }
395
+
396
+ module.exports = {
397
+ create: create,
398
+ OutboxError: OutboxError,
399
+ };
@@ -37,6 +37,7 @@
37
37
 
38
38
  var C = require("../constants");
39
39
  var numericBounds = require("../numeric-bounds");
40
+ var safeBuffer = require("../safe-buffer");
40
41
  var { defineClass } = require("../framework-error");
41
42
 
42
43
  var IniSafeError = defineClass("IniSafeError", { alwaysPermanent: true });
@@ -176,7 +177,7 @@ function _parseSectionHeader(line) {
176
177
  if (parts[i].length === 0) {
177
178
  throw _err("ini/bad-section", "section name has empty segment: " + JSON.stringify(inner));
178
179
  }
179
- if (!/^[A-Za-z0-9_-]+$/.test(parts[i])) {
180
+ if (!safeBuffer.BASE64URL_RE.test(parts[i])) {
180
181
  throw _err("ini/bad-section",
181
182
  "section segment must match [A-Za-z0-9_-]+ (got " + JSON.stringify(parts[i]) + ")");
182
183
  }
@@ -190,6 +190,12 @@ function secureZero(buf) {
190
190
  // itself does NOT bound length — that's the caller's contract.
191
191
  var HEX_RE = /^[0-9a-fA-F]+$/;
192
192
 
193
+ // BASE64URL_RE matches a non-empty base64url-encoded string (RFC 4648
194
+ // §5) with NO padding. Used by JOSE primitives (JWT/JWS/JWE compact
195
+ // serialisations), DPoP jti, WebAuthn credential IDs, etc. The regex
196
+ // is length-agnostic — callers cap length per protocol contract.
197
+ var BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
198
+
193
199
  // CRLF_RE matches any control character used in HTTP-header / SMTP-
194
200
  // envelope injection attacks. Header values that contain CR or LF must
195
201
  // be rejected before serialization.
@@ -231,6 +237,7 @@ module.exports = {
231
237
  stripCrlf: stripCrlf,
232
238
  stripTrailingHspace: stripTrailingHspace,
233
239
  HEX_RE: HEX_RE,
240
+ BASE64URL_RE: BASE64URL_RE,
234
241
  CRLF_RE: CRLF_RE,
235
242
  TRAILING_HSPACE_RE: TRAILING_HSPACE_RE,
236
243
  SafeBufferError: SafeBufferError,
package/lib/webhook.js CHANGED
@@ -214,7 +214,7 @@ function _pqcSign(privateKeyPem, data) {
214
214
  return crypto.sign(data, privateKeyPem).toString("base64url");
215
215
  }
216
216
 
217
- var _BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
217
+ var _BASE64URL_RE = safeBuffer.BASE64URL_RE;
218
218
 
219
219
  function _pqcVerify(publicKeyPem, data, expectedSig) {
220
220
  if (typeof expectedSig !== "string" || expectedSig.length === 0) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.88",
3
+ "version": "0.7.90",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:5980b589-6ae2-43c3-88ad-31cb87e43594",
5
+ "serialNumber": "urn:uuid:ea6013b4-0103-48ac-8789-54d7054c2b77",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T06:50:00.974Z",
8
+ "timestamp": "2026-05-06T07:47:56.714Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.88",
22
+ "bom-ref": "@blamejs/core@0.7.90",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.88",
25
+ "version": "0.7.90",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.88",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.90",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.88",
57
+ "ref": "@blamejs/core@0.7.90",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]