@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 +183 -0
- package/dist/chunk-W2AQTNYH.js +50 -0
- package/dist/chunk-W2AQTNYH.js.map +1 -0
- package/dist/index.cjs +527 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +253 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +454 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.cjs +76 -0
- package/dist/migrations.cjs.map +1 -0
- package/dist/migrations.d.cts +25 -0
- package/dist/migrations.d.ts +25 -0
- package/dist/migrations.js +9 -0
- package/dist/migrations.js.map +1 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# @eventferry/mysql
|
|
2
|
+
|
|
3
|
+
[](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":[]}
|