@eventferry/mysql 3.2.0 → 3.3.0
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/dist/index.cjs +16 -0
- 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 +16 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
package/dist/index.cjs
CHANGED
|
@@ -221,6 +221,22 @@ var MysqlStore = class {
|
|
|
221
221
|
[code, nextRetryAt, recordId]
|
|
222
222
|
);
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Re-queue a record to `failed` with the given `retryAt` **without
|
|
226
|
+
* bumping attempts** — used by the relay for backpressure handling.
|
|
227
|
+
* Also clears `claimed_at` so the reaper does not race the row.
|
|
228
|
+
*/
|
|
229
|
+
async requeue(recordId, retryAt) {
|
|
230
|
+
const failed = import_core2.OUTBOX_STATUS_CODE.failed;
|
|
231
|
+
await this.pool.query(
|
|
232
|
+
`UPDATE \`${this.table}\`
|
|
233
|
+
SET status = ${failed},
|
|
234
|
+
claimed_at = NULL,
|
|
235
|
+
next_retry_at = ?
|
|
236
|
+
WHERE id = ?`,
|
|
237
|
+
[retryAt, recordId]
|
|
238
|
+
);
|
|
239
|
+
}
|
|
224
240
|
/**
|
|
225
241
|
* Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
|
|
226
242
|
* batches (to avoid long locks / table bloat). Returns the total deleted.
|
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 * 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 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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -106,6 +106,12 @@ declare class MysqlStore implements OutboxStore {
|
|
|
106
106
|
claimBatch(batchSize: number): Promise<OutboxRecord[]>;
|
|
107
107
|
markDone(recordIds: string[]): Promise<void>;
|
|
108
108
|
markFailed(recordId: string, nextRetryAt: Date | null, status: "failed" | "dead"): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Re-queue a record to `failed` with the given `retryAt` **without
|
|
111
|
+
* bumping attempts** — used by the relay for backpressure handling.
|
|
112
|
+
* Also clears `claimed_at` so the reaper does not race the row.
|
|
113
|
+
*/
|
|
114
|
+
requeue(recordId: string, retryAt: Date): Promise<void>;
|
|
109
115
|
/**
|
|
110
116
|
* Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
|
|
111
117
|
* batches (to avoid long locks / table bloat). Returns the total deleted.
|
package/dist/index.d.ts
CHANGED
|
@@ -106,6 +106,12 @@ declare class MysqlStore implements OutboxStore {
|
|
|
106
106
|
claimBatch(batchSize: number): Promise<OutboxRecord[]>;
|
|
107
107
|
markDone(recordIds: string[]): Promise<void>;
|
|
108
108
|
markFailed(recordId: string, nextRetryAt: Date | null, status: "failed" | "dead"): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Re-queue a record to `failed` with the given `retryAt` **without
|
|
111
|
+
* bumping attempts** — used by the relay for backpressure handling.
|
|
112
|
+
* Also clears `claimed_at` so the reaper does not race the row.
|
|
113
|
+
*/
|
|
114
|
+
requeue(recordId: string, retryAt: Date): Promise<void>;
|
|
109
115
|
/**
|
|
110
116
|
* Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
|
|
111
117
|
* batches (to avoid long locks / table bloat). Returns the total deleted.
|
package/dist/index.js
CHANGED
|
@@ -178,6 +178,22 @@ var MysqlStore = class {
|
|
|
178
178
|
[code, nextRetryAt, recordId]
|
|
179
179
|
);
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Re-queue a record to `failed` with the given `retryAt` **without
|
|
183
|
+
* bumping attempts** — used by the relay for backpressure handling.
|
|
184
|
+
* Also clears `claimed_at` so the reaper does not race the row.
|
|
185
|
+
*/
|
|
186
|
+
async requeue(recordId, retryAt) {
|
|
187
|
+
const failed = OUTBOX_STATUS_CODE.failed;
|
|
188
|
+
await this.pool.query(
|
|
189
|
+
`UPDATE \`${this.table}\`
|
|
190
|
+
SET status = ${failed},
|
|
191
|
+
claimed_at = NULL,
|
|
192
|
+
next_retry_at = ?
|
|
193
|
+
WHERE id = ?`,
|
|
194
|
+
[retryAt, recordId]
|
|
195
|
+
);
|
|
196
|
+
}
|
|
181
197
|
/**
|
|
182
198
|
* Delete `done` rows whose `processed_at` is older than `olderThanMs`, in
|
|
183
199
|
* batches (to avoid long locks / table bloat). Returns the total deleted.
|
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 * 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":";;;;;;;AAAA,SAAS,kBAAkB;AAO3B,SAAS,0BAA0B;;;ACNnC,SAAS,+BAA+B;AAyBjC,SAAS,YAAY,KAA8B;AACxD,QAAM,SAAuB,wBAAwB,IAAI,MAAM,KAAK;AACpE,SAAO;AAAA,IACL,IAAI,OAAO,IAAI,EAAE;AAAA,IACjB,WAAW,IAAI;AAAA,IACf,OAAO,IAAI;AAAA,IACX,eAAe,IAAI;AAAA,IACnB,aAAa,IAAI;AAAA,IACjB,KAAK,IAAI;AAAA,IACT,SAAS,IAAI;AAAA,IACb,SAAS,IAAI,WAAW,CAAC;AAAA,IACzB,SAAS,IAAI;AAAA,IACb;AAAA,IACA,UAAU,IAAI;AAAA,IACd,aAAa,IAAI;AAAA,IACjB,WAAW,IAAI;AAAA,IACf,aAAa,IAAI;AAAA,EACnB;AACF;;;ADiCA,IAAM,2BAA2B;AACjC,IAAM,aAAa,mBAAmB;AACtC,IAAM,OAAO,mBAAmB;AAEzB,IAAM,aAAN,MAAwC;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAyB;AACnC,SAAK,OAAO,KAAK;AACjB,SAAK,QAAQ,YAAY,KAAK,SAAS,QAAQ;AAC/C,SAAK,iBAAiB,KAAK,kBAAkB;AAC7C,SAAK,kBAAkB,KAAK,mBAAmB;AAC/C,SAAK,UAAU,KAAK,WAAW;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,QACJ,IACA,KACiB;AAGjB,UAAM,UAAU,EAAE,GAAI,IAAI,WAAW,CAAC,EAAG;AACzC,SAAK,SAAS,OAAO,OAAO;AAI5B,UAAM,YAAY,IAAI,aAAa,WAAW;AAE9C,UAAM,MAAM;AAAA,sBACM,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAK5B,UAAM,GAAG,MAAM,KAAK;AAAA,MAClB;AAAA,MACA,IAAI;AAAA,MACJ,IAAI;AAAA,MACJ,IAAI;AAAA,MACJ,IAAI,OAAO;AAAA,MACX,KAAK,UAAU,IAAI,OAAO;AAAA,MAC1B,KAAK,UAAU,OAAO;AAAA,MACtB,IAAI,WAAW;AAAA,IACjB,CAAC;AACD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA0BA,MAAM,WAAW,WAA4C;AAC3D,UAAM,OAAO,MAAM,KAAK,KAAK,cAAc;AAC3C,QAAI;AACF,YAAM,KAAK,iBAAiB;AAQ5B,YAAM,gBAAgB,KAAK,kBAAkB,KAAK;AAElD,YAAM,YAAY;AAAA;AAAA,iBAEP,KAAK,KAAK;AAAA;AAAA,gBAEX,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAMN,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS3B,YAAM,CAAC,OAAO,IAAI,MAAM,KAAK,MAAM,WAAW;AAAA,QAC5C,KAAK,iBAAiB;AAAA,QACtB;AAAA,MACF,CAAC;AACD,YAAM,MAAO,QAAoD;AAAA,QAC/D,CAAC,MAAM,EAAE;AAAA,MACX;AAEA,UAAI,IAAI,WAAW,GAAG;AACpB,cAAM,KAAK,OAAO;AAClB,eAAO,CAAC;AAAA,MACV;AAEA,YAAM,KAAK;AAAA,QACT,YAAY,KAAK,KAAK,mBAAmB,UAAU;AAAA,QACnD,CAAC,GAAG;AAAA,MACN;AAEA,YAAM,CAAC,IAAI,IAAI,MAAM,KAAK;AAAA,QACxB;AAAA;AAAA;AAAA,oBAGY,KAAK,KAAK;AAAA;AAAA;AAAA,QAGtB,CAAC,GAAG;AAAA,MACN;AAEA,YAAM,KAAK,OAAO;AAClB,aAAQ,KAAgC,IAAI,WAAW;AAAA,IACzD,SAAS,KAAK;AACZ,UAAI;AACF,cAAM,KAAK,SAAS;AAAA,MACtB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR,UAAE;AACA,WAAK,QAAQ;AAAA,IACf;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,WAAoC;AACjD,QAAI,UAAU,WAAW,EAAG;AAC5B,UAAM,KAAK,KAAK;AAAA,MACd,YAAY,KAAK,KAAK,mBAAmB,IAAI;AAAA,MAC7C,CAAC,SAAS;AAAA,IACZ;AAAA,EACF;AAAA,EAEA,MAAM,WACJ,UACA,aACA,QACe;AACf,UAAM,OAAO,mBAAmB,MAAM;AACtC,UAAM,KAAK,KAAK;AAAA,MACd,YAAY,KAAK,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,MAKtB,CAAC,MAAM,aAAa,QAAQ;AAAA,IAC9B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,UAAU,MAAyC;AACvD,UAAM,YAAY,KAAK,aAAa;AACpC,QAAI,QAAQ;AACZ,eAAS;AAGP,YAAM,CAAC,MAAM,IAAI,MAAM,KAAK,KAAK;AAAA,QAC/B,iBAAiB,KAAK,KAAK;AAAA,2BACR,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA,QAKvB,CAAC,KAAK,cAAc,KAAM,SAAS;AAAA,MACrC;AACA,YAAM,UAAW,OAAqC,gBAAgB;AACtE,eAAS;AACT,UAAI,UAAU,UAAW;AACzB,UAAI,KAAK,YAAY,UAAa,SAAS,KAAK,QAAS;AAAA,IAC3D;AACA,WAAO;AAAA,EACT;AACF;;;AE9RA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA+GA,IAAM,mBAAN,MAAuB;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,SAA8B;AAAA,EAC9B,SAA0B,CAAC;AAAA,EAC3B,OAAsB,QAAQ,QAAQ;AAAA,EACtC,UAAU;AAAA,EAElB,YAAY,MAA+B;AACzC,SAAK,QAAQ,KAAK;AAClB,SAAK,YAAY,KAAK;AACtB,SAAK,aAAa,KAAK,cAAc,IAAI,eAAe;AACxD,SAAK,MAAM,KAAK,UAAU,IAAI,cAAc;AAC5C,SAAK,QAAQ,KAAK,SAAS,CAAC;AAC5B,SAAK,SAAS,KAAK;AACnB,SAAK,gBAAgB,KAAK,iBAAiB;AAC3C,SAAK,YAAY,KAAK,aAAa;AAEnC,SAAK,aAAa,IAAI,MAAM;AAAA,MAC1B,OAAO,KAAK;AAAA,MACZ,WAAW,KAAK;AAAA,MAChB,OAAO,KAAK;AAAA,MACZ,KAAK,KAAK;AAAA,MACV,YAAY,KAAK;AAAA,MACjB,QAAQ,KAAK;AAAA,MACb,OAAO,KAAK;AAAA,MACZ,gBAAgB,KAAK,wBAAwB;AAAA,IAC/C,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,QAAS;AAClB,SAAK,UAAU;AACf,UAAM,KAAK,WAAW,MAAM;AAC5B,SAAK,SAAS,KAAK,mBAAmB;AACtC,UAAM,KAAK,OAAO,MAAM;AAAA,MACtB,UAAU,CAAC,WAAW;AACpB,aAAK,OAAO,KAAK,MAAM;AAAA,MACzB;AAAA,MACA,UAAU,CAAC,aAAa;AACtB,cAAM,QAAQ,KAAK;AACnB,aAAK,SAAS,CAAC;AACf,aAAK,OAAO,KAAK,KAAK,KAAK,MAAM,KAAK,aAAa,OAAO,QAAQ,CAAC;AACnE,eAAO,KAAK;AAAA,MACd;AAAA,MACA,SAAS,CAAC,QAAQ;AAChB,aAAK,IAAI,MAAM,uBAAuB,EAAE,OAAO,IAAI,QAAQ,CAAC;AAC5D,aAAK,MAAM,UAAU,GAAG;AAAA,MAC1B;AAAA,IACF,CAAC;AACD,SAAK,IAAI,KAAK,8BAA8B;AAAA,MAC1C,UAAU,KAAK,OAAO;AAAA,MACtB,OAAO,KAAK,OAAO,SAAS;AAAA,IAC9B,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,OAAsB;AAC1B,QAAI,CAAC,KAAK,QAAS;AACnB,SAAK,UAAU;AACf,UAAM,KAAK,QAAQ,KAAK;AACxB,UAAM,KAAK;AACX,UAAM,KAAK,WAAW,KAAK;AAC3B,SAAK,IAAI,KAAK,4BAA4B;AAAA,EAC5C;AAAA;AAAA,EAGU,qBAAmC;AAC3C,WAAO,yBAAyB,KAAK,MAAM;AAAA,EAC7C;AAAA,EAEA,MAAc,aACZ,OACA,UACe;AACf,QAAI;AACF,eAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,KAAK,WAAW;AACrD,cAAM,KAAK,aAAa,MAAM,MAAM,GAAG,IAAI,KAAK,SAAS,CAAC;AAAA,MAC5D;AACA,YAAM,KAAK,QAAQ,YAAY,QAAQ;AAAA,IACzC,SAAS,KAAK;AAGZ,YAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AAChE,WAAK,IAAI,MAAM,0CAA0C;AAAA,QACvD,OAAO,MAAM;AAAA,MACf,CAAC;AACD,WAAK,MAAM,UAAU,KAAK;AAAA,IAC5B;AAAA,EACF;AAAA,EAEA,MAAc,aAAa,OAAuC;AAChE,UAAM,UAAU,MAAM,IAAI,CAAC,MAAM,YAAY,EAAE,GAAG,CAAC;AACnD,UAAM,WAAW,MAAM,QAAQ;AAAA,MAC7B,QAAQ,IAAI,CAAC,MAAM,iBAAiB,GAAG,KAAK,UAAU,CAAC;AAAA,IACzD;AACA,UAAM,UAAU,MAAM,KAAK,UAAU,QAAQ,QAAQ;AACrD,UAAM,OAAO,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AAElD,UAAM,YAAsB,CAAC;AAC7B,eAAW,UAAU,SAAS;AAC5B,YAAM,SAAS,KAAK,IAAI,OAAO,QAAQ;AACvC,UAAI,CAAC,OAAQ;AACb,UAAI,OAAO,IAAI;AACb,kBAAU,KAAK,OAAO,EAAE;AACxB,aAAK,MAAM,cAAc,MAAM;AAAA,MACjC,OAAO;AACL,cAAM,KAAK,MAAM,WAAW,OAAO,IAAI,MAAM,QAAQ;AACrD,aAAK,MAAM;AAAA,UACT;AAAA,UACA,OAAO,SAAS,IAAI,MAAM,gBAAgB;AAAA,UAC1C;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,QAAI,KAAK,iBAAiB,UAAU,SAAS,GAAG;AAC9C,YAAM,KAAK,MAAM,SAAS,SAAS;AAAA,IACrC;AAAA,EACF;AACF;AAMA,SAAS,yBAAyB,QAA+C;AAE/E,MAAI,SAAc;AAClB,MAAI,UAAU;AACd,QAAM,QAAQ,OAAO,SAAS;AAE9B,SAAO;AAAA,IACL,MAAM,MAAM,UAA+C;AACzD,YAAM,SAAS,MAAM,aAAa;AAClC,eAAS,IAAI,OAAO;AAAA,QAClB,MAAM,OAAO;AAAA,QACb,MAAM,OAAO,QAAQ;AAAA,QACrB,MAAM,OAAO;AAAA,QACb,UAAU,OAAO;AAAA,QACjB,UAAU,OAAO,YAAY,eAAe;AAAA,MAC9C,CAAC;AAID,UAAI,UAA0B,OAAO,iBAAiB;AAAA,QACpD,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAEA,aAAO,GAAG,UAAU,CAAC,QAAyB;AAC5C,cAAM,OAAO,IAAI,eAAe;AAChC,YAAI,SAAS,UAAU;AACrB,oBAAU;AAAA,YACR,UAAW,IAAI,cAAyB,QAAQ;AAAA,YAChD,UAAW,IAAI,YAAuB;AAAA,UACxC;AACA;AAAA,QACF;AACA,YAAI,SAAS,aAAa;AACxB,cACG,IAAI,YACL,CAAC,WAAW,KAAK,KAAK,GACtB;AACA;AAAA,UACF;AACA,gBAAM,OAAQ,IAAI,QAA2C,CAAC;AAC9D,qBAAW,OAAO,MAAM;AACtB,qBAAS,SAAS;AAAA,cAChB,UAAU;AAAA,cACV,KAAK,aAAa,GAAG;AAAA,YACvB,CAAC;AAAA,UACH;AACA,oBAAU;AAAA,YACR,UAAU,QAAQ;AAAA,YAClB,UAAW,IAAI,gBAA2B,QAAQ;AAAA,UACpD;AACA;AAAA,QACF;AACA,YAAI,SAAS,SAAS,SAAS,SAAS;AAEtC,oBAAU;AAAA,YACR,UAAU,QAAQ;AAAA,YAClB,UAAW,IAAI,gBAA2B,QAAQ;AAAA,UACpD;AACA,eAAK,SAAS,SAAS,OAAO;AAAA,QAChC;AAAA,MACF,CAAC;AAED,aAAO,GAAG,SAAS,CAAC,QAAe;AACjC,YAAI,QAAS;AACb,iBAAS,QAAQ,GAAG;AAAA,MACtB,CAAC;AAED,YAAM,YAAqC;AAAA,QACzC,eAAe,CAAC,YAAY,aAAa,OAAO,UAAU,OAAO;AAAA,QACjE,eAAe,EAAE,CAAC,OAAO,QAAQ,GAAG,CAAC,KAAK,EAAE;AAAA,MAC9C;AACA,UAAI,OAAO,eAAe;AACxB,kBAAU,UAAU,IAAI,OAAO,cAAc;AAC7C,kBAAU,UAAU,IAAI,OAAO,cAAc;AAAA,MAC/C;AACA,aAAO,MAAM,SAAS;AAAA,IACxB;AAAA,IACA,MAAM,YAAY,WAA0C;AAAA,IAI5D;AAAA,IACA,MAAM,OAAsB;AAC1B,gBAAU;AACV,UAAI,OAAQ,QAAO,KAAK;AAAA,IAC1B;AAAA,EACF;AACF;AAYA,SAAS,WAAW,KAAsB,OAAwB;AAChE,QAAM,MAAM,IAAI,YAAY,CAAC;AAC7B,aAAW,OAAO,OAAO,KAAK,GAAG,GAAG;AAClC,UAAM,OAAO,IAAI,GAAG;AACpB,QAAI,MAAM,cAAc,OAAO;AAG7B,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO,OAAO,KAAK,GAAG,EAAE,WAAW,IAAI,OAAO;AAChD;AAEA,SAAS,aAAa,KAAyC;AAC7D,QAAM,OAAO,CAAC,MACZ,OAAO,MAAM,WAAW,KAAK,MAAM,CAAC,IAAK,KAAK;AAChD,QAAM,UAAU,KAAK,IAAI,SAAS,CAAC;AACnC,SAAO;AAAA,IACL,IAAI,OAAO,IAAI,IAAI,CAAC;AAAA,IACpB,YAAY,OAAO,IAAI,YAAY,KAAK,EAAE;AAAA,IAC1C,gBAAgB,OAAO,IAAI,gBAAgB,KAAK,EAAE;AAAA,IAClD,cAAc,OAAO,IAAI,cAAc,KAAK,EAAE;AAAA,IAC9C,OAAO,OAAO,IAAI,OAAO,KAAK,EAAE;AAAA,IAChC,KAAM,IAAI,KAAK,KAAuB;AAAA,IACtC,SAAS,KAAK,IAAI,SAAS,CAAC;AAAA,IAC5B;AAAA,IACA,UAAW,IAAI,UAAU,KAAuB;AAAA,IAChD,QAAQ,OAAO,IAAI,QAAQ,KAAK,CAAC;AAAA,IACjC,UAAU,OAAO,IAAI,UAAU,KAAK,CAAC;AAAA,IACrC,eAAe,IAAI,eAAe,IAC9B,IAAI,KAAK,IAAI,eAAe,CAA2B,IACvD;AAAA,IACJ,YAAY,IAAI,YAAY,IACxB,IAAI,KAAK,IAAI,YAAY,CAA2B,IACpD,oBAAI,KAAK,CAAC;AAAA,IACd,cAAc,IAAI,cAAc,IAC5B,IAAI,KAAK,IAAI,cAAc,CAA2B,IACtD;AAAA,EACN;AACF;AAIA,SAAS,iBAAyB;AAChC,QAAM,MAAM,OAAO,YAAY,cAAc,QAAQ,MAAM;AAE3D,SAAQ,MAAa,MAAM,QAAgB;AAC7C;AAGA,eAAe,eAA6B;AAC1C,MAAI;AAMF,UAAM,MAAW,MAAM,OAAO,gBAAgB;AAC9C,WAAO,IAAI,WAAW;AAAA,EACxB,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;","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 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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventferry/mysql",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "MySQL / MariaDB store for @eventferry (polling + SKIP LOCKED claim)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -46,10 +46,12 @@
|
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=18"
|
|
48
48
|
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@eventferry/core": "3.3.0"
|
|
51
|
+
},
|
|
49
52
|
"peerDependencies": {
|
|
50
53
|
"mysql2": "^3.0.0",
|
|
51
|
-
"@vlasky/zongji": "^0.6.0"
|
|
52
|
-
"@eventferry/core": "3.2.0"
|
|
54
|
+
"@vlasky/zongji": "^0.6.0"
|
|
53
55
|
},
|
|
54
56
|
"peerDependenciesMeta": {
|
|
55
57
|
"@vlasky/zongji": {
|
|
@@ -61,7 +63,7 @@
|
|
|
61
63
|
"tsup": "^8.3.5",
|
|
62
64
|
"typescript": "^5.7.2",
|
|
63
65
|
"vitest": "^2.1.8",
|
|
64
|
-
"@eventferry/core": "3.
|
|
66
|
+
"@eventferry/core": "3.3.0"
|
|
65
67
|
},
|
|
66
68
|
"scripts": {
|
|
67
69
|
"build": "tsup",
|