@saga-bus/store-mysql 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Dean Foran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,260 @@
1
+ # @saga-bus/store-mysql
2
+
3
+ MySQL / MariaDB saga store for saga-bus.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @saga-bus/store-mysql mysql2
9
+ # or
10
+ pnpm add @saga-bus/store-mysql mysql2
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **MySQL 5.7+**: Native JSON column support
16
+ - **MariaDB 10.2+**: Compatible with MariaDB
17
+ - **Cloud Databases**: Works with PlanetScale, Vitess, AWS Aurora
18
+ - **Optimistic Concurrency**: Version-based conflict detection
19
+ - **Connection Pooling**: Built-in pool management
20
+ - **Query Helpers**: Find, count, and cleanup methods
21
+
22
+ ## Quick Start
23
+
24
+ ```typescript
25
+ import { createBus } from "@saga-bus/core";
26
+ import { MySqlSagaStore } from "@saga-bus/store-mysql";
27
+
28
+ const store = new MySqlSagaStore({
29
+ pool: {
30
+ host: "localhost",
31
+ user: "root",
32
+ password: "password",
33
+ database: "sagas",
34
+ },
35
+ });
36
+
37
+ await store.initialize();
38
+
39
+ const bus = createBus({
40
+ store,
41
+ // ... other config
42
+ });
43
+
44
+ await bus.start();
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ```typescript
50
+ interface MySqlSagaStoreOptions {
51
+ /** Connection pool or pool configuration */
52
+ pool: Pool | PoolOptions;
53
+
54
+ /** Table name for saga instances (default: "saga_instances") */
55
+ tableName?: string;
56
+ }
57
+ ```
58
+
59
+ ## Database Schema
60
+
61
+ Create the required table:
62
+
63
+ ```sql
64
+ CREATE TABLE saga_instances (
65
+ id VARCHAR(128) NOT NULL,
66
+ saga_name VARCHAR(128) NOT NULL,
67
+ correlation_id VARCHAR(256) NOT NULL,
68
+ version INT NOT NULL,
69
+ is_completed BOOLEAN NOT NULL DEFAULT FALSE,
70
+ state JSON NOT NULL,
71
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
73
+
74
+ PRIMARY KEY (saga_name, id),
75
+ UNIQUE KEY idx_correlation (saga_name, correlation_id),
76
+ KEY idx_cleanup (saga_name, is_completed, updated_at)
77
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
78
+ ```
79
+
80
+ ## Examples
81
+
82
+ ### Basic Usage
83
+
84
+ ```typescript
85
+ import { MySqlSagaStore } from "@saga-bus/store-mysql";
86
+
87
+ const store = new MySqlSagaStore({
88
+ pool: {
89
+ host: "localhost",
90
+ user: "root",
91
+ password: "password",
92
+ database: "sagas",
93
+ },
94
+ });
95
+
96
+ await store.initialize();
97
+
98
+ // Find by saga ID
99
+ const state = await store.getById("OrderSaga", "saga-123");
100
+
101
+ // Find by correlation ID
102
+ const stateByCorr = await store.getByCorrelationId("OrderSaga", "order-456");
103
+
104
+ // Insert new saga
105
+ await store.insert("OrderSaga", "order-789", {
106
+ orderId: "order-789",
107
+ status: "pending",
108
+ metadata: {
109
+ sagaId: "saga-new",
110
+ version: 1,
111
+ isCompleted: false,
112
+ createdAt: new Date(),
113
+ updatedAt: new Date(),
114
+ archivedAt: null,
115
+ timeoutMs: null,
116
+ timeoutExpiresAt: null,
117
+ },
118
+ });
119
+
120
+ // Update with concurrency check
121
+ await store.update("OrderSaga", updatedState, expectedVersion);
122
+
123
+ // Delete
124
+ await store.delete("OrderSaga", "saga-123");
125
+ ```
126
+
127
+ ### With PlanetScale
128
+
129
+ ```typescript
130
+ const store = new MySqlSagaStore({
131
+ pool: {
132
+ host: "aws.connect.psdb.cloud",
133
+ user: "your-username",
134
+ password: "your-password",
135
+ database: "your-database",
136
+ ssl: { rejectUnauthorized: true },
137
+ },
138
+ });
139
+ ```
140
+
141
+ ### With Existing Connection Pool
142
+
143
+ ```typescript
144
+ import { createPool } from "mysql2/promise";
145
+
146
+ const pool = createPool({
147
+ host: "localhost",
148
+ user: "root",
149
+ password: "password",
150
+ database: "sagas",
151
+ connectionLimit: 10,
152
+ });
153
+
154
+ const store = new MySqlSagaStore({
155
+ pool, // Use existing pool
156
+ });
157
+
158
+ await store.initialize();
159
+ ```
160
+
161
+ ### Query Helpers
162
+
163
+ ```typescript
164
+ // Find sagas by name with pagination
165
+ const sagas = await store.findByName("OrderSaga", {
166
+ limit: 10,
167
+ offset: 0,
168
+ completed: false,
169
+ });
170
+
171
+ // Count sagas
172
+ const total = await store.countByName("OrderSaga");
173
+ const completed = await store.countByName("OrderSaga", { completed: true });
174
+
175
+ // Cleanup old completed sagas
176
+ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
177
+ const deleted = await store.deleteCompletedBefore("OrderSaga", oneWeekAgo);
178
+ console.log(`Deleted ${deleted} completed sagas`);
179
+ ```
180
+
181
+ ## Optimistic Concurrency
182
+
183
+ The store uses version-based optimistic concurrency control:
184
+
185
+ ```typescript
186
+ // Read current state
187
+ const state = await store.getById("OrderSaga", "saga-123");
188
+ const expectedVersion = state.metadata.version;
189
+
190
+ // Make changes
191
+ state.status = "completed";
192
+ state.metadata.version += 1;
193
+ state.metadata.updatedAt = new Date();
194
+
195
+ try {
196
+ // Update with version check
197
+ await store.update("OrderSaga", state, expectedVersion);
198
+ } catch (error) {
199
+ if (error instanceof ConcurrencyError) {
200
+ // State was modified by another process
201
+ // Reload and retry
202
+ }
203
+ }
204
+ ```
205
+
206
+ ## Error Handling
207
+
208
+ ```typescript
209
+ import { ConcurrencyError } from "@saga-bus/core";
210
+
211
+ try {
212
+ await store.update("OrderSaga", state, expectedVersion);
213
+ } catch (error) {
214
+ if (error instanceof ConcurrencyError) {
215
+ console.log(`Concurrency conflict: expected v${error.expectedVersion}, actual v${error.actualVersion}`);
216
+ }
217
+ }
218
+ ```
219
+
220
+ ## Testing
221
+
222
+ For local development, you can run MySQL in Docker:
223
+
224
+ ```bash
225
+ docker run -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=sagas \
226
+ -p 3306:3306 --name mysql \
227
+ mysql:8
228
+ ```
229
+
230
+ Then create the table:
231
+
232
+ ```bash
233
+ docker exec -it mysql mysql -uroot -ppassword sagas -e "
234
+ CREATE TABLE saga_instances (
235
+ id VARCHAR(128) NOT NULL,
236
+ saga_name VARCHAR(128) NOT NULL,
237
+ correlation_id VARCHAR(256) NOT NULL,
238
+ version INT NOT NULL,
239
+ is_completed BOOLEAN NOT NULL DEFAULT FALSE,
240
+ state JSON NOT NULL,
241
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
242
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
243
+ PRIMARY KEY (saga_name, id),
244
+ UNIQUE KEY idx_correlation (saga_name, correlation_id),
245
+ KEY idx_cleanup (saga_name, is_completed, updated_at)
246
+ ) ENGINE=InnoDB;
247
+ "
248
+ ```
249
+
250
+ For unit tests, use an in-memory store:
251
+
252
+ ```typescript
253
+ import { InMemorySagaStore } from "@saga-bus/core";
254
+
255
+ const testStore = new InMemorySagaStore();
256
+ ```
257
+
258
+ ## License
259
+
260
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ MySqlSagaStore: () => MySqlSagaStore
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/MySqlSagaStore.ts
38
+ var mysql = __toESM(require("mysql2/promise"), 1);
39
+ var import_core = require("@saga-bus/core");
40
+ var MySqlSagaStore = class {
41
+ pool = null;
42
+ poolOptions;
43
+ tableName;
44
+ ownsPool;
45
+ constructor(options) {
46
+ if (typeof options.pool.query === "function") {
47
+ this.pool = options.pool;
48
+ this.poolOptions = null;
49
+ this.ownsPool = false;
50
+ } else {
51
+ this.poolOptions = options.pool;
52
+ this.ownsPool = true;
53
+ }
54
+ this.tableName = options.tableName ?? "saga_instances";
55
+ }
56
+ /**
57
+ * Initialize the connection pool if using config.
58
+ */
59
+ async initialize() {
60
+ if (this.poolOptions && !this.pool) {
61
+ this.pool = mysql.createPool(this.poolOptions);
62
+ }
63
+ }
64
+ async getById(sagaName, sagaId) {
65
+ if (!this.pool) throw new Error("Store not initialized");
66
+ const [rows] = await this.pool.query(
67
+ `SELECT * FROM \`${this.tableName}\` WHERE id = ? AND saga_name = ?`,
68
+ [sagaId, sagaName]
69
+ );
70
+ if (rows.length === 0) {
71
+ return null;
72
+ }
73
+ return this.rowToState(rows[0]);
74
+ }
75
+ async getByCorrelationId(sagaName, correlationId) {
76
+ if (!this.pool) throw new Error("Store not initialized");
77
+ const [rows] = await this.pool.query(
78
+ `SELECT * FROM \`${this.tableName}\` WHERE saga_name = ? AND correlation_id = ?`,
79
+ [sagaName, correlationId]
80
+ );
81
+ if (rows.length === 0) {
82
+ return null;
83
+ }
84
+ return this.rowToState(rows[0]);
85
+ }
86
+ async insert(sagaName, correlationId, state) {
87
+ if (!this.pool) throw new Error("Store not initialized");
88
+ const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
89
+ await this.pool.query(
90
+ `INSERT INTO \`${this.tableName}\`
91
+ (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
93
+ [
94
+ sagaId,
95
+ sagaName,
96
+ correlationId,
97
+ version,
98
+ isCompleted ? 1 : 0,
99
+ JSON.stringify(state),
100
+ createdAt,
101
+ updatedAt
102
+ ]
103
+ );
104
+ }
105
+ async update(sagaName, state, expectedVersion) {
106
+ if (!this.pool) throw new Error("Store not initialized");
107
+ const { sagaId, version, isCompleted, updatedAt } = state.metadata;
108
+ const [result] = await this.pool.query(
109
+ `UPDATE \`${this.tableName}\`
110
+ SET version = ?, is_completed = ?, state = ?, updated_at = ?
111
+ WHERE id = ? AND saga_name = ? AND version = ?`,
112
+ [
113
+ version,
114
+ isCompleted ? 1 : 0,
115
+ JSON.stringify(state),
116
+ updatedAt,
117
+ sagaId,
118
+ sagaName,
119
+ expectedVersion
120
+ ]
121
+ );
122
+ if (result.affectedRows === 0) {
123
+ const existing = await this.getById(sagaName, sagaId);
124
+ if (existing) {
125
+ throw new import_core.ConcurrencyError(
126
+ sagaId,
127
+ expectedVersion,
128
+ existing.metadata.version
129
+ );
130
+ } else {
131
+ throw new Error(`Saga ${sagaId} not found`);
132
+ }
133
+ }
134
+ }
135
+ async delete(sagaName, sagaId) {
136
+ if (!this.pool) throw new Error("Store not initialized");
137
+ await this.pool.query(
138
+ `DELETE FROM \`${this.tableName}\` WHERE id = ? AND saga_name = ?`,
139
+ [sagaId, sagaName]
140
+ );
141
+ }
142
+ /**
143
+ * Convert a database row to saga state.
144
+ */
145
+ rowToState(row) {
146
+ const state = JSON.parse(row.state);
147
+ return {
148
+ ...state,
149
+ metadata: {
150
+ ...state.metadata,
151
+ sagaId: row.id,
152
+ version: row.version,
153
+ isCompleted: row.is_completed === 1,
154
+ createdAt: new Date(row.created_at),
155
+ updatedAt: new Date(row.updated_at)
156
+ }
157
+ };
158
+ }
159
+ /**
160
+ * Close the connection pool (if owned by this store).
161
+ */
162
+ async close() {
163
+ if (this.ownsPool && this.pool) {
164
+ await this.pool.end();
165
+ }
166
+ this.pool = null;
167
+ }
168
+ /**
169
+ * Get the underlying pool for advanced operations.
170
+ */
171
+ getPool() {
172
+ return this.pool;
173
+ }
174
+ // ============ Query Helpers ============
175
+ /**
176
+ * Find sagas by name with pagination.
177
+ */
178
+ async findByName(sagaName, options) {
179
+ if (!this.pool) throw new Error("Store not initialized");
180
+ let query = `SELECT * FROM \`${this.tableName}\` WHERE saga_name = ?`;
181
+ const params = [sagaName];
182
+ if (options?.completed !== void 0) {
183
+ query += ` AND is_completed = ?`;
184
+ params.push(options.completed ? 1 : 0);
185
+ }
186
+ query += ` ORDER BY created_at DESC`;
187
+ if (options?.limit !== void 0) {
188
+ query += ` LIMIT ?`;
189
+ params.push(options.limit);
190
+ }
191
+ if (options?.offset !== void 0) {
192
+ query += ` OFFSET ?`;
193
+ params.push(options.offset);
194
+ }
195
+ const [rows] = await this.pool.query(
196
+ query,
197
+ params
198
+ );
199
+ return rows.map((row) => this.rowToState(row));
200
+ }
201
+ /**
202
+ * Count sagas by name.
203
+ */
204
+ async countByName(sagaName, options) {
205
+ if (!this.pool) throw new Error("Store not initialized");
206
+ let query = `SELECT COUNT(*) as count FROM \`${this.tableName}\` WHERE saga_name = ?`;
207
+ const params = [sagaName];
208
+ if (options?.completed !== void 0) {
209
+ query += ` AND is_completed = ?`;
210
+ params.push(options.completed ? 1 : 0);
211
+ }
212
+ const [rows] = await this.pool.query(
213
+ query,
214
+ params
215
+ );
216
+ return rows[0]?.count ?? 0;
217
+ }
218
+ /**
219
+ * Delete completed sagas older than a given date.
220
+ */
221
+ async deleteCompletedBefore(sagaName, before) {
222
+ if (!this.pool) throw new Error("Store not initialized");
223
+ const [result] = await this.pool.query(
224
+ `DELETE FROM \`${this.tableName}\`
225
+ WHERE saga_name = ? AND is_completed = 1 AND updated_at < ?`,
226
+ [sagaName, before]
227
+ );
228
+ return result.affectedRows;
229
+ }
230
+ };
231
+ // Annotate the CommonJS export names for ESM import in node:
232
+ 0 && (module.exports = {
233
+ MySqlSagaStore
234
+ });
235
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/MySqlSagaStore.ts"],"sourcesContent":["export { MySqlSagaStore } from \"./MySqlSagaStore.js\";\nexport type { MySqlSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n","import * as mysql from \"mysql2/promise\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { MySqlSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n\ntype RowDataPacket = mysql.RowDataPacket;\ntype ResultSetHeader = mysql.ResultSetHeader;\ntype PoolOptions = mysql.PoolOptions;\ntype FieldPacket = mysql.FieldPacket;\n\n// mysql2 types have issues with the mixin pattern, so we define our own interface\ninterface QueryablePool {\n query<T extends mysql.RowDataPacket[][] | mysql.RowDataPacket[] | mysql.OkPacket | mysql.OkPacket[] | mysql.ResultSetHeader>(\n sql: string,\n values?: unknown[]\n ): Promise<[T, FieldPacket[]]>;\n end(): Promise<void>;\n getConnection(): Promise<mysql.PoolConnection>;\n}\n\n/**\n * MySQL-backed saga store for saga-bus.\n *\n * @example\n * ```typescript\n * const store = new MySqlSagaStore<OrderState>({\n * pool: {\n * host: \"localhost\",\n * user: \"root\",\n * password: \"password\",\n * database: \"sagas\",\n * },\n * });\n *\n * await store.initialize();\n * ```\n */\nexport class MySqlSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private pool: QueryablePool | null = null;\n private readonly poolOptions: PoolOptions | null;\n private readonly tableName: string;\n private readonly ownsPool: boolean;\n\n constructor(options: MySqlSagaStoreOptions) {\n // Check if it's a Pool by looking for query method\n if (typeof (options.pool as QueryablePool).query === \"function\") {\n this.pool = options.pool as QueryablePool;\n this.poolOptions = null;\n this.ownsPool = false;\n } else {\n this.poolOptions = options.pool as PoolOptions;\n this.ownsPool = true;\n }\n\n this.tableName = options.tableName ?? \"saga_instances\";\n }\n\n /**\n * Initialize the connection pool if using config.\n */\n async initialize(): Promise<void> {\n if (this.poolOptions && !this.pool) {\n this.pool = mysql.createPool(this.poolOptions) as unknown as QueryablePool;\n }\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n `SELECT * FROM \\`${this.tableName}\\` WHERE id = ? AND saga_name = ?`,\n [sagaId, sagaName]\n );\n\n if (rows.length === 0) {\n return null;\n }\n\n return this.rowToState(rows[0]!);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n `SELECT * FROM \\`${this.tableName}\\` WHERE saga_name = ? AND correlation_id = ?`,\n [sagaName, correlationId]\n );\n\n if (rows.length === 0) {\n return null;\n }\n\n return this.rowToState(rows[0]!);\n }\n\n async insert(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const { sagaId, version, isCompleted, createdAt, updatedAt } =\n state.metadata;\n\n await this.pool.query(\n `INSERT INTO \\`${this.tableName}\\`\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted ? 1 : 0,\n JSON.stringify(state),\n createdAt,\n updatedAt,\n ]\n );\n }\n\n async update(\n sagaName: string,\n state: TState,\n expectedVersion: number\n ): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const { sagaId, version, isCompleted, updatedAt } = state.metadata;\n\n const [result] = await this.pool.query<ResultSetHeader>(\n `UPDATE \\`${this.tableName}\\`\n SET version = ?, is_completed = ?, state = ?, updated_at = ?\n WHERE id = ? AND saga_name = ? AND version = ?`,\n [\n version,\n isCompleted ? 1 : 0,\n JSON.stringify(state),\n updatedAt,\n sagaId,\n sagaName,\n expectedVersion,\n ]\n );\n\n if (result.affectedRows === 0) {\n // Either saga doesn't exist or version mismatch\n const existing = await this.getById(sagaName, sagaId);\n if (existing) {\n throw new ConcurrencyError(\n sagaId,\n expectedVersion,\n existing.metadata.version\n );\n } else {\n throw new Error(`Saga ${sagaId} not found`);\n }\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n await this.pool.query(\n `DELETE FROM \\`${this.tableName}\\` WHERE id = ? AND saga_name = ?`,\n [sagaId, sagaName]\n );\n }\n\n /**\n * Convert a database row to saga state.\n */\n private rowToState(row: SagaInstanceRow): TState {\n const state = JSON.parse(row.state) as TState;\n\n // Ensure metadata dates are Date objects\n return {\n ...state,\n metadata: {\n ...state.metadata,\n sagaId: row.id,\n version: row.version,\n isCompleted: row.is_completed === 1,\n createdAt: new Date(row.created_at),\n updatedAt: new Date(row.updated_at),\n },\n };\n }\n\n /**\n * Close the connection pool (if owned by this store).\n */\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) {\n await this.pool.end();\n }\n this.pool = null;\n }\n\n /**\n * Get the underlying pool for advanced operations.\n */\n getPool(): QueryablePool | null {\n return this.pool;\n }\n\n // ============ Query Helpers ============\n\n /**\n * Find sagas by name with pagination.\n */\n async findByName(\n sagaName: string,\n options?: {\n limit?: number;\n offset?: number;\n completed?: boolean;\n }\n ): Promise<TState[]> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n let query = `SELECT * FROM \\`${this.tableName}\\` WHERE saga_name = ?`;\n const params: (string | number | boolean)[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = ?`;\n params.push(options.completed ? 1 : 0);\n }\n\n query += ` ORDER BY created_at DESC`;\n\n if (options?.limit !== undefined) {\n query += ` LIMIT ?`;\n params.push(options.limit);\n }\n\n if (options?.offset !== undefined) {\n query += ` OFFSET ?`;\n params.push(options.offset);\n }\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n query,\n params\n );\n\n return rows.map((row) => this.rowToState(row));\n }\n\n /**\n * Count sagas by name.\n */\n async countByName(\n sagaName: string,\n options?: { completed?: boolean }\n ): Promise<number> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n let query = `SELECT COUNT(*) as count FROM \\`${this.tableName}\\` WHERE saga_name = ?`;\n const params: (string | number)[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = ?`;\n params.push(options.completed ? 1 : 0);\n }\n\n const [rows] = await this.pool.query<({ count: number } & RowDataPacket)[]>(\n query,\n params\n );\n\n return rows[0]?.count ?? 0;\n }\n\n /**\n * Delete completed sagas older than a given date.\n */\n async deleteCompletedBefore(\n sagaName: string,\n before: Date\n ): Promise<number> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [result] = await this.pool.query<ResultSetHeader>(\n `DELETE FROM \\`${this.tableName}\\`\n WHERE saga_name = ? AND is_completed = 1 AND updated_at < ?`,\n [sagaName, before]\n );\n\n return result.affectedRows;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,103 @@
1
+ import * as mysql from 'mysql2/promise';
2
+ import { SagaState, SagaStore } from '@saga-bus/core';
3
+
4
+ type Pool = mysql.Pool;
5
+ type PoolOptions = mysql.PoolOptions;
6
+ /**
7
+ * Options for creating a MySqlSagaStore.
8
+ */
9
+ interface MySqlSagaStoreOptions {
10
+ /**
11
+ * MySQL connection pool or pool configuration.
12
+ */
13
+ pool: Pool | PoolOptions;
14
+ /**
15
+ * Table name for saga instances. Default: "saga_instances"
16
+ */
17
+ tableName?: string;
18
+ }
19
+ /**
20
+ * Row structure in the saga_instances table.
21
+ */
22
+ interface SagaInstanceRow {
23
+ id: string;
24
+ saga_name: string;
25
+ correlation_id: string;
26
+ version: number;
27
+ is_completed: number;
28
+ state: string;
29
+ created_at: Date;
30
+ updated_at: Date;
31
+ }
32
+
33
+ type FieldPacket = mysql.FieldPacket;
34
+ interface QueryablePool {
35
+ query<T extends mysql.RowDataPacket[][] | mysql.RowDataPacket[] | mysql.OkPacket | mysql.OkPacket[] | mysql.ResultSetHeader>(sql: string, values?: unknown[]): Promise<[T, FieldPacket[]]>;
36
+ end(): Promise<void>;
37
+ getConnection(): Promise<mysql.PoolConnection>;
38
+ }
39
+ /**
40
+ * MySQL-backed saga store for saga-bus.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const store = new MySqlSagaStore<OrderState>({
45
+ * pool: {
46
+ * host: "localhost",
47
+ * user: "root",
48
+ * password: "password",
49
+ * database: "sagas",
50
+ * },
51
+ * });
52
+ *
53
+ * await store.initialize();
54
+ * ```
55
+ */
56
+ declare class MySqlSagaStore<TState extends SagaState> implements SagaStore<TState> {
57
+ private pool;
58
+ private readonly poolOptions;
59
+ private readonly tableName;
60
+ private readonly ownsPool;
61
+ constructor(options: MySqlSagaStoreOptions);
62
+ /**
63
+ * Initialize the connection pool if using config.
64
+ */
65
+ initialize(): Promise<void>;
66
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
67
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
68
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
69
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
70
+ delete(sagaName: string, sagaId: string): Promise<void>;
71
+ /**
72
+ * Convert a database row to saga state.
73
+ */
74
+ private rowToState;
75
+ /**
76
+ * Close the connection pool (if owned by this store).
77
+ */
78
+ close(): Promise<void>;
79
+ /**
80
+ * Get the underlying pool for advanced operations.
81
+ */
82
+ getPool(): QueryablePool | null;
83
+ /**
84
+ * Find sagas by name with pagination.
85
+ */
86
+ findByName(sagaName: string, options?: {
87
+ limit?: number;
88
+ offset?: number;
89
+ completed?: boolean;
90
+ }): Promise<TState[]>;
91
+ /**
92
+ * Count sagas by name.
93
+ */
94
+ countByName(sagaName: string, options?: {
95
+ completed?: boolean;
96
+ }): Promise<number>;
97
+ /**
98
+ * Delete completed sagas older than a given date.
99
+ */
100
+ deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
101
+ }
102
+
103
+ export { MySqlSagaStore, type MySqlSagaStoreOptions, type SagaInstanceRow };
@@ -0,0 +1,103 @@
1
+ import * as mysql from 'mysql2/promise';
2
+ import { SagaState, SagaStore } from '@saga-bus/core';
3
+
4
+ type Pool = mysql.Pool;
5
+ type PoolOptions = mysql.PoolOptions;
6
+ /**
7
+ * Options for creating a MySqlSagaStore.
8
+ */
9
+ interface MySqlSagaStoreOptions {
10
+ /**
11
+ * MySQL connection pool or pool configuration.
12
+ */
13
+ pool: Pool | PoolOptions;
14
+ /**
15
+ * Table name for saga instances. Default: "saga_instances"
16
+ */
17
+ tableName?: string;
18
+ }
19
+ /**
20
+ * Row structure in the saga_instances table.
21
+ */
22
+ interface SagaInstanceRow {
23
+ id: string;
24
+ saga_name: string;
25
+ correlation_id: string;
26
+ version: number;
27
+ is_completed: number;
28
+ state: string;
29
+ created_at: Date;
30
+ updated_at: Date;
31
+ }
32
+
33
+ type FieldPacket = mysql.FieldPacket;
34
+ interface QueryablePool {
35
+ query<T extends mysql.RowDataPacket[][] | mysql.RowDataPacket[] | mysql.OkPacket | mysql.OkPacket[] | mysql.ResultSetHeader>(sql: string, values?: unknown[]): Promise<[T, FieldPacket[]]>;
36
+ end(): Promise<void>;
37
+ getConnection(): Promise<mysql.PoolConnection>;
38
+ }
39
+ /**
40
+ * MySQL-backed saga store for saga-bus.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const store = new MySqlSagaStore<OrderState>({
45
+ * pool: {
46
+ * host: "localhost",
47
+ * user: "root",
48
+ * password: "password",
49
+ * database: "sagas",
50
+ * },
51
+ * });
52
+ *
53
+ * await store.initialize();
54
+ * ```
55
+ */
56
+ declare class MySqlSagaStore<TState extends SagaState> implements SagaStore<TState> {
57
+ private pool;
58
+ private readonly poolOptions;
59
+ private readonly tableName;
60
+ private readonly ownsPool;
61
+ constructor(options: MySqlSagaStoreOptions);
62
+ /**
63
+ * Initialize the connection pool if using config.
64
+ */
65
+ initialize(): Promise<void>;
66
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
67
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
68
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
69
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
70
+ delete(sagaName: string, sagaId: string): Promise<void>;
71
+ /**
72
+ * Convert a database row to saga state.
73
+ */
74
+ private rowToState;
75
+ /**
76
+ * Close the connection pool (if owned by this store).
77
+ */
78
+ close(): Promise<void>;
79
+ /**
80
+ * Get the underlying pool for advanced operations.
81
+ */
82
+ getPool(): QueryablePool | null;
83
+ /**
84
+ * Find sagas by name with pagination.
85
+ */
86
+ findByName(sagaName: string, options?: {
87
+ limit?: number;
88
+ offset?: number;
89
+ completed?: boolean;
90
+ }): Promise<TState[]>;
91
+ /**
92
+ * Count sagas by name.
93
+ */
94
+ countByName(sagaName: string, options?: {
95
+ completed?: boolean;
96
+ }): Promise<number>;
97
+ /**
98
+ * Delete completed sagas older than a given date.
99
+ */
100
+ deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
101
+ }
102
+
103
+ export { MySqlSagaStore, type MySqlSagaStoreOptions, type SagaInstanceRow };
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ // src/MySqlSagaStore.ts
2
+ import * as mysql from "mysql2/promise";
3
+ import { ConcurrencyError } from "@saga-bus/core";
4
+ var MySqlSagaStore = class {
5
+ pool = null;
6
+ poolOptions;
7
+ tableName;
8
+ ownsPool;
9
+ constructor(options) {
10
+ if (typeof options.pool.query === "function") {
11
+ this.pool = options.pool;
12
+ this.poolOptions = null;
13
+ this.ownsPool = false;
14
+ } else {
15
+ this.poolOptions = options.pool;
16
+ this.ownsPool = true;
17
+ }
18
+ this.tableName = options.tableName ?? "saga_instances";
19
+ }
20
+ /**
21
+ * Initialize the connection pool if using config.
22
+ */
23
+ async initialize() {
24
+ if (this.poolOptions && !this.pool) {
25
+ this.pool = mysql.createPool(this.poolOptions);
26
+ }
27
+ }
28
+ async getById(sagaName, sagaId) {
29
+ if (!this.pool) throw new Error("Store not initialized");
30
+ const [rows] = await this.pool.query(
31
+ `SELECT * FROM \`${this.tableName}\` WHERE id = ? AND saga_name = ?`,
32
+ [sagaId, sagaName]
33
+ );
34
+ if (rows.length === 0) {
35
+ return null;
36
+ }
37
+ return this.rowToState(rows[0]);
38
+ }
39
+ async getByCorrelationId(sagaName, correlationId) {
40
+ if (!this.pool) throw new Error("Store not initialized");
41
+ const [rows] = await this.pool.query(
42
+ `SELECT * FROM \`${this.tableName}\` WHERE saga_name = ? AND correlation_id = ?`,
43
+ [sagaName, correlationId]
44
+ );
45
+ if (rows.length === 0) {
46
+ return null;
47
+ }
48
+ return this.rowToState(rows[0]);
49
+ }
50
+ async insert(sagaName, correlationId, state) {
51
+ if (!this.pool) throw new Error("Store not initialized");
52
+ const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
53
+ await this.pool.query(
54
+ `INSERT INTO \`${this.tableName}\`
55
+ (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
57
+ [
58
+ sagaId,
59
+ sagaName,
60
+ correlationId,
61
+ version,
62
+ isCompleted ? 1 : 0,
63
+ JSON.stringify(state),
64
+ createdAt,
65
+ updatedAt
66
+ ]
67
+ );
68
+ }
69
+ async update(sagaName, state, expectedVersion) {
70
+ if (!this.pool) throw new Error("Store not initialized");
71
+ const { sagaId, version, isCompleted, updatedAt } = state.metadata;
72
+ const [result] = await this.pool.query(
73
+ `UPDATE \`${this.tableName}\`
74
+ SET version = ?, is_completed = ?, state = ?, updated_at = ?
75
+ WHERE id = ? AND saga_name = ? AND version = ?`,
76
+ [
77
+ version,
78
+ isCompleted ? 1 : 0,
79
+ JSON.stringify(state),
80
+ updatedAt,
81
+ sagaId,
82
+ sagaName,
83
+ expectedVersion
84
+ ]
85
+ );
86
+ if (result.affectedRows === 0) {
87
+ const existing = await this.getById(sagaName, sagaId);
88
+ if (existing) {
89
+ throw new ConcurrencyError(
90
+ sagaId,
91
+ expectedVersion,
92
+ existing.metadata.version
93
+ );
94
+ } else {
95
+ throw new Error(`Saga ${sagaId} not found`);
96
+ }
97
+ }
98
+ }
99
+ async delete(sagaName, sagaId) {
100
+ if (!this.pool) throw new Error("Store not initialized");
101
+ await this.pool.query(
102
+ `DELETE FROM \`${this.tableName}\` WHERE id = ? AND saga_name = ?`,
103
+ [sagaId, sagaName]
104
+ );
105
+ }
106
+ /**
107
+ * Convert a database row to saga state.
108
+ */
109
+ rowToState(row) {
110
+ const state = JSON.parse(row.state);
111
+ return {
112
+ ...state,
113
+ metadata: {
114
+ ...state.metadata,
115
+ sagaId: row.id,
116
+ version: row.version,
117
+ isCompleted: row.is_completed === 1,
118
+ createdAt: new Date(row.created_at),
119
+ updatedAt: new Date(row.updated_at)
120
+ }
121
+ };
122
+ }
123
+ /**
124
+ * Close the connection pool (if owned by this store).
125
+ */
126
+ async close() {
127
+ if (this.ownsPool && this.pool) {
128
+ await this.pool.end();
129
+ }
130
+ this.pool = null;
131
+ }
132
+ /**
133
+ * Get the underlying pool for advanced operations.
134
+ */
135
+ getPool() {
136
+ return this.pool;
137
+ }
138
+ // ============ Query Helpers ============
139
+ /**
140
+ * Find sagas by name with pagination.
141
+ */
142
+ async findByName(sagaName, options) {
143
+ if (!this.pool) throw new Error("Store not initialized");
144
+ let query = `SELECT * FROM \`${this.tableName}\` WHERE saga_name = ?`;
145
+ const params = [sagaName];
146
+ if (options?.completed !== void 0) {
147
+ query += ` AND is_completed = ?`;
148
+ params.push(options.completed ? 1 : 0);
149
+ }
150
+ query += ` ORDER BY created_at DESC`;
151
+ if (options?.limit !== void 0) {
152
+ query += ` LIMIT ?`;
153
+ params.push(options.limit);
154
+ }
155
+ if (options?.offset !== void 0) {
156
+ query += ` OFFSET ?`;
157
+ params.push(options.offset);
158
+ }
159
+ const [rows] = await this.pool.query(
160
+ query,
161
+ params
162
+ );
163
+ return rows.map((row) => this.rowToState(row));
164
+ }
165
+ /**
166
+ * Count sagas by name.
167
+ */
168
+ async countByName(sagaName, options) {
169
+ if (!this.pool) throw new Error("Store not initialized");
170
+ let query = `SELECT COUNT(*) as count FROM \`${this.tableName}\` WHERE saga_name = ?`;
171
+ const params = [sagaName];
172
+ if (options?.completed !== void 0) {
173
+ query += ` AND is_completed = ?`;
174
+ params.push(options.completed ? 1 : 0);
175
+ }
176
+ const [rows] = await this.pool.query(
177
+ query,
178
+ params
179
+ );
180
+ return rows[0]?.count ?? 0;
181
+ }
182
+ /**
183
+ * Delete completed sagas older than a given date.
184
+ */
185
+ async deleteCompletedBefore(sagaName, before) {
186
+ if (!this.pool) throw new Error("Store not initialized");
187
+ const [result] = await this.pool.query(
188
+ `DELETE FROM \`${this.tableName}\`
189
+ WHERE saga_name = ? AND is_completed = 1 AND updated_at < ?`,
190
+ [sagaName, before]
191
+ );
192
+ return result.affectedRows;
193
+ }
194
+ };
195
+ export {
196
+ MySqlSagaStore
197
+ };
198
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/MySqlSagaStore.ts"],"sourcesContent":["import * as mysql from \"mysql2/promise\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { MySqlSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n\ntype RowDataPacket = mysql.RowDataPacket;\ntype ResultSetHeader = mysql.ResultSetHeader;\ntype PoolOptions = mysql.PoolOptions;\ntype FieldPacket = mysql.FieldPacket;\n\n// mysql2 types have issues with the mixin pattern, so we define our own interface\ninterface QueryablePool {\n query<T extends mysql.RowDataPacket[][] | mysql.RowDataPacket[] | mysql.OkPacket | mysql.OkPacket[] | mysql.ResultSetHeader>(\n sql: string,\n values?: unknown[]\n ): Promise<[T, FieldPacket[]]>;\n end(): Promise<void>;\n getConnection(): Promise<mysql.PoolConnection>;\n}\n\n/**\n * MySQL-backed saga store for saga-bus.\n *\n * @example\n * ```typescript\n * const store = new MySqlSagaStore<OrderState>({\n * pool: {\n * host: \"localhost\",\n * user: \"root\",\n * password: \"password\",\n * database: \"sagas\",\n * },\n * });\n *\n * await store.initialize();\n * ```\n */\nexport class MySqlSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private pool: QueryablePool | null = null;\n private readonly poolOptions: PoolOptions | null;\n private readonly tableName: string;\n private readonly ownsPool: boolean;\n\n constructor(options: MySqlSagaStoreOptions) {\n // Check if it's a Pool by looking for query method\n if (typeof (options.pool as QueryablePool).query === \"function\") {\n this.pool = options.pool as QueryablePool;\n this.poolOptions = null;\n this.ownsPool = false;\n } else {\n this.poolOptions = options.pool as PoolOptions;\n this.ownsPool = true;\n }\n\n this.tableName = options.tableName ?? \"saga_instances\";\n }\n\n /**\n * Initialize the connection pool if using config.\n */\n async initialize(): Promise<void> {\n if (this.poolOptions && !this.pool) {\n this.pool = mysql.createPool(this.poolOptions) as unknown as QueryablePool;\n }\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n `SELECT * FROM \\`${this.tableName}\\` WHERE id = ? AND saga_name = ?`,\n [sagaId, sagaName]\n );\n\n if (rows.length === 0) {\n return null;\n }\n\n return this.rowToState(rows[0]!);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n `SELECT * FROM \\`${this.tableName}\\` WHERE saga_name = ? AND correlation_id = ?`,\n [sagaName, correlationId]\n );\n\n if (rows.length === 0) {\n return null;\n }\n\n return this.rowToState(rows[0]!);\n }\n\n async insert(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const { sagaId, version, isCompleted, createdAt, updatedAt } =\n state.metadata;\n\n await this.pool.query(\n `INSERT INTO \\`${this.tableName}\\`\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted ? 1 : 0,\n JSON.stringify(state),\n createdAt,\n updatedAt,\n ]\n );\n }\n\n async update(\n sagaName: string,\n state: TState,\n expectedVersion: number\n ): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const { sagaId, version, isCompleted, updatedAt } = state.metadata;\n\n const [result] = await this.pool.query<ResultSetHeader>(\n `UPDATE \\`${this.tableName}\\`\n SET version = ?, is_completed = ?, state = ?, updated_at = ?\n WHERE id = ? AND saga_name = ? AND version = ?`,\n [\n version,\n isCompleted ? 1 : 0,\n JSON.stringify(state),\n updatedAt,\n sagaId,\n sagaName,\n expectedVersion,\n ]\n );\n\n if (result.affectedRows === 0) {\n // Either saga doesn't exist or version mismatch\n const existing = await this.getById(sagaName, sagaId);\n if (existing) {\n throw new ConcurrencyError(\n sagaId,\n expectedVersion,\n existing.metadata.version\n );\n } else {\n throw new Error(`Saga ${sagaId} not found`);\n }\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n await this.pool.query(\n `DELETE FROM \\`${this.tableName}\\` WHERE id = ? AND saga_name = ?`,\n [sagaId, sagaName]\n );\n }\n\n /**\n * Convert a database row to saga state.\n */\n private rowToState(row: SagaInstanceRow): TState {\n const state = JSON.parse(row.state) as TState;\n\n // Ensure metadata dates are Date objects\n return {\n ...state,\n metadata: {\n ...state.metadata,\n sagaId: row.id,\n version: row.version,\n isCompleted: row.is_completed === 1,\n createdAt: new Date(row.created_at),\n updatedAt: new Date(row.updated_at),\n },\n };\n }\n\n /**\n * Close the connection pool (if owned by this store).\n */\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) {\n await this.pool.end();\n }\n this.pool = null;\n }\n\n /**\n * Get the underlying pool for advanced operations.\n */\n getPool(): QueryablePool | null {\n return this.pool;\n }\n\n // ============ Query Helpers ============\n\n /**\n * Find sagas by name with pagination.\n */\n async findByName(\n sagaName: string,\n options?: {\n limit?: number;\n offset?: number;\n completed?: boolean;\n }\n ): Promise<TState[]> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n let query = `SELECT * FROM \\`${this.tableName}\\` WHERE saga_name = ?`;\n const params: (string | number | boolean)[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = ?`;\n params.push(options.completed ? 1 : 0);\n }\n\n query += ` ORDER BY created_at DESC`;\n\n if (options?.limit !== undefined) {\n query += ` LIMIT ?`;\n params.push(options.limit);\n }\n\n if (options?.offset !== undefined) {\n query += ` OFFSET ?`;\n params.push(options.offset);\n }\n\n const [rows] = await this.pool.query<(SagaInstanceRow & RowDataPacket)[]>(\n query,\n params\n );\n\n return rows.map((row) => this.rowToState(row));\n }\n\n /**\n * Count sagas by name.\n */\n async countByName(\n sagaName: string,\n options?: { completed?: boolean }\n ): Promise<number> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n let query = `SELECT COUNT(*) as count FROM \\`${this.tableName}\\` WHERE saga_name = ?`;\n const params: (string | number)[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = ?`;\n params.push(options.completed ? 1 : 0);\n }\n\n const [rows] = await this.pool.query<({ count: number } & RowDataPacket)[]>(\n query,\n params\n );\n\n return rows[0]?.count ?? 0;\n }\n\n /**\n * Delete completed sagas older than a given date.\n */\n async deleteCompletedBefore(\n sagaName: string,\n before: Date\n ): Promise<number> {\n if (!this.pool) throw new Error(\"Store not initialized\");\n\n const [result] = await this.pool.query<ResultSetHeader>(\n `DELETE FROM \\`${this.tableName}\\`\n WHERE saga_name = ? AND is_completed = 1 AND updated_at < ?`,\n [sagaName, before]\n );\n\n return result.affectedRows;\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@saga-bus/store-mysql",
3
+ "version": "1.0.0",
4
+ "description": "MySQL saga store for saga-bus",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/deanforan/saga-bus.git",
26
+ "directory": "packages/store-mysql"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/deanforan/saga-bus/issues"
30
+ },
31
+ "homepage": "https://github.com/deanforan/saga-bus#readme",
32
+ "keywords": [
33
+ "saga",
34
+ "message-bus",
35
+ "store",
36
+ "mysql",
37
+ "mariadb",
38
+ "planetscale"
39
+ ],
40
+ "dependencies": {
41
+ "@saga-bus/core": "0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "mysql2": "^3.11.0",
45
+ "tsup": "^8.0.0",
46
+ "typescript": "^5.9.2",
47
+ "vitest": "^3.0.0",
48
+ "@repo/eslint-config": "0.0.0",
49
+ "@repo/typescript-config": "0.0.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@saga-bus/core": ">=0.1.0",
53
+ "mysql2": ">=3.0.0"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "dev": "tsup --watch",
58
+ "lint": "eslint src/",
59
+ "check-types": "tsc --noEmit",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest"
62
+ }
63
+ }