@eventferry/mysql 3.3.1 → 3.3.3
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 +18 -0
- package/README.md +80 -1
- package/dist/index.cjs +13 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @eventferry/mysql
|
|
2
2
|
|
|
3
|
+
## 3.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 51e5359: MariaDB parity fix: `MysqlStore.rowToRecord` now defensively `JSON.parse`s `payload` and `headers` when the driver returns them as strings. MySQL 8 has a native JSON type and the `mysql2` driver auto-parses it; MariaDB exposes JSON as a `LONGTEXT` alias with a CHECK constraint, so the driver returns the raw string. Without the parse, consumers would see `payload: '{"x":1}'` (string) instead of `payload: { x: 1 }` (object). Belt and suspenders — works the same on both engines, and on any future engine that parses or doesn't.
|
|
8
|
+
|
|
9
|
+
Caught by parametrizing the `mysql-store` integration suite over both MySQL 8 and MariaDB 10.11 — three previously-passing tests failed on MariaDB until the fix landed. Suite now passes on both.
|
|
10
|
+
|
|
11
|
+
The package README gains a "Running on an older engine" section documenting the `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback for shops stuck on MySQL 5.7 / MariaDB <10.6 (no `SKIP LOCKED`). Schema addition + full claim path with caveats. Not bundled because the throughput floor is lower than the SKIP LOCKED path; documented as an explicit workaround for legacy engines.
|
|
12
|
+
|
|
13
|
+
## 3.3.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [715523f]
|
|
18
|
+
- Updated dependencies [fb0549d]
|
|
19
|
+
- @eventferry/core@3.4.0
|
|
20
|
+
|
|
3
21
|
## 3.3.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Provides:
|
|
|
21
21
|
- Node.js **18+**
|
|
22
22
|
|
|
23
23
|
> Older MySQL versions don't support `SKIP LOCKED` — concurrent relays would
|
|
24
|
-
> serialize on the same rows.
|
|
24
|
+
> serialize on the same rows. See [Running on an older engine](#running-on-an-older-engine) below for the documented fallback pattern.
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
@@ -159,6 +159,85 @@ position to your own KV store, then pass it back via `startPosition` on the
|
|
|
159
159
|
next start — without it the relay starts at the **end** of the binlog (tail
|
|
160
160
|
mode) and won't replay rows written before it connected.
|
|
161
161
|
|
|
162
|
+
## Running on an older engine
|
|
163
|
+
|
|
164
|
+
`MysqlStore` requires `SELECT ... FOR UPDATE SKIP LOCKED` — MySQL 8.0.1+ or MariaDB 10.6+. On older engines (MySQL 5.7, MariaDB <10.6), the built-in `claimBatch` would serialize concurrent relays on the same rows, which defeats the purpose of running more than one. For shops stuck on a legacy engine, the supported workaround is the **claim-token pattern** below — proven, but not bundled because it requires a small schema addition and a different claim path.
|
|
165
|
+
|
|
166
|
+
### Schema addition
|
|
167
|
+
|
|
168
|
+
Add a `claim_token` column to the outbox table (one-time migration):
|
|
169
|
+
|
|
170
|
+
```sql
|
|
171
|
+
ALTER TABLE `outbox`
|
|
172
|
+
ADD COLUMN `claim_token` CHAR(36) NULL,
|
|
173
|
+
ADD INDEX `idx_outbox_claim_token` (`claim_token`);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Claim path
|
|
177
|
+
|
|
178
|
+
Instead of `SELECT ... FOR UPDATE SKIP LOCKED → UPDATE → SELECT`, do **`UPDATE ... ORDER BY id LIMIT n` → `SELECT WHERE claim_token = ?`**. The UPDATE acquires row-level locks atomically; concurrent UPDATEs serialize briefly and the race-losers naturally pick up different rows on the next iteration.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { randomUUID } from "node:crypto";
|
|
182
|
+
|
|
183
|
+
async function claimBatchLegacy(pool, table, batchSize, claimTimeoutMs = 60_000) {
|
|
184
|
+
const token = randomUUID();
|
|
185
|
+
|
|
186
|
+
// 1. Atomic claim — UPDATE acquires row locks; concurrent UPDATEs serialize.
|
|
187
|
+
// Subquery for strict head-of-aggregate ordering. The derived-table
|
|
188
|
+
// wrapper sidesteps MySQL 5.7's "can't specify target table in FROM" rule.
|
|
189
|
+
await pool.query(
|
|
190
|
+
`UPDATE \`${table}\`
|
|
191
|
+
SET status = 1, claimed_at = NOW(3), claim_token = ?
|
|
192
|
+
WHERE id IN (
|
|
193
|
+
SELECT id FROM (
|
|
194
|
+
SELECT o.id
|
|
195
|
+
FROM \`${table}\` o
|
|
196
|
+
WHERE (o.status = 0
|
|
197
|
+
OR (o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW(3))))
|
|
198
|
+
AND (o.claimed_at IS NULL
|
|
199
|
+
OR o.claimed_at <= DATE_SUB(NOW(3), INTERVAL ? MICROSECOND))
|
|
200
|
+
AND NOT EXISTS (
|
|
201
|
+
SELECT 1 FROM \`${table}\` e
|
|
202
|
+
WHERE e.aggregate_id = o.aggregate_id
|
|
203
|
+
AND e.id < o.id
|
|
204
|
+
AND e.status IN (0, 1, 3)
|
|
205
|
+
)
|
|
206
|
+
ORDER BY o.id
|
|
207
|
+
LIMIT ?
|
|
208
|
+
) AS eligible
|
|
209
|
+
)`,
|
|
210
|
+
[token, claimTimeoutMs * 1000, batchSize],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// 2. Read back exactly what we claimed.
|
|
214
|
+
const [rows] = await pool.query(
|
|
215
|
+
`SELECT * FROM \`${table}\` WHERE claim_token = ? ORDER BY id`,
|
|
216
|
+
[token],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// 3. Clear the token so the row is markDone-able by id later (token slot
|
|
220
|
+
// is reusable on the next claim cycle). markDone / markFailed touch
|
|
221
|
+
// the row by id, NOT by token, so this clear is for hygiene.
|
|
222
|
+
if (rows.length > 0) {
|
|
223
|
+
await pool.query(
|
|
224
|
+
`UPDATE \`${table}\` SET claim_token = NULL WHERE claim_token = ?`,
|
|
225
|
+
[token],
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Caveats
|
|
234
|
+
|
|
235
|
+
- **Throughput** — UPDATE-based claim serializes briefly under concurrent claimers. Fine for low-to-mid hundreds of events/sec. For heavy traffic, upgrade the engine.
|
|
236
|
+
- **Correctness** — Race-free. The UPDATE's row-locking acquires exclusive locks on the targeted rows; concurrent claim losers re-evaluate the subquery on their next attempt and pick up different rows.
|
|
237
|
+
- **Maintenance** — Not covered by the package's test suite. If you ship this in production, integration-test it against your engine version.
|
|
238
|
+
|
|
239
|
+
This pattern is referenced from the [roadmap](https://github.com/SametGoktepe/eventferry/blob/main/ROADMAP.md#mysql--mariadb--eventferrymysql--shipped) under "documented fallback for older engines."
|
|
240
|
+
|
|
162
241
|
## What's still not in this package
|
|
163
242
|
|
|
164
243
|
- **No native low-latency waker** for the polling relay. MySQL has no
|
package/dist/index.cjs
CHANGED
|
@@ -62,8 +62,8 @@ function rowToRecord(row) {
|
|
|
62
62
|
aggregateType: row.aggregate_type,
|
|
63
63
|
aggregateId: row.aggregate_id,
|
|
64
64
|
key: row.key,
|
|
65
|
-
payload: row.payload,
|
|
66
|
-
headers: row.headers ?? {},
|
|
65
|
+
payload: parseJsonField(row.payload),
|
|
66
|
+
headers: parseJsonField(row.headers) ?? {},
|
|
67
67
|
traceId: row.trace_id,
|
|
68
68
|
status,
|
|
69
69
|
attempts: row.attempts,
|
|
@@ -72,6 +72,11 @@ function rowToRecord(row) {
|
|
|
72
72
|
processedAt: row.processed_at
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
+
function parseJsonField(value) {
|
|
76
|
+
if (value === null || value === void 0) return value;
|
|
77
|
+
if (typeof value === "string") return JSON.parse(value);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
75
80
|
|
|
76
81
|
// src/store.ts
|
|
77
82
|
var DEFAULT_CLAIM_TIMEOUT_MS = 6e4;
|
|
@@ -142,6 +147,12 @@ var MysqlStore = class {
|
|
|
142
147
|
* A row stuck in `processing` longer than claimTimeoutMs is treated as due
|
|
143
148
|
* again (its owning relay is presumed dead), which keeps a crash between
|
|
144
149
|
* claim and ack from orphaning messages.
|
|
150
|
+
*
|
|
151
|
+
* ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —
|
|
152
|
+
* MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented
|
|
153
|
+
* `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package
|
|
154
|
+
* README is the supported workaround (one-time `claim_token` column
|
|
155
|
+
* addition + a custom claim path). Throughput trades for engine support.
|
|
145
156
|
*/
|
|
146
157
|
async claimBatch(batchSize) {
|
|
147
158
|
const conn = await this.pool.getConnection();
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/store.ts","../src/ident.ts","../src/row.ts","../src/migrations.ts","../src/binlog-relay.ts"],"sourcesContent":["export * from \"./store.js\";\nexport * from \"./migrations.js\";\nexport * from \"./binlog-relay.js\";\n","import { randomUUID } from \"node:crypto\";\nimport type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal mysql2/promise query surface — satisfied by both `Pool` and\n * `PoolConnection`, so `enqueue` can run inside a caller-supplied transaction.\n * `query` returns the canonical `[rows, fields]` tuple shape of the driver.\n */\nexport interface MysqlQueryable {\n query(sql: string, values?: unknown[]): Promise<[unknown, unknown]>;\n}\n\n/**\n * Minimal mysql2/promise PoolConnection surface used by {@link MysqlStore.claimBatch}\n * for its internal `BEGIN ... SELECT FOR UPDATE SKIP LOCKED ... UPDATE ... COMMIT`\n * dance — MySQL has no `RETURNING`, so we cannot fold the claim into a single\n * statement the way the Postgres adapter does.\n */\nexport interface MysqlConnection extends MysqlQueryable {\n beginTransaction(): Promise<void>;\n commit(): Promise<void>;\n rollback(): Promise<void>;\n release(): void;\n}\n\n/**\n * Minimal mysql2/promise Pool surface — `query` for stateless ops and\n * `getConnection` for the transactional claim path.\n */\nexport interface MysqlPool extends MysqlQueryable {\n getConnection(): Promise<MysqlConnection>;\n}\n\nexport interface MysqlStoreOptions {\n /** A connected mysql2/promise Pool used by the relay for claim/ack queries. */\n pool: MysqlPool;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Reserved for future streaming\n * relay modes that own the pending path. Default false.\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\nconst PROCESSING = OUTBOX_STATUS_CODE.processing;\nconst DONE = OUTBOX_STATUS_CODE.done;\n\nexport class MysqlStore implements OutboxStore {\n private readonly pool: MysqlPool;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: MysqlStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated (or caller-supplied) message id.\n */\n async enqueue(\n tx: MysqlQueryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n // MySQL has no UUID-generating default; mint client-side. crypto.randomUUID\n // is RFC 4122 v4 and available in Node 18+ (our minimum engine).\n const messageId = msg.messageId ?? randomUUID();\n\n const sql = `\n INSERT INTO \\`${this.table}\\`\n (message_id, aggregate_type, aggregate_id, topic, \\`key\\`, payload, headers, trace_id, status)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, ?, 0)\n `;\n await tx.query(sql, [\n messageId,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return messageId;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at.\n *\n * MySQL has no `RETURNING`, so the claim is a three-step transaction:\n *\n * 1. SELECT due ids FOR UPDATE SKIP LOCKED — the locks are held until COMMIT.\n * 2. UPDATE ... WHERE id IN (...) — atomically flip status + claimed_at.\n * 3. SELECT * WHERE id IN (...) — read the (now updated) rows back.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which keeps a crash between\n * claim and ack from orphaning messages.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const conn = await this.pool.getConnection();\n try {\n await conn.beginTransaction();\n\n // The reaper window cutoff is computed SERVER-SIDE\n // (`DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)`). Sending a JS Date as a\n // bound value goes through mysql2's local-time serialiser, while NOW(3)\n // returns the connection's clock; the two can drift by the host's TZ\n // offset and the reaper fires (or fails to fire) at the wrong instant.\n // Server-side INTERVAL sidesteps that entirely.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0 OR \";\n\n const selectDue = `\n SELECT o.id\n FROM \\`${this.table}\\` o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW(3)))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= DATE_SUB(NOW(3), INTERVAL ? MICROSECOND))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM \\`${this.table}\\` earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT ?\n FOR UPDATE SKIP LOCKED\n `;\n const [dueRows] = await conn.query(selectDue, [\n this.claimTimeoutMs * 1000,\n batchSize,\n ]);\n const ids = (dueRows as Array<{ id: number | string | bigint }>).map(\n (r) => r.id,\n );\n\n if (ids.length === 0) {\n await conn.commit();\n return [];\n }\n\n await conn.query(\n `UPDATE \\`${this.table}\\` SET status = ${PROCESSING}, claimed_at = NOW(3) WHERE id IN (?)`,\n [ids],\n );\n\n const [rows] = await conn.query(\n `SELECT id, message_id, aggregate_type, aggregate_id, topic, \\`key\\`,\n payload, headers, trace_id, status, attempts, next_retry_at,\n created_at, processed_at\n FROM \\`${this.table}\\`\n WHERE id IN (?)\n ORDER BY id`,\n [ids],\n );\n\n await conn.commit();\n return (rows as unknown as OutboxRow[]).map(rowToRecord);\n } catch (err) {\n try {\n await conn.rollback();\n } catch {\n // Connection may already be dead; let the original error surface.\n }\n throw err;\n } finally {\n conn.release();\n }\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n await this.pool.query(\n `UPDATE \\`${this.table}\\` SET status = ${DONE}, processed_at = NOW(3) WHERE id IN (?)`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ?,\n attempts = attempts + 1,\n next_retry_at = ?\n WHERE id = ?`,\n [code, nextRetryAt, recordId],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = ?\n WHERE id = ?`,\n [retryAt, recordId],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place for\n * post-mortem.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const batchSize = opts.batchSize ?? 1000;\n let total = 0;\n for (;;) {\n // Same TZ-safety as claimBatch: compute the cutoff server-side via\n // INTERVAL so the comparison never drifts vs the host's local time.\n const [result] = await this.pool.query(\n `DELETE FROM \\`${this.table}\\`\n WHERE status = ${DONE}\n AND processed_at IS NOT NULL\n AND processed_at < DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)\n ORDER BY id\n LIMIT ?`,\n [opts.olderThanMs * 1000, batchSize],\n );\n const deleted = (result as { affectedRows?: number }).affectedRows ?? 0;\n total += deleted;\n if (deleted < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","/**\n * Allow only safe SQL identifier characters. Used wherever a user-supplied name\n * (table) is interpolated into SQL, to prevent injection.\n */\nexport function assertIdent(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(\n `Invalid identifier \"${name}\": must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`,\n );\n }\n return name;\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from MySQL (snake_case columns). */\nexport interface OutboxRow {\n id: number | string | bigint;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/**\n * Map a raw DB row to the broker-agnostic core record. `id` is stringified\n * because mysql2 may return BIGINT either as a JS number, string, or bigint\n * depending on driver options — the core contract is `string`.\n */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import { assertIdent } from \"./ident.js\";\n\n/**\n * Generate the DDL for the MySQL outbox table, parameterized by table name.\n * Kept as a string template (not a file read) so it works regardless of how\n * the package is bundled or where it's installed.\n *\n * Requirements:\n * - **MySQL 8.0.1+** or **MariaDB 10.6+** (needs `SELECT ... FOR UPDATE SKIP LOCKED`).\n * - **InnoDB** engine — required for transactions and row-level locking.\n * - `DATETIME(3)` is used (not `TIMESTAMP`) so values are tz-stable and free of\n * the 2038 problem; millisecond precision matches the reaper's claim window.\n *\n * MySQL has no partial indexes, so `idx_${t}_ready` covers all statuses (it\n * still helps because the planner picks the index for `WHERE status IN (...)`).\n * Pair with `createRetentionIndexSql` only if `purgeDone` scans become hot.\n */\nexport function createMigrationSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE TABLE IF NOT EXISTS \\`${t}\\` (\n id BIGINT NOT NULL AUTO_INCREMENT,\n message_id CHAR(36) NOT NULL,\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n topic VARCHAR(255) NOT NULL,\n \\`key\\` TEXT,\n payload JSON NOT NULL,\n headers JSON NOT NULL,\n trace_id VARCHAR(64),\n status TINYINT NOT NULL DEFAULT 0,\n attempts INT NOT NULL DEFAULT 0,\n next_retry_at DATETIME(3),\n claimed_at DATETIME(3),\n created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),\n processed_at DATETIME(3),\n PRIMARY KEY (id),\n UNIQUE KEY uq_${t}_message_id (message_id),\n KEY idx_${t}_ready (status, id),\n KEY idx_${t}_agg_order (aggregate_id, id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n`.trim();\n}\n\n/**\n * Optional index that speeds up `purgeDone` on high-volume tables. The default\n * indexes don't cover `processed_at`, so the retention scan otherwise filesorts\n * across all done rows; this index makes it index-driven. Skip unless retention\n * scans are slow — it adds write/space overhead on the bulk (done) segment.\n */\nexport function createRetentionIndexSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE INDEX idx_${t}_done_processed ON \\`${t}\\` (processed_at);\n`.trim();\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Connection + reader settings for the MySQL binlog stream.\n *\n * Requires the server to be configured for row-based replication:\n * - `binlog_format=ROW`\n * - `binlog_row_image=FULL`\n * - `gtid_mode=ON` (optional but recommended for resumption)\n * and a user with `REPLICATION SLAVE` + `REPLICATION CLIENT` grants.\n */\nexport interface BinlogReplicationConfig {\n host: string;\n port?: number;\n user: string;\n password: string;\n /** Database that owns the outbox table. Used to scope row events. */\n database: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n /**\n * MySQL replica server-id. MUST be unique within the cluster (real replicas\n * + every binlog reader). Defaults to a deterministic value derived from\n * pid; override in clustered setups.\n */\n serverId?: number;\n /**\n * Resume from this binlog position. If omitted, the reader starts from the\n * current end-of-log (\"tail\" mode) — new rows only. Persist the position\n * yourself (e.g. in your app's KV store) by subscribing to commit events.\n */\n startPosition?: BinlogPosition;\n}\n\n/** A binlog coordinate. (file, offset) — MySQL's analogue of a Postgres LSN. */\nexport interface BinlogPosition {\n readonly filename: string;\n readonly position: number;\n}\n\n/** A decoded INSERT on the outbox table, with the binlog position it occurred at. */\nexport interface DecodedInsert {\n readonly position: BinlogPosition;\n readonly row: OutboxRow;\n}\n\nexport interface BinlogStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (position: BinlogPosition) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/**\n * The binlog source the streaming relay consumes. Implemented over @vlasky/zongji\n * by default; overridable as a test seam, or to plug a custom reader / managed\n * service (RDS Streams, ProxySQL bridge, etc.).\n */\nexport interface BinlogStream {\n start(handlers: BinlogStreamHandlers): Promise<void>;\n /**\n * Note the position has been durably handled downstream. MySQL has no native\n * server-side ack like logical replication, so the default implementation\n * just tracks the position in memory. Override to persist it.\n */\n acknowledge(position: BinlogPosition): Promise<void>;\n stop(): Promise<void>;\n}\n\nexport interface MysqlBinlogRelayOptions {\n /**\n * Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the binlog stream).\n */\n store: OutboxStore;\n publisher: Publisher;\n binlog: BinlogReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a single transaction's worth of events. Default 100. */\n batchSize?: number;\n}\n\n/**\n * Publishes outbox events straight from the MySQL binlog (row-based), with no\n * claim query on the happy path. Failures are demoted to `failed` and drained\n * by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's position is acknowledged only after its batch's\n * side effects commit, so a crash re-streams and re-publishes (a duplicate\n * idempotent consumers absorb). Ordering is best-effort per aggregate — a\n * retried failure lands after later same-aggregate rows; use the polling\n * relay (default `Relay` + `MysqlStore`) when strict ordering matters.\n */\nexport class MysqlBinlogRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly binlog: BinlogReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: BinlogStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: MysqlBinlogRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.binlog = opts.binlog;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start();\n this.stream = this.createBinlogStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (position) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, position));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"binlog stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"mysql binlog relay started\", {\n database: this.binlog.database,\n table: this.binlog.table ?? \"outbox\",\n });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail;\n await this.retryRelay.stop();\n this.log.info(\"mysql binlog relay stopped\");\n }\n\n /** Build the binlog stream. Overridable as a test seam. */\n protected createBinlogStream(): BinlogStream {\n return createZongjiBinlogStream(this.binlog);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n position: BinlogPosition,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(position);\n } catch (err) {\n // A failure here MUST NOT advance the position: the commit is re-streamed\n // and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"binlog batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/**\n * Real binlog stream backed by @vlasky/zongji. Covered by the integration suite\n * (it needs a live MySQL with binlog enabled); unit tests use a fake stream.\n */\nfunction createZongjiBinlogStream(config: BinlogReplicationConfig): BinlogStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let zongji: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: BinlogStreamHandlers): Promise<void> {\n const ZongJi = await importZongji();\n zongji = new ZongJi({\n host: config.host,\n port: config.port ?? 3306,\n user: config.user,\n password: config.password,\n serverId: config.serverId ?? deriveServerId(),\n });\n\n // Track the latest position seen per transaction so a single ROTATE\n // followed by writeRows then xid (commit) gives us a coherent commit point.\n let current: BinlogPosition = config.startPosition ?? {\n filename: \"\",\n position: 0,\n };\n\n zongji.on(\"binlog\", (evt: BinlogEventLike) => {\n const name = evt.getEventName?.();\n if (name === \"rotate\") {\n current = {\n filename: (evt.nextBinlog as string) ?? current.filename,\n position: (evt.position as number) ?? 0,\n };\n return;\n }\n if (name === \"writerows\") {\n if (\n (evt.tableMap as { [k: string]: { tableName: string } } | undefined) &&\n !rowsAreFor(evt, table)\n ) {\n return;\n }\n const rows = (evt.rows as Array<Record<string, unknown>>) ?? [];\n for (const raw of rows) {\n handlers.onInsert({\n position: current,\n row: normalizeRow(raw),\n });\n }\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n return;\n }\n if (name === \"xid\" || name === \"query\") {\n // xid = commit (InnoDB); query may carry BEGIN/COMMIT for statement-based.\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n void handlers.onCommit(current);\n }\n });\n\n zongji.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n });\n\n const startOpts: Record<string, unknown> = {\n includeEvents: [\"tablemap\", \"writerows\", \"xid\", \"rotate\", \"query\"],\n includeSchema: { [config.database]: [table] },\n };\n if (config.startPosition) {\n startOpts[\"filename\"] = config.startPosition.filename;\n startOpts[\"position\"] = config.startPosition.position;\n }\n zongji.start(startOpts);\n },\n async acknowledge(_position: BinlogPosition): Promise<void> {\n // MySQL has no native server-side ack. Position tracking lives in the\n // user's KV store via the onCommit hook (or this is replaced by a custom\n // BinlogStream implementation).\n },\n async stop(): Promise<void> {\n stopped = true;\n if (zongji) zongji.stop();\n },\n };\n}\n\n/** Minimal shape of a zongji binlog event we look at. */\ninterface BinlogEventLike {\n getEventName?(): string;\n nextBinlog?: string;\n position?: number;\n nextPosition?: number;\n tableMap?: Record<string, { tableName: string }>;\n rows?: Array<Record<string, unknown>>;\n}\n\nfunction rowsAreFor(evt: BinlogEventLike, table: string): boolean {\n const map = evt.tableMap ?? {};\n for (const tid of Object.keys(map)) {\n const meta = map[tid];\n if (meta?.tableName === table) {\n // zongji exposes schemaName inconsistently across versions; checking the\n // table name alone matches the includeSchema filter we asked for.\n return true;\n }\n }\n // If we have no tableMap context yet (rotate-after-restart edge), accept.\n return Object.keys(map).length === 0 ? true : false;\n}\n\nfunction normalizeRow(raw: Record<string, unknown>): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : (v ?? null);\n const headers = json(raw[\"headers\"]) as Record<string, string> | null;\n return {\n id: String(raw[\"id\"]),\n message_id: String(raw[\"message_id\"] ?? \"\"),\n aggregate_type: String(raw[\"aggregate_type\"] ?? \"\"),\n aggregate_id: String(raw[\"aggregate_id\"] ?? \"\"),\n topic: String(raw[\"topic\"] ?? \"\"),\n key: (raw[\"key\"] as string | null) ?? null,\n payload: json(raw[\"payload\"]),\n headers,\n trace_id: (raw[\"trace_id\"] as string | null) ?? null,\n status: Number(raw[\"status\"] ?? 0),\n attempts: Number(raw[\"attempts\"] ?? 0),\n next_retry_at: raw[\"next_retry_at\"]\n ? new Date(raw[\"next_retry_at\"] as string | number | Date)\n : null,\n created_at: raw[\"created_at\"]\n ? new Date(raw[\"created_at\"] as string | number | Date)\n : new Date(0),\n processed_at: raw[\"processed_at\"]\n ? new Date(raw[\"processed_at\"] as string | number | Date)\n : null,\n };\n}\n\n/** Deterministic serverId derived from PID so two reader processes on one host\n * do not clash. Override via {@link BinlogReplicationConfig.serverId}. */\nfunction deriveServerId(): number {\n const pid = typeof process !== \"undefined\" ? process.pid : 1;\n // Keep in the safe 1..2^32-1 range; bias well above real replica server-ids.\n return (1_000_000 + (pid % 1_000_000)) >>> 0;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nasync function importZongji(): Promise<any> {\n try {\n // @vlasky/zongji is an optional peer dep — typed via a `@ts-ignore`\n // because users without the binlog relay should not need the type\n // declarations installed.\n // @ts-ignore -- optional peer dep, types not required for compilation\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod: any = await import(\"@vlasky/zongji\");\n return mod.default ?? mod;\n } catch {\n throw new Error(\n 'Binlog relay needs the \"@vlasky/zongji\" package. Run: npm i @vlasky/zongji',\n );\n }\n}\n"],"mappings":"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","names":["import_core","import_core"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/store.ts","../src/ident.ts","../src/row.ts","../src/migrations.ts","../src/binlog-relay.ts"],"sourcesContent":["export * from \"./store.js\";\nexport * from \"./migrations.js\";\nexport * from \"./binlog-relay.js\";\n","import { randomUUID } from \"node:crypto\";\nimport type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal mysql2/promise query surface — satisfied by both `Pool` and\n * `PoolConnection`, so `enqueue` can run inside a caller-supplied transaction.\n * `query` returns the canonical `[rows, fields]` tuple shape of the driver.\n */\nexport interface MysqlQueryable {\n query(sql: string, values?: unknown[]): Promise<[unknown, unknown]>;\n}\n\n/**\n * Minimal mysql2/promise PoolConnection surface used by {@link MysqlStore.claimBatch}\n * for its internal `BEGIN ... SELECT FOR UPDATE SKIP LOCKED ... UPDATE ... COMMIT`\n * dance — MySQL has no `RETURNING`, so we cannot fold the claim into a single\n * statement the way the Postgres adapter does.\n */\nexport interface MysqlConnection extends MysqlQueryable {\n beginTransaction(): Promise<void>;\n commit(): Promise<void>;\n rollback(): Promise<void>;\n release(): void;\n}\n\n/**\n * Minimal mysql2/promise Pool surface — `query` for stateless ops and\n * `getConnection` for the transactional claim path.\n */\nexport interface MysqlPool extends MysqlQueryable {\n getConnection(): Promise<MysqlConnection>;\n}\n\nexport interface MysqlStoreOptions {\n /** A connected mysql2/promise Pool used by the relay for claim/ack queries. */\n pool: MysqlPool;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Reserved for future streaming\n * relay modes that own the pending path. Default false.\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\nconst PROCESSING = OUTBOX_STATUS_CODE.processing;\nconst DONE = OUTBOX_STATUS_CODE.done;\n\nexport class MysqlStore implements OutboxStore {\n private readonly pool: MysqlPool;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: MysqlStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated (or caller-supplied) message id.\n */\n async enqueue(\n tx: MysqlQueryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n // MySQL has no UUID-generating default; mint client-side. crypto.randomUUID\n // is RFC 4122 v4 and available in Node 18+ (our minimum engine).\n const messageId = msg.messageId ?? randomUUID();\n\n const sql = `\n INSERT INTO \\`${this.table}\\`\n (message_id, aggregate_type, aggregate_id, topic, \\`key\\`, payload, headers, trace_id, status)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, ?, 0)\n `;\n await tx.query(sql, [\n messageId,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return messageId;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at.\n *\n * MySQL has no `RETURNING`, so the claim is a three-step transaction:\n *\n * 1. SELECT due ids FOR UPDATE SKIP LOCKED — the locks are held until COMMIT.\n * 2. UPDATE ... WHERE id IN (...) — atomically flip status + claimed_at.\n * 3. SELECT * WHERE id IN (...) — read the (now updated) rows back.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which keeps a crash between\n * claim and ack from orphaning messages.\n *\n * ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —\n * MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented\n * `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package\n * README is the supported workaround (one-time `claim_token` column\n * addition + a custom claim path). Throughput trades for engine support.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const conn = await this.pool.getConnection();\n try {\n await conn.beginTransaction();\n\n // The reaper window cutoff is computed SERVER-SIDE\n // (`DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)`). Sending a JS Date as a\n // bound value goes through mysql2's local-time serialiser, while NOW(3)\n // returns the connection's clock; the two can drift by the host's TZ\n // offset and the reaper fires (or fails to fire) at the wrong instant.\n // Server-side INTERVAL sidesteps that entirely.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0 OR \";\n\n const selectDue = `\n SELECT o.id\n FROM \\`${this.table}\\` o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW(3)))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= DATE_SUB(NOW(3), INTERVAL ? MICROSECOND))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM \\`${this.table}\\` earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT ?\n FOR UPDATE SKIP LOCKED\n `;\n const [dueRows] = await conn.query(selectDue, [\n this.claimTimeoutMs * 1000,\n batchSize,\n ]);\n const ids = (dueRows as Array<{ id: number | string | bigint }>).map(\n (r) => r.id,\n );\n\n if (ids.length === 0) {\n await conn.commit();\n return [];\n }\n\n await conn.query(\n `UPDATE \\`${this.table}\\` SET status = ${PROCESSING}, claimed_at = NOW(3) WHERE id IN (?)`,\n [ids],\n );\n\n const [rows] = await conn.query(\n `SELECT id, message_id, aggregate_type, aggregate_id, topic, \\`key\\`,\n payload, headers, trace_id, status, attempts, next_retry_at,\n created_at, processed_at\n FROM \\`${this.table}\\`\n WHERE id IN (?)\n ORDER BY id`,\n [ids],\n );\n\n await conn.commit();\n return (rows as unknown as OutboxRow[]).map(rowToRecord);\n } catch (err) {\n try {\n await conn.rollback();\n } catch {\n // Connection may already be dead; let the original error surface.\n }\n throw err;\n } finally {\n conn.release();\n }\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n await this.pool.query(\n `UPDATE \\`${this.table}\\` SET status = ${DONE}, processed_at = NOW(3) WHERE id IN (?)`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ?,\n attempts = attempts + 1,\n next_retry_at = ?\n WHERE id = ?`,\n [code, nextRetryAt, recordId],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = ?\n WHERE id = ?`,\n [retryAt, recordId],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place for\n * post-mortem.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const batchSize = opts.batchSize ?? 1000;\n let total = 0;\n for (;;) {\n // Same TZ-safety as claimBatch: compute the cutoff server-side via\n // INTERVAL so the comparison never drifts vs the host's local time.\n const [result] = await this.pool.query(\n `DELETE FROM \\`${this.table}\\`\n WHERE status = ${DONE}\n AND processed_at IS NOT NULL\n AND processed_at < DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)\n ORDER BY id\n LIMIT ?`,\n [opts.olderThanMs * 1000, batchSize],\n );\n const deleted = (result as { affectedRows?: number }).affectedRows ?? 0;\n total += deleted;\n if (deleted < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","/**\n * Allow only safe SQL identifier characters. Used wherever a user-supplied name\n * (table) is interpolated into SQL, to prevent injection.\n */\nexport function assertIdent(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(\n `Invalid identifier \"${name}\": must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`,\n );\n }\n return name;\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from MySQL (snake_case columns). */\nexport interface OutboxRow {\n id: number | string | bigint;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/**\n * Map a raw DB row to the broker-agnostic core record. `id` is stringified\n * because mysql2 may return BIGINT either as a JS number, string, or bigint\n * depending on driver options — the core contract is `string`.\n *\n * `payload` / `headers` are JSON columns, but driver behavior splits:\n * - MySQL 8 has a native JSON type, and the `mysql2` driver auto-parses\n * it to a JS object / array on read.\n * - MariaDB exposes JSON as a `LONGTEXT` alias with a CHECK constraint —\n * no native type → the driver returns the raw string.\n * To stay engine-agnostic we re-parse strings here; objects pass through\n * untouched. Belt and suspenders.\n */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: parseJsonField<unknown>(row.payload),\n headers: parseJsonField<Record<string, string> | null>(row.headers) ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n\n/**\n * Defensive JSON parser for fields the driver may or may not have parsed\n * (MySQL 8 yes, MariaDB no). Strings get JSON.parse'd; everything else\n * passes through. Throws if the string is malformed — a malformed JSON\n * payload in the outbox would be a write-side bug, surface it loudly.\n */\nfunction parseJsonField<T>(value: unknown): T {\n if (value === null || value === undefined) return value as T;\n if (typeof value === \"string\") return JSON.parse(value) as T;\n return value as T;\n}\n","import { assertIdent } from \"./ident.js\";\n\n/**\n * Generate the DDL for the MySQL outbox table, parameterized by table name.\n * Kept as a string template (not a file read) so it works regardless of how\n * the package is bundled or where it's installed.\n *\n * Requirements:\n * - **MySQL 8.0.1+** or **MariaDB 10.6+** (needs `SELECT ... FOR UPDATE SKIP LOCKED`).\n * - **InnoDB** engine — required for transactions and row-level locking.\n * - `DATETIME(3)` is used (not `TIMESTAMP`) so values are tz-stable and free of\n * the 2038 problem; millisecond precision matches the reaper's claim window.\n *\n * MySQL has no partial indexes, so `idx_${t}_ready` covers all statuses (it\n * still helps because the planner picks the index for `WHERE status IN (...)`).\n * Pair with `createRetentionIndexSql` only if `purgeDone` scans become hot.\n */\nexport function createMigrationSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE TABLE IF NOT EXISTS \\`${t}\\` (\n id BIGINT NOT NULL AUTO_INCREMENT,\n message_id CHAR(36) NOT NULL,\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n topic VARCHAR(255) NOT NULL,\n \\`key\\` TEXT,\n payload JSON NOT NULL,\n headers JSON NOT NULL,\n trace_id VARCHAR(64),\n status TINYINT NOT NULL DEFAULT 0,\n attempts INT NOT NULL DEFAULT 0,\n next_retry_at DATETIME(3),\n claimed_at DATETIME(3),\n created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),\n processed_at DATETIME(3),\n PRIMARY KEY (id),\n UNIQUE KEY uq_${t}_message_id (message_id),\n KEY idx_${t}_ready (status, id),\n KEY idx_${t}_agg_order (aggregate_id, id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n`.trim();\n}\n\n/**\n * Optional index that speeds up `purgeDone` on high-volume tables. The default\n * indexes don't cover `processed_at`, so the retention scan otherwise filesorts\n * across all done rows; this index makes it index-driven. Skip unless retention\n * scans are slow — it adds write/space overhead on the bulk (done) segment.\n */\nexport function createRetentionIndexSql(tableName = \"outbox\"): string {\n const t = assertIdent(tableName);\n return `\nCREATE INDEX idx_${t}_done_processed ON \\`${t}\\` (processed_at);\n`.trim();\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Connection + reader settings for the MySQL binlog stream.\n *\n * Requires the server to be configured for row-based replication:\n * - `binlog_format=ROW`\n * - `binlog_row_image=FULL`\n * - `gtid_mode=ON` (optional but recommended for resumption)\n * and a user with `REPLICATION SLAVE` + `REPLICATION CLIENT` grants.\n */\nexport interface BinlogReplicationConfig {\n host: string;\n port?: number;\n user: string;\n password: string;\n /** Database that owns the outbox table. Used to scope row events. */\n database: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n /**\n * MySQL replica server-id. MUST be unique within the cluster (real replicas\n * + every binlog reader). Defaults to a deterministic value derived from\n * pid; override in clustered setups.\n */\n serverId?: number;\n /**\n * Resume from this binlog position. If omitted, the reader starts from the\n * current end-of-log (\"tail\" mode) — new rows only. Persist the position\n * yourself (e.g. in your app's KV store) by subscribing to commit events.\n */\n startPosition?: BinlogPosition;\n}\n\n/** A binlog coordinate. (file, offset) — MySQL's analogue of a Postgres LSN. */\nexport interface BinlogPosition {\n readonly filename: string;\n readonly position: number;\n}\n\n/** A decoded INSERT on the outbox table, with the binlog position it occurred at. */\nexport interface DecodedInsert {\n readonly position: BinlogPosition;\n readonly row: OutboxRow;\n}\n\nexport interface BinlogStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (position: BinlogPosition) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/**\n * The binlog source the streaming relay consumes. Implemented over @vlasky/zongji\n * by default; overridable as a test seam, or to plug a custom reader / managed\n * service (RDS Streams, ProxySQL bridge, etc.).\n */\nexport interface BinlogStream {\n start(handlers: BinlogStreamHandlers): Promise<void>;\n /**\n * Note the position has been durably handled downstream. MySQL has no native\n * server-side ack like logical replication, so the default implementation\n * just tracks the position in memory. Override to persist it.\n */\n acknowledge(position: BinlogPosition): Promise<void>;\n stop(): Promise<void>;\n}\n\nexport interface MysqlBinlogRelayOptions {\n /**\n * Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the binlog stream).\n */\n store: OutboxStore;\n publisher: Publisher;\n binlog: BinlogReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a single transaction's worth of events. Default 100. */\n batchSize?: number;\n}\n\n/**\n * Publishes outbox events straight from the MySQL binlog (row-based), with no\n * claim query on the happy path. Failures are demoted to `failed` and drained\n * by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's position is acknowledged only after its batch's\n * side effects commit, so a crash re-streams and re-publishes (a duplicate\n * idempotent consumers absorb). Ordering is best-effort per aggregate — a\n * retried failure lands after later same-aggregate rows; use the polling\n * relay (default `Relay` + `MysqlStore`) when strict ordering matters.\n */\nexport class MysqlBinlogRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly binlog: BinlogReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: BinlogStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: MysqlBinlogRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.binlog = opts.binlog;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start();\n this.stream = this.createBinlogStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (position) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, position));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"binlog stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"mysql binlog relay started\", {\n database: this.binlog.database,\n table: this.binlog.table ?? \"outbox\",\n });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail;\n await this.retryRelay.stop();\n this.log.info(\"mysql binlog relay stopped\");\n }\n\n /** Build the binlog stream. Overridable as a test seam. */\n protected createBinlogStream(): BinlogStream {\n return createZongjiBinlogStream(this.binlog);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n position: BinlogPosition,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(position);\n } catch (err) {\n // A failure here MUST NOT advance the position: the commit is re-streamed\n // and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"binlog batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/**\n * Real binlog stream backed by @vlasky/zongji. Covered by the integration suite\n * (it needs a live MySQL with binlog enabled); unit tests use a fake stream.\n */\nfunction createZongjiBinlogStream(config: BinlogReplicationConfig): BinlogStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let zongji: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: BinlogStreamHandlers): Promise<void> {\n const ZongJi = await importZongji();\n zongji = new ZongJi({\n host: config.host,\n port: config.port ?? 3306,\n user: config.user,\n password: config.password,\n serverId: config.serverId ?? deriveServerId(),\n });\n\n // Track the latest position seen per transaction so a single ROTATE\n // followed by writeRows then xid (commit) gives us a coherent commit point.\n let current: BinlogPosition = config.startPosition ?? {\n filename: \"\",\n position: 0,\n };\n\n zongji.on(\"binlog\", (evt: BinlogEventLike) => {\n const name = evt.getEventName?.();\n if (name === \"rotate\") {\n current = {\n filename: (evt.nextBinlog as string) ?? current.filename,\n position: (evt.position as number) ?? 0,\n };\n return;\n }\n if (name === \"writerows\") {\n if (\n (evt.tableMap as { [k: string]: { tableName: string } } | undefined) &&\n !rowsAreFor(evt, table)\n ) {\n return;\n }\n const rows = (evt.rows as Array<Record<string, unknown>>) ?? [];\n for (const raw of rows) {\n handlers.onInsert({\n position: current,\n row: normalizeRow(raw),\n });\n }\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n return;\n }\n if (name === \"xid\" || name === \"query\") {\n // xid = commit (InnoDB); query may carry BEGIN/COMMIT for statement-based.\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n void handlers.onCommit(current);\n }\n });\n\n zongji.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n });\n\n const startOpts: Record<string, unknown> = {\n includeEvents: [\"tablemap\", \"writerows\", \"xid\", \"rotate\", \"query\"],\n includeSchema: { [config.database]: [table] },\n };\n if (config.startPosition) {\n startOpts[\"filename\"] = config.startPosition.filename;\n startOpts[\"position\"] = config.startPosition.position;\n }\n zongji.start(startOpts);\n },\n async acknowledge(_position: BinlogPosition): Promise<void> {\n // MySQL has no native server-side ack. Position tracking lives in the\n // user's KV store via the onCommit hook (or this is replaced by a custom\n // BinlogStream implementation).\n },\n async stop(): Promise<void> {\n stopped = true;\n if (zongji) zongji.stop();\n },\n };\n}\n\n/** Minimal shape of a zongji binlog event we look at. */\ninterface BinlogEventLike {\n getEventName?(): string;\n nextBinlog?: string;\n position?: number;\n nextPosition?: number;\n tableMap?: Record<string, { tableName: string }>;\n rows?: Array<Record<string, unknown>>;\n}\n\nfunction rowsAreFor(evt: BinlogEventLike, table: string): boolean {\n const map = evt.tableMap ?? {};\n for (const tid of Object.keys(map)) {\n const meta = map[tid];\n if (meta?.tableName === table) {\n // zongji exposes schemaName inconsistently across versions; checking the\n // table name alone matches the includeSchema filter we asked for.\n return true;\n }\n }\n // If we have no tableMap context yet (rotate-after-restart edge), accept.\n return Object.keys(map).length === 0 ? true : false;\n}\n\nfunction normalizeRow(raw: Record<string, unknown>): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : (v ?? null);\n const headers = json(raw[\"headers\"]) as Record<string, string> | null;\n return {\n id: String(raw[\"id\"]),\n message_id: String(raw[\"message_id\"] ?? \"\"),\n aggregate_type: String(raw[\"aggregate_type\"] ?? \"\"),\n aggregate_id: String(raw[\"aggregate_id\"] ?? \"\"),\n topic: String(raw[\"topic\"] ?? \"\"),\n key: (raw[\"key\"] as string | null) ?? null,\n payload: json(raw[\"payload\"]),\n headers,\n trace_id: (raw[\"trace_id\"] as string | null) ?? null,\n status: Number(raw[\"status\"] ?? 0),\n attempts: Number(raw[\"attempts\"] ?? 0),\n next_retry_at: raw[\"next_retry_at\"]\n ? new Date(raw[\"next_retry_at\"] as string | number | Date)\n : null,\n created_at: raw[\"created_at\"]\n ? new Date(raw[\"created_at\"] as string | number | Date)\n : new Date(0),\n processed_at: raw[\"processed_at\"]\n ? new Date(raw[\"processed_at\"] as string | number | Date)\n : null,\n };\n}\n\n/** Deterministic serverId derived from PID so two reader processes on one host\n * do not clash. Override via {@link BinlogReplicationConfig.serverId}. */\nfunction deriveServerId(): number {\n const pid = typeof process !== \"undefined\" ? process.pid : 1;\n // Keep in the safe 1..2^32-1 range; bias well above real replica server-ids.\n return (1_000_000 + (pid % 1_000_000)) >>> 0;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nasync function importZongji(): Promise<any> {\n try {\n // @vlasky/zongji is an optional peer dep — typed via a `@ts-ignore`\n // because users without the binlog relay should not need the type\n // declarations installed.\n // @ts-ignore -- optional peer dep, types not required for compilation\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod: any = await import(\"@vlasky/zongji\");\n return mod.default ?? mod;\n } catch {\n throw new Error(\n 'Binlog relay needs the \"@vlasky/zongji\" package. Run: npm i @vlasky/zongji',\n );\n }\n}\n"],"mappings":"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","names":["import_core","import_core"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -102,6 +102,12 @@ declare class MysqlStore implements OutboxStore {
|
|
|
102
102
|
* A row stuck in `processing` longer than claimTimeoutMs is treated as due
|
|
103
103
|
* again (its owning relay is presumed dead), which keeps a crash between
|
|
104
104
|
* claim and ack from orphaning messages.
|
|
105
|
+
*
|
|
106
|
+
* ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —
|
|
107
|
+
* MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented
|
|
108
|
+
* `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package
|
|
109
|
+
* README is the supported workaround (one-time `claim_token` column
|
|
110
|
+
* addition + a custom claim path). Throughput trades for engine support.
|
|
105
111
|
*/
|
|
106
112
|
claimBatch(batchSize: number): Promise<OutboxRecord[]>;
|
|
107
113
|
markDone(recordIds: string[]): Promise<void>;
|
package/dist/index.d.ts
CHANGED
|
@@ -102,6 +102,12 @@ declare class MysqlStore implements OutboxStore {
|
|
|
102
102
|
* A row stuck in `processing` longer than claimTimeoutMs is treated as due
|
|
103
103
|
* again (its owning relay is presumed dead), which keeps a crash between
|
|
104
104
|
* claim and ack from orphaning messages.
|
|
105
|
+
*
|
|
106
|
+
* ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —
|
|
107
|
+
* MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented
|
|
108
|
+
* `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package
|
|
109
|
+
* README is the supported workaround (one-time `claim_token` column
|
|
110
|
+
* addition + a custom claim path). Throughput trades for engine support.
|
|
105
111
|
*/
|
|
106
112
|
claimBatch(batchSize: number): Promise<OutboxRecord[]>;
|
|
107
113
|
markDone(recordIds: string[]): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -19,8 +19,8 @@ function rowToRecord(row) {
|
|
|
19
19
|
aggregateType: row.aggregate_type,
|
|
20
20
|
aggregateId: row.aggregate_id,
|
|
21
21
|
key: row.key,
|
|
22
|
-
payload: row.payload,
|
|
23
|
-
headers: row.headers ?? {},
|
|
22
|
+
payload: parseJsonField(row.payload),
|
|
23
|
+
headers: parseJsonField(row.headers) ?? {},
|
|
24
24
|
traceId: row.trace_id,
|
|
25
25
|
status,
|
|
26
26
|
attempts: row.attempts,
|
|
@@ -29,6 +29,11 @@ function rowToRecord(row) {
|
|
|
29
29
|
processedAt: row.processed_at
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
function parseJsonField(value) {
|
|
33
|
+
if (value === null || value === void 0) return value;
|
|
34
|
+
if (typeof value === "string") return JSON.parse(value);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
// src/store.ts
|
|
34
39
|
var DEFAULT_CLAIM_TIMEOUT_MS = 6e4;
|
|
@@ -99,6 +104,12 @@ var MysqlStore = class {
|
|
|
99
104
|
* A row stuck in `processing` longer than claimTimeoutMs is treated as due
|
|
100
105
|
* again (its owning relay is presumed dead), which keeps a crash between
|
|
101
106
|
* claim and ack from orphaning messages.
|
|
107
|
+
*
|
|
108
|
+
* ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —
|
|
109
|
+
* MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented
|
|
110
|
+
* `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package
|
|
111
|
+
* README is the supported workaround (one-time `claim_token` column
|
|
112
|
+
* addition + a custom claim path). Throughput trades for engine support.
|
|
102
113
|
*/
|
|
103
114
|
async claimBatch(batchSize) {
|
|
104
115
|
const conn = await this.pool.getConnection();
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/store.ts","../src/row.ts","../src/binlog-relay.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal mysql2/promise query surface — satisfied by both `Pool` and\n * `PoolConnection`, so `enqueue` can run inside a caller-supplied transaction.\n * `query` returns the canonical `[rows, fields]` tuple shape of the driver.\n */\nexport interface MysqlQueryable {\n query(sql: string, values?: unknown[]): Promise<[unknown, unknown]>;\n}\n\n/**\n * Minimal mysql2/promise PoolConnection surface used by {@link MysqlStore.claimBatch}\n * for its internal `BEGIN ... SELECT FOR UPDATE SKIP LOCKED ... UPDATE ... COMMIT`\n * dance — MySQL has no `RETURNING`, so we cannot fold the claim into a single\n * statement the way the Postgres adapter does.\n */\nexport interface MysqlConnection extends MysqlQueryable {\n beginTransaction(): Promise<void>;\n commit(): Promise<void>;\n rollback(): Promise<void>;\n release(): void;\n}\n\n/**\n * Minimal mysql2/promise Pool surface — `query` for stateless ops and\n * `getConnection` for the transactional claim path.\n */\nexport interface MysqlPool extends MysqlQueryable {\n getConnection(): Promise<MysqlConnection>;\n}\n\nexport interface MysqlStoreOptions {\n /** A connected mysql2/promise Pool used by the relay for claim/ack queries. */\n pool: MysqlPool;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Reserved for future streaming\n * relay modes that own the pending path. Default false.\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\nconst PROCESSING = OUTBOX_STATUS_CODE.processing;\nconst DONE = OUTBOX_STATUS_CODE.done;\n\nexport class MysqlStore implements OutboxStore {\n private readonly pool: MysqlPool;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: MysqlStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated (or caller-supplied) message id.\n */\n async enqueue(\n tx: MysqlQueryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n // MySQL has no UUID-generating default; mint client-side. crypto.randomUUID\n // is RFC 4122 v4 and available in Node 18+ (our minimum engine).\n const messageId = msg.messageId ?? randomUUID();\n\n const sql = `\n INSERT INTO \\`${this.table}\\`\n (message_id, aggregate_type, aggregate_id, topic, \\`key\\`, payload, headers, trace_id, status)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, ?, 0)\n `;\n await tx.query(sql, [\n messageId,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return messageId;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at.\n *\n * MySQL has no `RETURNING`, so the claim is a three-step transaction:\n *\n * 1. SELECT due ids FOR UPDATE SKIP LOCKED — the locks are held until COMMIT.\n * 2. UPDATE ... WHERE id IN (...) — atomically flip status + claimed_at.\n * 3. SELECT * WHERE id IN (...) — read the (now updated) rows back.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which keeps a crash between\n * claim and ack from orphaning messages.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const conn = await this.pool.getConnection();\n try {\n await conn.beginTransaction();\n\n // The reaper window cutoff is computed SERVER-SIDE\n // (`DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)`). Sending a JS Date as a\n // bound value goes through mysql2's local-time serialiser, while NOW(3)\n // returns the connection's clock; the two can drift by the host's TZ\n // offset and the reaper fires (or fails to fire) at the wrong instant.\n // Server-side INTERVAL sidesteps that entirely.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0 OR \";\n\n const selectDue = `\n SELECT o.id\n FROM \\`${this.table}\\` o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW(3)))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= DATE_SUB(NOW(3), INTERVAL ? MICROSECOND))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM \\`${this.table}\\` earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT ?\n FOR UPDATE SKIP LOCKED\n `;\n const [dueRows] = await conn.query(selectDue, [\n this.claimTimeoutMs * 1000,\n batchSize,\n ]);\n const ids = (dueRows as Array<{ id: number | string | bigint }>).map(\n (r) => r.id,\n );\n\n if (ids.length === 0) {\n await conn.commit();\n return [];\n }\n\n await conn.query(\n `UPDATE \\`${this.table}\\` SET status = ${PROCESSING}, claimed_at = NOW(3) WHERE id IN (?)`,\n [ids],\n );\n\n const [rows] = await conn.query(\n `SELECT id, message_id, aggregate_type, aggregate_id, topic, \\`key\\`,\n payload, headers, trace_id, status, attempts, next_retry_at,\n created_at, processed_at\n FROM \\`${this.table}\\`\n WHERE id IN (?)\n ORDER BY id`,\n [ids],\n );\n\n await conn.commit();\n return (rows as unknown as OutboxRow[]).map(rowToRecord);\n } catch (err) {\n try {\n await conn.rollback();\n } catch {\n // Connection may already be dead; let the original error surface.\n }\n throw err;\n } finally {\n conn.release();\n }\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n await this.pool.query(\n `UPDATE \\`${this.table}\\` SET status = ${DONE}, processed_at = NOW(3) WHERE id IN (?)`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ?,\n attempts = attempts + 1,\n next_retry_at = ?\n WHERE id = ?`,\n [code, nextRetryAt, recordId],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = ?\n WHERE id = ?`,\n [retryAt, recordId],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place for\n * post-mortem.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const batchSize = opts.batchSize ?? 1000;\n let total = 0;\n for (;;) {\n // Same TZ-safety as claimBatch: compute the cutoff server-side via\n // INTERVAL so the comparison never drifts vs the host's local time.\n const [result] = await this.pool.query(\n `DELETE FROM \\`${this.table}\\`\n WHERE status = ${DONE}\n AND processed_at IS NOT NULL\n AND processed_at < DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)\n ORDER BY id\n LIMIT ?`,\n [opts.olderThanMs * 1000, batchSize],\n );\n const deleted = (result as { affectedRows?: number }).affectedRows ?? 0;\n total += deleted;\n if (deleted < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from MySQL (snake_case columns). */\nexport interface OutboxRow {\n id: number | string | bigint;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/**\n * Map a raw DB row to the broker-agnostic core record. `id` is stringified\n * because mysql2 may return BIGINT either as a JS number, string, or bigint\n * depending on driver options — the core contract is `string`.\n */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: row.payload,\n headers: row.headers ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Connection + reader settings for the MySQL binlog stream.\n *\n * Requires the server to be configured for row-based replication:\n * - `binlog_format=ROW`\n * - `binlog_row_image=FULL`\n * - `gtid_mode=ON` (optional but recommended for resumption)\n * and a user with `REPLICATION SLAVE` + `REPLICATION CLIENT` grants.\n */\nexport interface BinlogReplicationConfig {\n host: string;\n port?: number;\n user: string;\n password: string;\n /** Database that owns the outbox table. Used to scope row events. */\n database: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n /**\n * MySQL replica server-id. MUST be unique within the cluster (real replicas\n * + every binlog reader). Defaults to a deterministic value derived from\n * pid; override in clustered setups.\n */\n serverId?: number;\n /**\n * Resume from this binlog position. If omitted, the reader starts from the\n * current end-of-log (\"tail\" mode) — new rows only. Persist the position\n * yourself (e.g. in your app's KV store) by subscribing to commit events.\n */\n startPosition?: BinlogPosition;\n}\n\n/** A binlog coordinate. (file, offset) — MySQL's analogue of a Postgres LSN. */\nexport interface BinlogPosition {\n readonly filename: string;\n readonly position: number;\n}\n\n/** A decoded INSERT on the outbox table, with the binlog position it occurred at. */\nexport interface DecodedInsert {\n readonly position: BinlogPosition;\n readonly row: OutboxRow;\n}\n\nexport interface BinlogStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (position: BinlogPosition) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/**\n * The binlog source the streaming relay consumes. Implemented over @vlasky/zongji\n * by default; overridable as a test seam, or to plug a custom reader / managed\n * service (RDS Streams, ProxySQL bridge, etc.).\n */\nexport interface BinlogStream {\n start(handlers: BinlogStreamHandlers): Promise<void>;\n /**\n * Note the position has been durably handled downstream. MySQL has no native\n * server-side ack like logical replication, so the default implementation\n * just tracks the position in memory. Override to persist it.\n */\n acknowledge(position: BinlogPosition): Promise<void>;\n stop(): Promise<void>;\n}\n\nexport interface MysqlBinlogRelayOptions {\n /**\n * Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the binlog stream).\n */\n store: OutboxStore;\n publisher: Publisher;\n binlog: BinlogReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a single transaction's worth of events. Default 100. */\n batchSize?: number;\n}\n\n/**\n * Publishes outbox events straight from the MySQL binlog (row-based), with no\n * claim query on the happy path. Failures are demoted to `failed` and drained\n * by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's position is acknowledged only after its batch's\n * side effects commit, so a crash re-streams and re-publishes (a duplicate\n * idempotent consumers absorb). Ordering is best-effort per aggregate — a\n * retried failure lands after later same-aggregate rows; use the polling\n * relay (default `Relay` + `MysqlStore`) when strict ordering matters.\n */\nexport class MysqlBinlogRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly binlog: BinlogReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: BinlogStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: MysqlBinlogRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.binlog = opts.binlog;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start();\n this.stream = this.createBinlogStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (position) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, position));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"binlog stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"mysql binlog relay started\", {\n database: this.binlog.database,\n table: this.binlog.table ?? \"outbox\",\n });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail;\n await this.retryRelay.stop();\n this.log.info(\"mysql binlog relay stopped\");\n }\n\n /** Build the binlog stream. Overridable as a test seam. */\n protected createBinlogStream(): BinlogStream {\n return createZongjiBinlogStream(this.binlog);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n position: BinlogPosition,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(position);\n } catch (err) {\n // A failure here MUST NOT advance the position: the commit is re-streamed\n // and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"binlog batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/**\n * Real binlog stream backed by @vlasky/zongji. Covered by the integration suite\n * (it needs a live MySQL with binlog enabled); unit tests use a fake stream.\n */\nfunction createZongjiBinlogStream(config: BinlogReplicationConfig): BinlogStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let zongji: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: BinlogStreamHandlers): Promise<void> {\n const ZongJi = await importZongji();\n zongji = new ZongJi({\n host: config.host,\n port: config.port ?? 3306,\n user: config.user,\n password: config.password,\n serverId: config.serverId ?? deriveServerId(),\n });\n\n // Track the latest position seen per transaction so a single ROTATE\n // followed by writeRows then xid (commit) gives us a coherent commit point.\n let current: BinlogPosition = config.startPosition ?? {\n filename: \"\",\n position: 0,\n };\n\n zongji.on(\"binlog\", (evt: BinlogEventLike) => {\n const name = evt.getEventName?.();\n if (name === \"rotate\") {\n current = {\n filename: (evt.nextBinlog as string) ?? current.filename,\n position: (evt.position as number) ?? 0,\n };\n return;\n }\n if (name === \"writerows\") {\n if (\n (evt.tableMap as { [k: string]: { tableName: string } } | undefined) &&\n !rowsAreFor(evt, table)\n ) {\n return;\n }\n const rows = (evt.rows as Array<Record<string, unknown>>) ?? [];\n for (const raw of rows) {\n handlers.onInsert({\n position: current,\n row: normalizeRow(raw),\n });\n }\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n return;\n }\n if (name === \"xid\" || name === \"query\") {\n // xid = commit (InnoDB); query may carry BEGIN/COMMIT for statement-based.\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n void handlers.onCommit(current);\n }\n });\n\n zongji.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n });\n\n const startOpts: Record<string, unknown> = {\n includeEvents: [\"tablemap\", \"writerows\", \"xid\", \"rotate\", \"query\"],\n includeSchema: { [config.database]: [table] },\n };\n if (config.startPosition) {\n startOpts[\"filename\"] = config.startPosition.filename;\n startOpts[\"position\"] = config.startPosition.position;\n }\n zongji.start(startOpts);\n },\n async acknowledge(_position: BinlogPosition): Promise<void> {\n // MySQL has no native server-side ack. Position tracking lives in the\n // user's KV store via the onCommit hook (or this is replaced by a custom\n // BinlogStream implementation).\n },\n async stop(): Promise<void> {\n stopped = true;\n if (zongji) zongji.stop();\n },\n };\n}\n\n/** Minimal shape of a zongji binlog event we look at. */\ninterface BinlogEventLike {\n getEventName?(): string;\n nextBinlog?: string;\n position?: number;\n nextPosition?: number;\n tableMap?: Record<string, { tableName: string }>;\n rows?: Array<Record<string, unknown>>;\n}\n\nfunction rowsAreFor(evt: BinlogEventLike, table: string): boolean {\n const map = evt.tableMap ?? {};\n for (const tid of Object.keys(map)) {\n const meta = map[tid];\n if (meta?.tableName === table) {\n // zongji exposes schemaName inconsistently across versions; checking the\n // table name alone matches the includeSchema filter we asked for.\n return true;\n }\n }\n // If we have no tableMap context yet (rotate-after-restart edge), accept.\n return Object.keys(map).length === 0 ? true : false;\n}\n\nfunction normalizeRow(raw: Record<string, unknown>): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : (v ?? null);\n const headers = json(raw[\"headers\"]) as Record<string, string> | null;\n return {\n id: String(raw[\"id\"]),\n message_id: String(raw[\"message_id\"] ?? \"\"),\n aggregate_type: String(raw[\"aggregate_type\"] ?? \"\"),\n aggregate_id: String(raw[\"aggregate_id\"] ?? \"\"),\n topic: String(raw[\"topic\"] ?? \"\"),\n key: (raw[\"key\"] as string | null) ?? null,\n payload: json(raw[\"payload\"]),\n headers,\n trace_id: (raw[\"trace_id\"] as string | null) ?? null,\n status: Number(raw[\"status\"] ?? 0),\n attempts: Number(raw[\"attempts\"] ?? 0),\n next_retry_at: raw[\"next_retry_at\"]\n ? new Date(raw[\"next_retry_at\"] as string | number | Date)\n : null,\n created_at: raw[\"created_at\"]\n ? new Date(raw[\"created_at\"] as string | number | Date)\n : new Date(0),\n processed_at: raw[\"processed_at\"]\n ? new Date(raw[\"processed_at\"] as string | number | Date)\n : null,\n };\n}\n\n/** Deterministic serverId derived from PID so two reader processes on one host\n * do not clash. Override via {@link BinlogReplicationConfig.serverId}. */\nfunction deriveServerId(): number {\n const pid = typeof process !== \"undefined\" ? process.pid : 1;\n // Keep in the safe 1..2^32-1 range; bias well above real replica server-ids.\n return (1_000_000 + (pid % 1_000_000)) >>> 0;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nasync function importZongji(): Promise<any> {\n try {\n // @vlasky/zongji is an optional peer dep — typed via a `@ts-ignore`\n // because users without the binlog relay should not need the type\n // declarations installed.\n // @ts-ignore -- optional peer dep, types not required for compilation\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod: any = await import(\"@vlasky/zongji\");\n return mod.default ?? mod;\n } catch {\n throw new Error(\n 'Binlog relay needs the \"@vlasky/zongji\" package. Run: npm i @vlasky/zongji',\n );\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/store.ts","../src/row.ts","../src/binlog-relay.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type {\n OutboxMessageInput,\n OutboxRecord,\n OutboxStore,\n Tracing,\n} from \"@eventferry/core\";\nimport { OUTBOX_STATUS_CODE } from \"@eventferry/core\";\nimport { assertIdent } from \"./ident.js\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Minimal mysql2/promise query surface — satisfied by both `Pool` and\n * `PoolConnection`, so `enqueue` can run inside a caller-supplied transaction.\n * `query` returns the canonical `[rows, fields]` tuple shape of the driver.\n */\nexport interface MysqlQueryable {\n query(sql: string, values?: unknown[]): Promise<[unknown, unknown]>;\n}\n\n/**\n * Minimal mysql2/promise PoolConnection surface used by {@link MysqlStore.claimBatch}\n * for its internal `BEGIN ... SELECT FOR UPDATE SKIP LOCKED ... UPDATE ... COMMIT`\n * dance — MySQL has no `RETURNING`, so we cannot fold the claim into a single\n * statement the way the Postgres adapter does.\n */\nexport interface MysqlConnection extends MysqlQueryable {\n beginTransaction(): Promise<void>;\n commit(): Promise<void>;\n rollback(): Promise<void>;\n release(): void;\n}\n\n/**\n * Minimal mysql2/promise Pool surface — `query` for stateless ops and\n * `getConnection` for the transactional claim path.\n */\nexport interface MysqlPool extends MysqlQueryable {\n getConnection(): Promise<MysqlConnection>;\n}\n\nexport interface MysqlStoreOptions {\n /** A connected mysql2/promise Pool used by the relay for claim/ack queries. */\n pool: MysqlPool;\n /** Outbox table name. Default \"outbox\". */\n table?: string;\n /**\n * Visibility timeout (ms) after which a row stuck in `processing` is\n * reclaimable by any relay. Guards against permanently-orphaned rows when\n * a relay crashes between claiming and acking. MUST be comfortably larger\n * than the worst-case publish latency, otherwise a slow-but-alive relay's\n * in-flight rows get reclaimed and re-published (a duplicate). Default 60s.\n */\n claimTimeoutMs?: number;\n /**\n * When true, `claimBatch` never claims `pending`(0) rows — only `failed`(3)\n * (retry due) and timed-out `processing`(1). Reserved for future streaming\n * relay modes that own the pending path. Default false.\n */\n claimFailedOnly?: boolean;\n /**\n * Optional trace-context propagator. When set, `enqueue` captures the active\n * W3C trace context into the row's headers, so it rides along to the published\n * message and the consumer can continue the trace. See {@link Tracing}.\n */\n tracing?: Tracing;\n}\n\nexport interface PurgeDoneOptions {\n /** Delete `done` rows whose processed_at is older than this (milliseconds). */\n olderThanMs: number;\n /** Rows deleted per batch. Default 1000. */\n batchSize?: number;\n /** Optional cap on the total rows deleted in this call. */\n maxRows?: number;\n}\n\nconst DEFAULT_CLAIM_TIMEOUT_MS = 60_000;\nconst PROCESSING = OUTBOX_STATUS_CODE.processing;\nconst DONE = OUTBOX_STATUS_CODE.done;\n\nexport class MysqlStore implements OutboxStore {\n private readonly pool: MysqlPool;\n private readonly table: string;\n private readonly claimTimeoutMs: number;\n private readonly claimFailedOnly: boolean;\n private readonly tracing: Tracing | null;\n\n constructor(opts: MysqlStoreOptions) {\n this.pool = opts.pool;\n this.table = assertIdent(opts.table ?? \"outbox\");\n this.claimTimeoutMs = opts.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;\n this.claimFailedOnly = opts.claimFailedOnly ?? false;\n this.tracing = opts.tracing ?? null;\n }\n\n /**\n * Insert a message into the outbox. MUST be called with the same\n * transaction (`tx`) that persists the business state, so the event\n * and the state commit atomically.\n *\n * @returns the generated (or caller-supplied) message id.\n */\n async enqueue(\n tx: MysqlQueryable,\n msg: OutboxMessageInput & { traceId?: string },\n ): Promise<string> {\n // Copy (never mutate the caller's object) and let tracing capture the\n // active W3C context into the headers, so it rides along to the broker.\n const headers = { ...(msg.headers ?? {}) };\n this.tracing?.inject(headers);\n\n // MySQL has no UUID-generating default; mint client-side. crypto.randomUUID\n // is RFC 4122 v4 and available in Node 18+ (our minimum engine).\n const messageId = msg.messageId ?? randomUUID();\n\n const sql = `\n INSERT INTO \\`${this.table}\\`\n (message_id, aggregate_type, aggregate_id, topic, \\`key\\`, payload, headers, trace_id, status)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, ?, 0)\n `;\n await tx.query(sql, [\n messageId,\n msg.aggregateType,\n msg.aggregateId,\n msg.topic,\n msg.key ?? null,\n JSON.stringify(msg.payload),\n JSON.stringify(headers),\n msg.traceId ?? null,\n ]);\n return messageId;\n }\n\n /**\n * Claim up to `batchSize` due rows using FOR UPDATE SKIP LOCKED so that\n * concurrent relay instances never contend for the same rows. Claimed\n * rows are flipped to status=processing(1) and stamped with claimed_at.\n *\n * MySQL has no `RETURNING`, so the claim is a three-step transaction:\n *\n * 1. SELECT due ids FOR UPDATE SKIP LOCKED — the locks are held until COMMIT.\n * 2. UPDATE ... WHERE id IN (...) — atomically flip status + claimed_at.\n * 3. SELECT * WHERE id IN (...) — read the (now updated) rows back.\n *\n * Strict per-aggregate ordering is enforced by only claiming a row when it\n * is the *head* of its aggregate — i.e. no earlier row (lower id) for the\n * same aggregate_id is still unfinished (pending/processing/failed). This\n * guarantees:\n * - at most one in-flight row per aggregate at any time (across relays),\n * - a failed row blocks its successors until it is done or dead,\n * - within a single batch every aggregate appears at most once, so the\n * broker can never observe two same-key messages out of order.\n *\n * A row stuck in `processing` longer than claimTimeoutMs is treated as due\n * again (its owning relay is presumed dead), which keeps a crash between\n * claim and ack from orphaning messages.\n *\n * ENGINE FLOOR: requires `SELECT ... FOR UPDATE SKIP LOCKED` —\n * MySQL 8.0.1+ or MariaDB 10.6+. On older engines, the documented\n * `UPDATE ... ORDER BY id LIMIT n` + claim-token fallback in the package\n * README is the supported workaround (one-time `claim_token` column\n * addition + a custom claim path). Throughput trades for engine support.\n */\n async claimBatch(batchSize: number): Promise<OutboxRecord[]> {\n const conn = await this.pool.getConnection();\n try {\n await conn.beginTransaction();\n\n // The reaper window cutoff is computed SERVER-SIDE\n // (`DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)`). Sending a JS Date as a\n // bound value goes through mysql2's local-time serialiser, while NOW(3)\n // returns the connection's clock; the two can drift by the host's TZ\n // offset and the reaper fires (or fails to fire) at the wrong instant.\n // Server-side INTERVAL sidesteps that entirely.\n const pendingClause = this.claimFailedOnly ? \"\" : \"o.status = 0 OR \";\n\n const selectDue = `\n SELECT o.id\n FROM \\`${this.table}\\` o\n WHERE (\n ${pendingClause}(o.status = 3 AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW(3)))\n OR (o.status = 1 AND o.claimed_at IS NOT NULL\n AND o.claimed_at <= DATE_SUB(NOW(3), INTERVAL ? MICROSECOND))\n )\n AND NOT EXISTS (\n SELECT 1\n FROM \\`${this.table}\\` earlier\n WHERE earlier.aggregate_id = o.aggregate_id\n AND earlier.id < o.id\n AND earlier.status IN (0, 1, 3)\n )\n ORDER BY o.id\n LIMIT ?\n FOR UPDATE SKIP LOCKED\n `;\n const [dueRows] = await conn.query(selectDue, [\n this.claimTimeoutMs * 1000,\n batchSize,\n ]);\n const ids = (dueRows as Array<{ id: number | string | bigint }>).map(\n (r) => r.id,\n );\n\n if (ids.length === 0) {\n await conn.commit();\n return [];\n }\n\n await conn.query(\n `UPDATE \\`${this.table}\\` SET status = ${PROCESSING}, claimed_at = NOW(3) WHERE id IN (?)`,\n [ids],\n );\n\n const [rows] = await conn.query(\n `SELECT id, message_id, aggregate_type, aggregate_id, topic, \\`key\\`,\n payload, headers, trace_id, status, attempts, next_retry_at,\n created_at, processed_at\n FROM \\`${this.table}\\`\n WHERE id IN (?)\n ORDER BY id`,\n [ids],\n );\n\n await conn.commit();\n return (rows as unknown as OutboxRow[]).map(rowToRecord);\n } catch (err) {\n try {\n await conn.rollback();\n } catch {\n // Connection may already be dead; let the original error surface.\n }\n throw err;\n } finally {\n conn.release();\n }\n }\n\n async markDone(recordIds: string[]): Promise<void> {\n if (recordIds.length === 0) return;\n await this.pool.query(\n `UPDATE \\`${this.table}\\` SET status = ${DONE}, processed_at = NOW(3) WHERE id IN (?)`,\n [recordIds],\n );\n }\n\n async markFailed(\n recordId: string,\n nextRetryAt: Date | null,\n status: \"failed\" | \"dead\",\n ): Promise<void> {\n const code = OUTBOX_STATUS_CODE[status];\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ?,\n attempts = attempts + 1,\n next_retry_at = ?\n WHERE id = ?`,\n [code, nextRetryAt, recordId],\n );\n }\n\n /**\n * Re-queue a record to `failed` with the given `retryAt` **without\n * bumping attempts** — used by the relay for backpressure handling.\n * Also clears `claimed_at` so the reaper does not race the row.\n */\n async requeue(recordId: string, retryAt: Date): Promise<void> {\n const failed = OUTBOX_STATUS_CODE.failed;\n await this.pool.query(\n `UPDATE \\`${this.table}\\`\n SET status = ${failed},\n claimed_at = NULL,\n next_retry_at = ?\n WHERE id = ?`,\n [retryAt, recordId],\n );\n }\n\n /**\n * Delete `done` rows whose `processed_at` is older than `olderThanMs`, in\n * batches (to avoid long locks / table bloat). Returns the total deleted.\n * Run periodically (e.g. from a cron) — there is no built-in scheduler.\n * Note: only `done`(2) rows are purged; `dead` rows are left in place for\n * post-mortem.\n */\n async purgeDone(opts: PurgeDoneOptions): Promise<number> {\n const batchSize = opts.batchSize ?? 1000;\n let total = 0;\n for (;;) {\n // Same TZ-safety as claimBatch: compute the cutoff server-side via\n // INTERVAL so the comparison never drifts vs the host's local time.\n const [result] = await this.pool.query(\n `DELETE FROM \\`${this.table}\\`\n WHERE status = ${DONE}\n AND processed_at IS NOT NULL\n AND processed_at < DATE_SUB(NOW(3), INTERVAL ? MICROSECOND)\n ORDER BY id\n LIMIT ?`,\n [opts.olderThanMs * 1000, batchSize],\n );\n const deleted = (result as { affectedRows?: number }).affectedRows ?? 0;\n total += deleted;\n if (deleted < batchSize) break;\n if (opts.maxRows !== undefined && total >= opts.maxRows) break;\n }\n return total;\n }\n}\n","import type { OutboxRecord, OutboxStatus } from \"@eventferry/core\";\nimport { OUTBOX_STATUS_FROM_CODE } from \"@eventferry/core\";\n\n/** Raw outbox row shape as read from MySQL (snake_case columns). */\nexport interface OutboxRow {\n id: number | string | bigint;\n message_id: string;\n aggregate_type: string;\n aggregate_id: string;\n topic: string;\n key: string | null;\n payload: unknown;\n headers: Record<string, string> | null;\n trace_id: string | null;\n status: number;\n attempts: number;\n next_retry_at: Date | null;\n created_at: Date;\n processed_at: Date | null;\n}\n\n/**\n * Map a raw DB row to the broker-agnostic core record. `id` is stringified\n * because mysql2 may return BIGINT either as a JS number, string, or bigint\n * depending on driver options — the core contract is `string`.\n *\n * `payload` / `headers` are JSON columns, but driver behavior splits:\n * - MySQL 8 has a native JSON type, and the `mysql2` driver auto-parses\n * it to a JS object / array on read.\n * - MariaDB exposes JSON as a `LONGTEXT` alias with a CHECK constraint —\n * no native type → the driver returns the raw string.\n * To stay engine-agnostic we re-parse strings here; objects pass through\n * untouched. Belt and suspenders.\n */\nexport function rowToRecord(row: OutboxRow): OutboxRecord {\n const status: OutboxStatus = OUTBOX_STATUS_FROM_CODE[row.status] ?? \"pending\";\n return {\n id: String(row.id),\n messageId: row.message_id,\n topic: row.topic,\n aggregateType: row.aggregate_type,\n aggregateId: row.aggregate_id,\n key: row.key,\n payload: parseJsonField<unknown>(row.payload),\n headers: parseJsonField<Record<string, string> | null>(row.headers) ?? {},\n traceId: row.trace_id,\n status,\n attempts: row.attempts,\n nextRetryAt: row.next_retry_at,\n createdAt: row.created_at,\n processedAt: row.processed_at,\n };\n}\n\n/**\n * Defensive JSON parser for fields the driver may or may not have parsed\n * (MySQL 8 yes, MariaDB no). Strings get JSON.parse'd; everything else\n * passes through. Throws if the string is malformed — a malformed JSON\n * payload in the outbox would be a write-side bug, surface it loudly.\n */\nfunction parseJsonField<T>(value: unknown): T {\n if (value === null || value === undefined) return value as T;\n if (typeof value === \"string\") return JSON.parse(value) as T;\n return value as T;\n}\n","import {\n buildPublishable,\n ConsoleLogger,\n JsonSerializer,\n Relay,\n} from \"@eventferry/core\";\nimport type {\n DlqConfig,\n Logger,\n OutboxStore,\n Publisher,\n RelayHooks,\n RetryConfig,\n Serializer,\n} from \"@eventferry/core\";\nimport { rowToRecord, type OutboxRow } from \"./row.js\";\n\n/**\n * Connection + reader settings for the MySQL binlog stream.\n *\n * Requires the server to be configured for row-based replication:\n * - `binlog_format=ROW`\n * - `binlog_row_image=FULL`\n * - `gtid_mode=ON` (optional but recommended for resumption)\n * and a user with `REPLICATION SLAVE` + `REPLICATION CLIENT` grants.\n */\nexport interface BinlogReplicationConfig {\n host: string;\n port?: number;\n user: string;\n password: string;\n /** Database that owns the outbox table. Used to scope row events. */\n database: string;\n /** Outbox table to capture. Default \"outbox\". */\n table?: string;\n /**\n * MySQL replica server-id. MUST be unique within the cluster (real replicas\n * + every binlog reader). Defaults to a deterministic value derived from\n * pid; override in clustered setups.\n */\n serverId?: number;\n /**\n * Resume from this binlog position. If omitted, the reader starts from the\n * current end-of-log (\"tail\" mode) — new rows only. Persist the position\n * yourself (e.g. in your app's KV store) by subscribing to commit events.\n */\n startPosition?: BinlogPosition;\n}\n\n/** A binlog coordinate. (file, offset) — MySQL's analogue of a Postgres LSN. */\nexport interface BinlogPosition {\n readonly filename: string;\n readonly position: number;\n}\n\n/** A decoded INSERT on the outbox table, with the binlog position it occurred at. */\nexport interface DecodedInsert {\n readonly position: BinlogPosition;\n readonly row: OutboxRow;\n}\n\nexport interface BinlogStreamHandlers {\n onInsert: (insert: DecodedInsert) => void;\n onCommit: (position: BinlogPosition) => void | Promise<void>;\n onError: (err: Error) => void;\n}\n\n/**\n * The binlog source the streaming relay consumes. Implemented over @vlasky/zongji\n * by default; overridable as a test seam, or to plug a custom reader / managed\n * service (RDS Streams, ProxySQL bridge, etc.).\n */\nexport interface BinlogStream {\n start(handlers: BinlogStreamHandlers): Promise<void>;\n /**\n * Note the position has been durably handled downstream. MySQL has no native\n * server-side ack like logical replication, so the default implementation\n * just tracks the position in memory. Override to persist it.\n */\n acknowledge(position: BinlogPosition): Promise<void>;\n stop(): Promise<void>;\n}\n\nexport interface MysqlBinlogRelayOptions {\n /**\n * Outbox store. Construct with `{ claimFailedOnly: true }` so the internal\n * retry loop only drains failures (pending rows are owned by the binlog stream).\n */\n store: OutboxStore;\n publisher: Publisher;\n binlog: BinlogReplicationConfig;\n retry?: Partial<RetryConfig>;\n dlq?: DlqConfig;\n serializer?: Serializer;\n logger?: Logger;\n hooks?: RelayHooks;\n /** Poll interval (ms) for the internal failed-row retry loop. Default 5000. */\n failedPollIntervalMs?: number;\n /** Mark happy-path rows done (status=2) after publish. Default true. */\n markPublished?: boolean;\n /** Max rows published per chunk within a single transaction's worth of events. Default 100. */\n batchSize?: number;\n}\n\n/**\n * Publishes outbox events straight from the MySQL binlog (row-based), with no\n * claim query on the happy path. Failures are demoted to `failed` and drained\n * by an internal claim-based retry loop (the core `Relay` over a\n * `claimFailedOnly` store), reusing the existing backoff / DLQ / dead handling.\n *\n * At-least-once: a commit's position is acknowledged only after its batch's\n * side effects commit, so a crash re-streams and re-publishes (a duplicate\n * idempotent consumers absorb). Ordering is best-effort per aggregate — a\n * retried failure lands after later same-aggregate rows; use the polling\n * relay (default `Relay` + `MysqlStore`) when strict ordering matters.\n */\nexport class MysqlBinlogRelay {\n private readonly store: OutboxStore;\n private readonly publisher: Publisher;\n private readonly serializer: Serializer;\n private readonly log: Logger;\n private readonly hooks: RelayHooks;\n private readonly binlog: BinlogReplicationConfig;\n private readonly markPublished: boolean;\n private readonly batchSize: number;\n private readonly retryRelay: Relay;\n\n private stream: BinlogStream | null = null;\n private buffer: DecodedInsert[] = [];\n private tail: Promise<void> = Promise.resolve();\n private running = false;\n\n constructor(opts: MysqlBinlogRelayOptions) {\n this.store = opts.store;\n this.publisher = opts.publisher;\n this.serializer = opts.serializer ?? new JsonSerializer();\n this.log = opts.logger ?? new ConsoleLogger();\n this.hooks = opts.hooks ?? {};\n this.binlog = opts.binlog;\n this.markPublished = opts.markPublished ?? true;\n this.batchSize = opts.batchSize ?? 100;\n\n this.retryRelay = new Relay({\n store: opts.store,\n publisher: opts.publisher,\n retry: opts.retry,\n dlq: opts.dlq,\n serializer: this.serializer,\n logger: opts.logger,\n hooks: opts.hooks,\n pollIntervalMs: opts.failedPollIntervalMs ?? 5000,\n });\n }\n\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n await this.retryRelay.start();\n this.stream = this.createBinlogStream();\n await this.stream.start({\n onInsert: (insert) => {\n this.buffer.push(insert);\n },\n onCommit: (position) => {\n const batch = this.buffer;\n this.buffer = [];\n this.tail = this.tail.then(() => this.processBatch(batch, position));\n return this.tail;\n },\n onError: (err) => {\n this.log.error(\"binlog stream error\", { error: err.message });\n this.hooks.onError?.(err);\n },\n });\n this.log.info(\"mysql binlog relay started\", {\n database: this.binlog.database,\n table: this.binlog.table ?? \"outbox\",\n });\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n await this.stream?.stop();\n await this.tail;\n await this.retryRelay.stop();\n this.log.info(\"mysql binlog relay stopped\");\n }\n\n /** Build the binlog stream. Overridable as a test seam. */\n protected createBinlogStream(): BinlogStream {\n return createZongjiBinlogStream(this.binlog);\n }\n\n private async processBatch(\n batch: DecodedInsert[],\n position: BinlogPosition,\n ): Promise<void> {\n try {\n for (let i = 0; i < batch.length; i += this.batchSize) {\n await this.publishChunk(batch.slice(i, i + this.batchSize));\n }\n await this.stream?.acknowledge(position);\n } catch (err) {\n // A failure here MUST NOT advance the position: the commit is re-streamed\n // and re-published on reconnect.\n const error = err instanceof Error ? err : new Error(String(err));\n this.log.error(\"binlog batch failed; not acknowledging\", {\n error: error.message,\n });\n this.hooks.onError?.(error);\n }\n }\n\n private async publishChunk(chunk: DecodedInsert[]): Promise<void> {\n const records = chunk.map((c) => rowToRecord(c.row));\n const messages = await Promise.all(\n records.map((r) => buildPublishable(r, this.serializer)),\n );\n const results = await this.publisher.publish(messages);\n const byId = new Map(records.map((r) => [r.id, r]));\n\n const succeeded: string[] = [];\n for (const result of results) {\n const record = byId.get(result.recordId);\n if (!record) continue;\n if (result.ok) {\n succeeded.push(record.id);\n this.hooks.onPublished?.(result);\n } else {\n await this.store.markFailed(record.id, null, \"failed\");\n this.hooks.onFailed?.(\n record,\n result.error ?? new Error(\"publish failed\"),\n true,\n );\n }\n }\n if (this.markPublished && succeeded.length > 0) {\n await this.store.markDone(succeeded);\n }\n }\n}\n\n/**\n * Real binlog stream backed by @vlasky/zongji. Covered by the integration suite\n * (it needs a live MySQL with binlog enabled); unit tests use a fake stream.\n */\nfunction createZongjiBinlogStream(config: BinlogReplicationConfig): BinlogStream {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let zongji: any = null;\n let stopped = false;\n const table = config.table ?? \"outbox\";\n\n return {\n async start(handlers: BinlogStreamHandlers): Promise<void> {\n const ZongJi = await importZongji();\n zongji = new ZongJi({\n host: config.host,\n port: config.port ?? 3306,\n user: config.user,\n password: config.password,\n serverId: config.serverId ?? deriveServerId(),\n });\n\n // Track the latest position seen per transaction so a single ROTATE\n // followed by writeRows then xid (commit) gives us a coherent commit point.\n let current: BinlogPosition = config.startPosition ?? {\n filename: \"\",\n position: 0,\n };\n\n zongji.on(\"binlog\", (evt: BinlogEventLike) => {\n const name = evt.getEventName?.();\n if (name === \"rotate\") {\n current = {\n filename: (evt.nextBinlog as string) ?? current.filename,\n position: (evt.position as number) ?? 0,\n };\n return;\n }\n if (name === \"writerows\") {\n if (\n (evt.tableMap as { [k: string]: { tableName: string } } | undefined) &&\n !rowsAreFor(evt, table)\n ) {\n return;\n }\n const rows = (evt.rows as Array<Record<string, unknown>>) ?? [];\n for (const raw of rows) {\n handlers.onInsert({\n position: current,\n row: normalizeRow(raw),\n });\n }\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n return;\n }\n if (name === \"xid\" || name === \"query\") {\n // xid = commit (InnoDB); query may carry BEGIN/COMMIT for statement-based.\n current = {\n filename: current.filename,\n position: (evt.nextPosition as number) ?? current.position,\n };\n void handlers.onCommit(current);\n }\n });\n\n zongji.on(\"error\", (err: Error) => {\n if (stopped) return;\n handlers.onError(err);\n });\n\n const startOpts: Record<string, unknown> = {\n includeEvents: [\"tablemap\", \"writerows\", \"xid\", \"rotate\", \"query\"],\n includeSchema: { [config.database]: [table] },\n };\n if (config.startPosition) {\n startOpts[\"filename\"] = config.startPosition.filename;\n startOpts[\"position\"] = config.startPosition.position;\n }\n zongji.start(startOpts);\n },\n async acknowledge(_position: BinlogPosition): Promise<void> {\n // MySQL has no native server-side ack. Position tracking lives in the\n // user's KV store via the onCommit hook (or this is replaced by a custom\n // BinlogStream implementation).\n },\n async stop(): Promise<void> {\n stopped = true;\n if (zongji) zongji.stop();\n },\n };\n}\n\n/** Minimal shape of a zongji binlog event we look at. */\ninterface BinlogEventLike {\n getEventName?(): string;\n nextBinlog?: string;\n position?: number;\n nextPosition?: number;\n tableMap?: Record<string, { tableName: string }>;\n rows?: Array<Record<string, unknown>>;\n}\n\nfunction rowsAreFor(evt: BinlogEventLike, table: string): boolean {\n const map = evt.tableMap ?? {};\n for (const tid of Object.keys(map)) {\n const meta = map[tid];\n if (meta?.tableName === table) {\n // zongji exposes schemaName inconsistently across versions; checking the\n // table name alone matches the includeSchema filter we asked for.\n return true;\n }\n }\n // If we have no tableMap context yet (rotate-after-restart edge), accept.\n return Object.keys(map).length === 0 ? true : false;\n}\n\nfunction normalizeRow(raw: Record<string, unknown>): OutboxRow {\n const json = (v: unknown) =>\n typeof v === \"string\" ? JSON.parse(v) : (v ?? null);\n const headers = json(raw[\"headers\"]) as Record<string, string> | null;\n return {\n id: String(raw[\"id\"]),\n message_id: String(raw[\"message_id\"] ?? \"\"),\n aggregate_type: String(raw[\"aggregate_type\"] ?? \"\"),\n aggregate_id: String(raw[\"aggregate_id\"] ?? \"\"),\n topic: String(raw[\"topic\"] ?? \"\"),\n key: (raw[\"key\"] as string | null) ?? null,\n payload: json(raw[\"payload\"]),\n headers,\n trace_id: (raw[\"trace_id\"] as string | null) ?? null,\n status: Number(raw[\"status\"] ?? 0),\n attempts: Number(raw[\"attempts\"] ?? 0),\n next_retry_at: raw[\"next_retry_at\"]\n ? new Date(raw[\"next_retry_at\"] as string | number | Date)\n : null,\n created_at: raw[\"created_at\"]\n ? new Date(raw[\"created_at\"] as string | number | Date)\n : new Date(0),\n processed_at: raw[\"processed_at\"]\n ? new Date(raw[\"processed_at\"] as string | number | Date)\n : null,\n };\n}\n\n/** Deterministic serverId derived from PID so two reader processes on one host\n * do not clash. Override via {@link BinlogReplicationConfig.serverId}. */\nfunction deriveServerId(): number {\n const pid = typeof process !== \"undefined\" ? process.pid : 1;\n // Keep in the safe 1..2^32-1 range; bias well above real replica server-ids.\n return (1_000_000 + (pid % 1_000_000)) >>> 0;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nasync function importZongji(): Promise<any> {\n try {\n // @vlasky/zongji is an optional peer dep — typed via a `@ts-ignore`\n // because users without the binlog relay should not need the type\n // declarations installed.\n // @ts-ignore -- optional peer dep, types not required for compilation\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod: any = await import(\"@vlasky/zongji\");\n return mod.default ?? mod;\n } catch {\n throw new Error(\n 'Binlog relay needs the \"@vlasky/zongji\" package. Run: npm i @vlasky/zongji',\n );\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventferry/mysql",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "MySQL / MariaDB store for @eventferry (polling + SKIP LOCKED claim)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"dist",
|
|
23
23
|
"CHANGELOG.md"
|
|
24
24
|
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
25
28
|
"keywords": [
|
|
26
29
|
"outbox",
|
|
27
30
|
"outbox-pattern",
|
|
@@ -48,7 +51,7 @@
|
|
|
48
51
|
"node": ">=18"
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
|
-
"@eventferry/core": "3.
|
|
54
|
+
"@eventferry/core": "3.4.0"
|
|
52
55
|
},
|
|
53
56
|
"peerDependencies": {
|
|
54
57
|
"mysql2": "^3.0.0",
|
|
@@ -63,8 +66,8 @@
|
|
|
63
66
|
"mysql2": "^3.11.5",
|
|
64
67
|
"tsup": "^8.3.5",
|
|
65
68
|
"typescript": "^5.7.2",
|
|
66
|
-
"vitest": "^2.
|
|
67
|
-
"@eventferry/core": "3.
|
|
69
|
+
"vitest": "^3.2.6",
|
|
70
|
+
"@eventferry/core": "3.4.0"
|
|
68
71
|
},
|
|
69
72
|
"scripts": {
|
|
70
73
|
"build": "tsup",
|