@blamejs/core 0.7.89 → 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/CHANGELOG.md +2 -0
- package/index.js +2 -0
- package/lib/outbox.js +399 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.90** (2026-05-06) — `b.outbox` — transactional outbox primitive for at-least-once event publication without distributed transactions. **`b.outbox.create({ externalDb, table, publisher, ... })`** returns an outbox instance with three core operations: `enqueue(event, txn)` writes the outbox row inside the operator's transaction (using the `txClient` returned by `b.externalDb.transaction`), `start()` spins a polling publisher worker that claims rows via `SELECT ... FOR UPDATE SKIP LOCKED` (Postgres) and dispatches to the operator-supplied async `publisher(event)` callback, `stop()` gracefully shuts the worker down. Failed publishes retry with exponential backoff (`retryBackoff: { initialMs, maxMs, factor }`); rows that exceed `maxAttempts` are marked `'dead'` for operator triage and an `system.outbox.deadletter` audit event fires. Schema is operator-managed: `outbox.declareSchema(externalDb)` runs an idempotent `CREATE TABLE IF NOT EXISTS ... (id, topic, payload, key, headers, enqueued_at, next_attempt_at, published_at, attempts, last_error, status)` + a partial index on `(next_attempt_at) WHERE status = 'pending'`. `pendingCount()` / `deadCount()` expose the queue depth + DLQ depth for operator dashboards. Observability events on every state transition (`outbox.enqueued` / `outbox.published` / `outbox.publish-failed` / `outbox.dead-letter`).
|
|
12
|
+
|
|
11
13
|
- **0.7.89** (2026-05-06) — Three additive primitives bundled: TUS resumable uploads, WebAuthn Signal API, DPoP server-issued nonce challenge. **`b.middleware.tusUpload({ mountPath, store, ... })`** implements the [tus.io](https://tus.io) v1.0.0 resumable-upload protocol — POST creates uploads, HEAD reports offsets, PATCH appends chunks, DELETE terminates. Supported extensions: `creation`, `creation-with-upload`, `expiration`, `checksum`, `termination`. The `checksum` extension defaults to PQC-first algorithms (`sha3-512`, `shake256`) — operators add classical algorithms explicitly via `checksumAlgorithms`. A built-in `b.middleware.tusUpload.memoryStore({ maxSize })` ships for development; production operators implement the `{ create, head, append, setLength, terminate, purgeExpired, getBuffer }` shape against their object-store backend. Bounded chunk collection routes through `safeBuffer.boundedChunkCollector` (cap-enforced at push time, no 10-GiB pre-collect). Concatenation extension (parallel-chunk assembly) deferred — operators that need it compose against their store layer; re-open if a store-layer-only solution proves insufficient. **`b.auth.passkey.signalUnknownCredential` / `signalAllAcceptedCredentials` / `signalCurrentUserDetails`** add the W3C WebAuthn Signal API descriptor builders — when the browser implements `PublicKeyCredential.signal*`, operators emit the matching JSON descriptor to clean up stale passkeys, refresh user details, and surface revocations without forcing a re-registration. All three validate `rpId` / `userId` / `credentialId` shape (base64url) and refuse `name`/`displayName` longer than 256 chars. **`b.middleware.dpop({ requireNonce: true, nonceRotateSec? })`** implements RFC 9449 §8 server-issued DPoP-Nonce challenge — the middleware emits `DPoP-Nonce: <fresh>` on every 401 response, refuses proofs whose `nonce` claim isn't in the rolling current+previous pair, and refreshes the nonce on every successful response. The rolling-pair manager rotates without timers (lazy maybe-rotate on access); no operator nonce store needed. The `getNonce` callback path stays intact for operator-managed nonce flows.
|
|
12
14
|
|
|
13
15
|
- **0.7.88** (2026-05-06) — `b.middleware.webAppManifest` + `b.middleware.assetlinks` — two static-content middlewares for PWA + Trusted Web Activity support. **`b.middleware.webAppManifest({ name, start_url, icons, ... })`** serves the W3C Web App Manifest at `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath: true`). The framework JSON-serializes once at create() and serves with `Content-Type: application/manifest+json` per the W3C spec + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. The W3C-spec attribute set is allowlisted (name / short_name / description / start_url / scope / display / display_override / orientation / theme_color / background_color / icons / screenshots / shortcuts / categories / lang / dir / id / prefer_related_applications / related_applications) — typos throw at create. `name`, `start_url`, and at least one icon are required (W3C — installability minimum). HEAD + GET only. **`b.middleware.assetlinks({ statements })`** serves Digital Asset Links at `/.well-known/assetlinks.json` per Google's spec — used by Trusted Web Activity, Android App Links, Smart Lock for Passwords, WebAuthn for Android. Validates each statement carries `relation` (non-empty array) and `target` (object). Same Content-Type / Cache-Control / X-Content-Type-Options posture as the security.txt + manifest emitters.
|
package/index.js
CHANGED
|
@@ -213,6 +213,7 @@ var dualControl = require("./lib/dual-control");
|
|
|
213
213
|
var retention = require("./lib/retention");
|
|
214
214
|
var network = require("./lib/network");
|
|
215
215
|
var cloudEvents = require("./lib/cloud-events");
|
|
216
|
+
var outbox = require("./lib/outbox");
|
|
216
217
|
|
|
217
218
|
module.exports = {
|
|
218
219
|
crypto: crypto,
|
|
@@ -360,6 +361,7 @@ module.exports = {
|
|
|
360
361
|
retention: retention,
|
|
361
362
|
network: network,
|
|
362
363
|
cloudEvents: cloudEvents,
|
|
364
|
+
outbox: outbox,
|
|
363
365
|
ntpCheck: ntpCheck,
|
|
364
366
|
version: constants.version,
|
|
365
367
|
};
|
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
|
+
};
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -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:
|
|
5
|
+
"serialNumber": "urn:uuid:ea6013b4-0103-48ac-8789-54d7054c2b77",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-06T07:
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.90",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.7.90",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|