@eventferry/mysql 2.0.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/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @eventferry/mysql
2
+
3
+ [![npm](https://img.shields.io/npm/v/@eventferry/mysql.svg)](https://www.npmjs.com/package/@eventferry/mysql)
4
+
5
+ The **MySQL / MariaDB store** for [eventferry](https://github.com/SametGoktepe/eventferry) —
6
+ a transactional outbox toolkit for relational databases + Kafka/Redpanda.
7
+
8
+ Provides:
9
+
10
+ - `MysqlStore` — transaction-joining `enqueue` and a lock-free `claimBatch`
11
+ using `SELECT ... FOR UPDATE SKIP LOCKED`, with **strict per-aggregate
12
+ ordering** and a **crash-recovery reaper** (visibility timeout).
13
+ - `createMigrationSql` — idempotent DDL generator for the outbox table
14
+ (InnoDB + utf8mb4 + DATETIME(3) + JSON).
15
+ - `purgeDone` — batched retention of published rows.
16
+
17
+ ## Requirements
18
+
19
+ - **MySQL 8.0.1+** or **MariaDB 10.6+** (needs `FOR UPDATE SKIP LOCKED`)
20
+ - **InnoDB** storage engine (default)
21
+ - Node.js **18+**
22
+
23
+ > Older MySQL versions don't support `SKIP LOCKED` — concurrent relays would
24
+ > serialize on the same rows. There is no fallback in this MVP.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm i @eventferry/mysql @eventferry/core mysql2
30
+ ```
31
+
32
+ `mysql2` is an optional peer (the driver).
33
+
34
+ ## Usage
35
+
36
+ ```ts
37
+ import mysql from "mysql2/promise";
38
+ import { MysqlStore, createMigrationSql } from "@eventferry/mysql";
39
+
40
+ const pool = mysql.createPool({
41
+ host: "localhost",
42
+ user: "app",
43
+ password: "...",
44
+ database: "shop",
45
+ // Recommended for the outbox: stable date handling and bigint-safe ids.
46
+ dateStrings: false,
47
+ supportBigNumbers: true,
48
+ });
49
+
50
+ await pool.query(createMigrationSql("outbox")); // idempotent DDL
51
+
52
+ const store = new MysqlStore({ pool });
53
+
54
+ // Inside your business transaction:
55
+ const conn = await pool.getConnection();
56
+ try {
57
+ await conn.beginTransaction();
58
+ await conn.query("INSERT INTO orders ...");
59
+ await store.enqueue(conn, {
60
+ topic: "orders.created",
61
+ aggregateType: "order",
62
+ aggregateId: order.id,
63
+ payload: { orderId: order.id, total: order.total },
64
+ });
65
+ await conn.commit();
66
+ } catch (err) {
67
+ await conn.rollback();
68
+ throw err;
69
+ } finally {
70
+ conn.release();
71
+ }
72
+ ```
73
+
74
+ Hand the store to a `Relay` from `@eventferry/core` together with a publisher
75
+ from `@eventferry/kafka`:
76
+
77
+ ```ts
78
+ import { Relay } from "@eventferry/core";
79
+ import { KafkaPublisher } from "@eventferry/kafka";
80
+
81
+ const publisher = new KafkaPublisher({
82
+ driver: "kafkajs",
83
+ brokers: ["localhost:19092"],
84
+ idempotent: true,
85
+ });
86
+
87
+ const relay = new Relay({ store, publisher, dlq: { topic: "orders.dlq" } });
88
+ await relay.start();
89
+ process.on("SIGTERM", () => relay.stop());
90
+ ```
91
+
92
+ ## Binlog streaming (CDC) mode
93
+
94
+ For high-throughput workloads, use the `MysqlBinlogRelay` to tail the **MySQL
95
+ binary log** directly — the same mechanism Debezium uses — and bypass the
96
+ polling claim loop entirely. Latency drops from "one poll interval" to a few
97
+ milliseconds.
98
+
99
+ **MySQL server requirements:**
100
+
101
+ ```ini
102
+ # my.cnf
103
+ [mysqld]
104
+ binlog_format = ROW
105
+ binlog_row_image = FULL
106
+ server_id = 1 # any value, must be unique in the cluster
107
+ log_bin = mysql-bin
108
+ gtid_mode = ON # recommended for safer resumption
109
+ enforce_gtid_consistency = ON
110
+ ```
111
+
112
+ **Grants the reader user needs:**
113
+
114
+ ```sql
115
+ CREATE USER 'outbox_reader'@'%' IDENTIFIED BY 'strong-password';
116
+ GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'outbox_reader'@'%';
117
+ GRANT SELECT ON shop.outbox TO 'outbox_reader'@'%';
118
+ ```
119
+
120
+ **Install the optional peer dep:**
121
+
122
+ ```bash
123
+ npm i @vlasky/zongji
124
+ ```
125
+
126
+ **Usage:**
127
+
128
+ ```ts
129
+ import { MysqlStore, MysqlBinlogRelay } from "@eventferry/mysql";
130
+ import { KafkaPublisher } from "@eventferry/kafka";
131
+
132
+ // IMPORTANT: claimFailedOnly=true so the internal retry loop only drains
133
+ // failures — pending rows are owned by the binlog stream.
134
+ const store = new MysqlStore({ pool, claimFailedOnly: true });
135
+ const publisher = new KafkaPublisher({ driver: "kafkajs", brokers, idempotent: true });
136
+
137
+ const relay = new MysqlBinlogRelay({
138
+ store,
139
+ publisher,
140
+ binlog: {
141
+ host: "localhost",
142
+ user: "outbox_reader",
143
+ password: "strong-password",
144
+ database: "shop",
145
+ table: "outbox",
146
+ // serverId: 1_000_001, // override if you have multiple readers
147
+ // startPosition: { filename: "mysql-bin.000042", position: 12345 },
148
+ },
149
+ });
150
+ await relay.start();
151
+ process.on("SIGTERM", () => relay.stop());
152
+ ```
153
+
154
+ **Position persistence (at-least-once across restarts):**
155
+
156
+ MySQL has no server-side ack like Postgres logical replication, so binlog
157
+ position tracking lives outside the server. Hook `onCommit` to persist the
158
+ position to your own KV store, then pass it back via `startPosition` on the
159
+ next start — without it the relay starts at the **end** of the binlog (tail
160
+ mode) and won't replay rows written before it connected.
161
+
162
+ ## What's still not in this package
163
+
164
+ - **No native low-latency waker** for the polling relay. MySQL has no
165
+ `LISTEN/NOTIFY`; if you don't want binlog mode either, tune the relay's
166
+ polling interval down (e.g. 100ms).
167
+ - **Built-in position persistence** for the binlog relay — for now, you persist
168
+ the position yourself via the `onCommit` hook.
169
+
170
+ ## Retention
171
+
172
+ The outbox table grows; `purgeDone` batch-deletes old `done` rows (run from
173
+ your own cron):
174
+
175
+ ```ts
176
+ await store.purgeDone({ olderThanMs: 7 * 24 * 60 * 60 * 1000 }); // 7 days
177
+ ```
178
+
179
+ 📖 **Full documentation:** [github.com/SametGoktepe/eventferry](https://github.com/SametGoktepe/eventferry#readme)
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,50 @@
1
+ // src/ident.ts
2
+ function assertIdent(name) {
3
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
4
+ throw new Error(
5
+ `Invalid identifier "${name}": must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`
6
+ );
7
+ }
8
+ return name;
9
+ }
10
+
11
+ // src/migrations.ts
12
+ function createMigrationSql(tableName = "outbox") {
13
+ const t = assertIdent(tableName);
14
+ return `
15
+ CREATE TABLE IF NOT EXISTS \`${t}\` (
16
+ id BIGINT NOT NULL AUTO_INCREMENT,
17
+ message_id CHAR(36) NOT NULL,
18
+ aggregate_type VARCHAR(255) NOT NULL,
19
+ aggregate_id VARCHAR(255) NOT NULL,
20
+ topic VARCHAR(255) NOT NULL,
21
+ \`key\` TEXT,
22
+ payload JSON NOT NULL,
23
+ headers JSON NOT NULL,
24
+ trace_id VARCHAR(64),
25
+ status TINYINT NOT NULL DEFAULT 0,
26
+ attempts INT NOT NULL DEFAULT 0,
27
+ next_retry_at DATETIME(3),
28
+ claimed_at DATETIME(3),
29
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
30
+ processed_at DATETIME(3),
31
+ PRIMARY KEY (id),
32
+ UNIQUE KEY uq_${t}_message_id (message_id),
33
+ KEY idx_${t}_ready (status, id),
34
+ KEY idx_${t}_agg_order (aggregate_id, id)
35
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
36
+ `.trim();
37
+ }
38
+ function createRetentionIndexSql(tableName = "outbox") {
39
+ const t = assertIdent(tableName);
40
+ return `
41
+ CREATE INDEX idx_${t}_done_processed ON \`${t}\` (processed_at);
42
+ `.trim();
43
+ }
44
+
45
+ export {
46
+ assertIdent,
47
+ createMigrationSql,
48
+ createRetentionIndexSql
49
+ };
50
+ //# sourceMappingURL=chunk-W2AQTNYH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ident.ts","../src/migrations.ts"],"sourcesContent":["/**\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 { 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"],"mappings":";AAIO,SAAS,YAAY,MAAsB;AAChD,MAAI,CAAC,2BAA2B,KAAK,IAAI,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR,uBAAuB,IAAI;AAAA,IAC7B;AAAA,EACF;AACA,SAAO;AACT;;;ACMO,SAAS,mBAAmB,YAAY,UAAkB;AAC/D,QAAM,IAAI,YAAY,SAAS;AAC/B,SAAO;AAAA,+BACsB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAiBd,CAAC;AAAA,YACP,CAAC;AAAA,YACD,CAAC;AAAA;AAAA,EAEX,KAAK;AACP;AAQO,SAAS,wBAAwB,YAAY,UAAkB;AACpE,QAAM,IAAI,YAAY,SAAS;AAC/B,SAAO;AAAA,mBACU,CAAC,wBAAwB,CAAC;AAAA,EAC3C,KAAK;AACP;","names":[]}