@saga-bus/store-postgres 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 +21 -0
- package/README.md +88 -0
- package/dist/index.cjs +286 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +120 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +256 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
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,88 @@
|
|
|
1
|
+
# @saga-bus/store-postgres
|
|
2
|
+
|
|
3
|
+
PostgreSQL saga store using native pg driver.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @saga-bus/store-postgres pg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Pool } from "pg";
|
|
15
|
+
import { PostgresSagaStore, createSchema } from "@saga-bus/store-postgres";
|
|
16
|
+
import { createBus } from "@saga-bus/core";
|
|
17
|
+
|
|
18
|
+
const pool = new Pool({
|
|
19
|
+
connectionString: "postgresql://user:pass@localhost:5432/mydb",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Initialize schema (run once)
|
|
23
|
+
await createSchema(pool);
|
|
24
|
+
|
|
25
|
+
const store = new PostgresSagaStore({ pool });
|
|
26
|
+
|
|
27
|
+
const bus = createBus({
|
|
28
|
+
sagas: [{ definition: mySaga, store }],
|
|
29
|
+
transport,
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Database Schema
|
|
34
|
+
|
|
35
|
+
The `createSchema` function creates:
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
CREATE TABLE saga_instances (
|
|
39
|
+
id VARCHAR(128) NOT NULL,
|
|
40
|
+
saga_name VARCHAR(128) NOT NULL,
|
|
41
|
+
correlation_id VARCHAR(256) NOT NULL,
|
|
42
|
+
version INTEGER NOT NULL,
|
|
43
|
+
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
44
|
+
state JSONB NOT NULL,
|
|
45
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
46
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
47
|
+
PRIMARY KEY (saga_name, id)
|
|
48
|
+
);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- Optimistic concurrency with version column
|
|
54
|
+
- JSONB state storage
|
|
55
|
+
- Connection pooling
|
|
56
|
+
- Indexed lookups by correlation ID
|
|
57
|
+
- Cleanup helpers for completed sagas
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
| Option | Type | Default | Description |
|
|
62
|
+
|--------|------|---------|-------------|
|
|
63
|
+
| `pool` | `Pool` | required | pg Pool instance |
|
|
64
|
+
| `schema` | `string` | `"public"` | Database schema |
|
|
65
|
+
| `tableName` | `string` | `"saga_instances"` | Table name |
|
|
66
|
+
|
|
67
|
+
## Sharing Across Sagas
|
|
68
|
+
|
|
69
|
+
A single store instance can be shared across multiple sagas:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const store = new PostgresSagaStore({ pool });
|
|
73
|
+
|
|
74
|
+
const bus = createBus({
|
|
75
|
+
transport,
|
|
76
|
+
store, // shared by all sagas
|
|
77
|
+
sagas: [
|
|
78
|
+
{ definition: orderSaga },
|
|
79
|
+
{ definition: paymentSaga },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Data is isolated by `saga_name` in the database, so different saga types won't conflict.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
PostgresSagaStore: () => PostgresSagaStore,
|
|
24
|
+
createSchema: () => createSchema,
|
|
25
|
+
dropSchema: () => dropSchema,
|
|
26
|
+
getSchemaSql: () => getSchemaSql
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/PostgresSagaStore.ts
|
|
31
|
+
var import_pg = require("pg");
|
|
32
|
+
var import_core = require("@saga-bus/core");
|
|
33
|
+
var PostgresSagaStore = class {
|
|
34
|
+
pool;
|
|
35
|
+
tableName;
|
|
36
|
+
schema;
|
|
37
|
+
ownsPool;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
if (options.pool instanceof import_pg.Pool) {
|
|
40
|
+
this.pool = options.pool;
|
|
41
|
+
this.ownsPool = false;
|
|
42
|
+
} else {
|
|
43
|
+
this.pool = new import_pg.Pool(options.pool);
|
|
44
|
+
this.ownsPool = true;
|
|
45
|
+
}
|
|
46
|
+
this.tableName = options.tableName ?? "saga_instances";
|
|
47
|
+
this.schema = options.schema ?? "public";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get the full table name with schema.
|
|
51
|
+
*/
|
|
52
|
+
get fullTableName() {
|
|
53
|
+
return `${this.schema}.${this.tableName}`;
|
|
54
|
+
}
|
|
55
|
+
async getById(sagaName, sagaId) {
|
|
56
|
+
const result = await this.pool.query(
|
|
57
|
+
`SELECT * FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,
|
|
58
|
+
[sagaId, sagaName]
|
|
59
|
+
);
|
|
60
|
+
if (result.rows.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return this.rowToState(result.rows[0]);
|
|
64
|
+
}
|
|
65
|
+
async getByCorrelationId(sagaName, correlationId) {
|
|
66
|
+
const result = await this.pool.query(
|
|
67
|
+
`SELECT * FROM ${this.fullTableName} WHERE saga_name = $1 AND correlation_id = $2`,
|
|
68
|
+
[sagaName, correlationId]
|
|
69
|
+
);
|
|
70
|
+
if (result.rows.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return this.rowToState(result.rows[0]);
|
|
74
|
+
}
|
|
75
|
+
async insert(sagaName, correlationId, state) {
|
|
76
|
+
const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
|
|
77
|
+
await this.pool.query(
|
|
78
|
+
`INSERT INTO ${this.fullTableName}
|
|
79
|
+
(id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
|
|
80
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
81
|
+
[
|
|
82
|
+
sagaId,
|
|
83
|
+
sagaName,
|
|
84
|
+
correlationId,
|
|
85
|
+
version,
|
|
86
|
+
isCompleted,
|
|
87
|
+
JSON.stringify(state),
|
|
88
|
+
createdAt,
|
|
89
|
+
updatedAt
|
|
90
|
+
]
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Insert a saga with explicit correlation ID.
|
|
95
|
+
*/
|
|
96
|
+
async insertWithCorrelation(sagaName, correlationId, state) {
|
|
97
|
+
const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
|
|
98
|
+
await this.pool.query(
|
|
99
|
+
`INSERT INTO ${this.fullTableName}
|
|
100
|
+
(id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
|
|
101
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
102
|
+
[
|
|
103
|
+
sagaId,
|
|
104
|
+
sagaName,
|
|
105
|
+
correlationId,
|
|
106
|
+
version,
|
|
107
|
+
isCompleted,
|
|
108
|
+
JSON.stringify(state),
|
|
109
|
+
createdAt,
|
|
110
|
+
updatedAt
|
|
111
|
+
]
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
async update(sagaName, state, expectedVersion) {
|
|
115
|
+
const { sagaId, version, isCompleted, updatedAt } = state.metadata;
|
|
116
|
+
const result = await this.pool.query(
|
|
117
|
+
`UPDATE ${this.fullTableName}
|
|
118
|
+
SET version = $1, is_completed = $2, state = $3, updated_at = $4
|
|
119
|
+
WHERE id = $5 AND saga_name = $6 AND version = $7`,
|
|
120
|
+
[
|
|
121
|
+
version,
|
|
122
|
+
isCompleted,
|
|
123
|
+
JSON.stringify(state),
|
|
124
|
+
updatedAt,
|
|
125
|
+
sagaId,
|
|
126
|
+
sagaName,
|
|
127
|
+
expectedVersion
|
|
128
|
+
]
|
|
129
|
+
);
|
|
130
|
+
if (result.rowCount === 0) {
|
|
131
|
+
const existing = await this.getById(sagaName, sagaId);
|
|
132
|
+
if (existing) {
|
|
133
|
+
throw new import_core.ConcurrencyError(
|
|
134
|
+
sagaId,
|
|
135
|
+
expectedVersion,
|
|
136
|
+
existing.metadata.version
|
|
137
|
+
);
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(`Saga ${sagaId} not found`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async delete(sagaName, sagaId) {
|
|
144
|
+
await this.pool.query(
|
|
145
|
+
`DELETE FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,
|
|
146
|
+
[sagaId, sagaName]
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Convert a database row to saga state.
|
|
151
|
+
*/
|
|
152
|
+
rowToState(row) {
|
|
153
|
+
const state = row.state;
|
|
154
|
+
return {
|
|
155
|
+
...state,
|
|
156
|
+
metadata: {
|
|
157
|
+
...state.metadata,
|
|
158
|
+
sagaId: row.id,
|
|
159
|
+
version: row.version,
|
|
160
|
+
isCompleted: row.is_completed,
|
|
161
|
+
createdAt: new Date(row.created_at),
|
|
162
|
+
updatedAt: new Date(row.updated_at)
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Close the connection pool (if owned by this store).
|
|
168
|
+
*/
|
|
169
|
+
async close() {
|
|
170
|
+
if (this.ownsPool) {
|
|
171
|
+
await this.pool.end();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Get the underlying pool for advanced operations.
|
|
176
|
+
*/
|
|
177
|
+
getPool() {
|
|
178
|
+
return this.pool;
|
|
179
|
+
}
|
|
180
|
+
// ============ Query Helpers ============
|
|
181
|
+
/**
|
|
182
|
+
* Find sagas by name with pagination.
|
|
183
|
+
*/
|
|
184
|
+
async findByName(sagaName, options) {
|
|
185
|
+
let query = `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1`;
|
|
186
|
+
const params = [sagaName];
|
|
187
|
+
if (options?.completed !== void 0) {
|
|
188
|
+
query += ` AND is_completed = $${params.length + 1}`;
|
|
189
|
+
params.push(options.completed);
|
|
190
|
+
}
|
|
191
|
+
query += ` ORDER BY created_at DESC`;
|
|
192
|
+
if (options?.limit) {
|
|
193
|
+
query += ` LIMIT $${params.length + 1}`;
|
|
194
|
+
params.push(options.limit);
|
|
195
|
+
}
|
|
196
|
+
if (options?.offset) {
|
|
197
|
+
query += ` OFFSET $${params.length + 1}`;
|
|
198
|
+
params.push(options.offset);
|
|
199
|
+
}
|
|
200
|
+
const result = await this.pool.query(query, params);
|
|
201
|
+
return result.rows.map((row) => this.rowToState(row));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Count sagas by name.
|
|
205
|
+
*/
|
|
206
|
+
async countByName(sagaName, options) {
|
|
207
|
+
let query = `SELECT COUNT(*) FROM ${this.fullTableName} WHERE saga_name = $1`;
|
|
208
|
+
const params = [sagaName];
|
|
209
|
+
if (options?.completed !== void 0) {
|
|
210
|
+
query += ` AND is_completed = $${params.length + 1}`;
|
|
211
|
+
params.push(options.completed);
|
|
212
|
+
}
|
|
213
|
+
const result = await this.pool.query(query, params);
|
|
214
|
+
return parseInt(result.rows[0]?.count ?? "0", 10);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Delete completed sagas older than a given date.
|
|
218
|
+
*/
|
|
219
|
+
async deleteCompletedBefore(sagaName, before) {
|
|
220
|
+
const result = await this.pool.query(
|
|
221
|
+
`DELETE FROM ${this.fullTableName}
|
|
222
|
+
WHERE saga_name = $1 AND is_completed = true AND updated_at < $2`,
|
|
223
|
+
[sagaName, before]
|
|
224
|
+
);
|
|
225
|
+
return result.rowCount ?? 0;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// src/schema.ts
|
|
230
|
+
function getSchemaSql() {
|
|
231
|
+
return `
|
|
232
|
+
-- Saga instances table
|
|
233
|
+
CREATE TABLE IF NOT EXISTS saga_instances (
|
|
234
|
+
id VARCHAR(128) NOT NULL,
|
|
235
|
+
saga_name VARCHAR(128) NOT NULL,
|
|
236
|
+
correlation_id VARCHAR(256) NOT NULL,
|
|
237
|
+
version INTEGER NOT NULL,
|
|
238
|
+
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
239
|
+
state JSONB NOT NULL,
|
|
240
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
241
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
242
|
+
PRIMARY KEY (saga_name, id)
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
-- Index for looking up sagas by name
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_saga_name
|
|
247
|
+
ON saga_instances (saga_name);
|
|
248
|
+
|
|
249
|
+
-- Index for correlation ID lookups (saga_name + correlation_id)
|
|
250
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_saga_instances_correlation
|
|
251
|
+
ON saga_instances (saga_name, correlation_id);
|
|
252
|
+
|
|
253
|
+
-- Index for finding incomplete sagas
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_incomplete
|
|
255
|
+
ON saga_instances (saga_name, is_completed)
|
|
256
|
+
WHERE is_completed = FALSE;
|
|
257
|
+
|
|
258
|
+
-- Index for cleanup queries (completed + updated_at)
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_cleanup
|
|
260
|
+
ON saga_instances (saga_name, is_completed, updated_at)
|
|
261
|
+
WHERE is_completed = TRUE;
|
|
262
|
+
`.trim();
|
|
263
|
+
}
|
|
264
|
+
async function createSchema(pool, options) {
|
|
265
|
+
const schema = options?.schema ?? "public";
|
|
266
|
+
const tableName = options?.tableName ?? "saga_instances";
|
|
267
|
+
await pool.query(`SET search_path TO ${schema}`);
|
|
268
|
+
let sql = getSchemaSql();
|
|
269
|
+
if (tableName !== "saga_instances") {
|
|
270
|
+
sql = sql.replace(/saga_instances/g, tableName);
|
|
271
|
+
}
|
|
272
|
+
await pool.query(sql);
|
|
273
|
+
}
|
|
274
|
+
async function dropSchema(pool, options) {
|
|
275
|
+
const schema = options?.schema ?? "public";
|
|
276
|
+
const tableName = options?.tableName ?? "saga_instances";
|
|
277
|
+
await pool.query(`DROP TABLE IF EXISTS ${schema}.${tableName} CASCADE`);
|
|
278
|
+
}
|
|
279
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
280
|
+
0 && (module.exports = {
|
|
281
|
+
PostgresSagaStore,
|
|
282
|
+
createSchema,
|
|
283
|
+
dropSchema,
|
|
284
|
+
getSchemaSql
|
|
285
|
+
});
|
|
286
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/PostgresSagaStore.ts","../src/schema.ts"],"sourcesContent":["export { PostgresSagaStore } from \"./PostgresSagaStore.js\";\nexport { createSchema, dropSchema, getSchemaSql } from \"./schema.js\";\nexport type { PostgresSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n","import { Pool } from \"pg\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { PostgresSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n\n/**\n * PostgreSQL-backed saga store using native pg driver.\n *\n * @example\n * ```typescript\n * const store = new PostgresSagaStore<OrderState>({\n * pool: new Pool({ connectionString: process.env.DATABASE_URL }),\n * });\n *\n * // Or with pool config\n * const store = new PostgresSagaStore<OrderState>({\n * pool: { connectionString: process.env.DATABASE_URL },\n * });\n * ```\n */\nexport class PostgresSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private readonly pool: Pool;\n private readonly tableName: string;\n private readonly schema: string;\n private readonly ownsPool: boolean;\n\n constructor(options: PostgresSagaStoreOptions) {\n if (options.pool instanceof Pool) {\n this.pool = options.pool;\n this.ownsPool = false;\n } else {\n this.pool = new Pool(options.pool);\n this.ownsPool = true;\n }\n\n this.tableName = options.tableName ?? \"saga_instances\";\n this.schema = options.schema ?? \"public\";\n }\n\n /**\n * Get the full table name with schema.\n */\n private get fullTableName(): string {\n return `${this.schema}.${this.tableName}`;\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n const result = await this.pool.query<SagaInstanceRow>(\n `SELECT * FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,\n [sagaId, sagaName]\n );\n\n if (result.rows.length === 0) {\n return null;\n }\n\n return this.rowToState(result.rows[0]!);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n const result = await this.pool.query<SagaInstanceRow>(\n `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1 AND correlation_id = $2`,\n [sagaName, correlationId]\n );\n\n if (result.rows.length === 0) {\n return null;\n }\n\n return this.rowToState(result.rows[0]!);\n }\n\n async insert(sagaName: string, correlationId: string, state: TState): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;\n\n await this.pool.query(\n `INSERT INTO ${this.fullTableName}\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted,\n JSON.stringify(state),\n createdAt,\n updatedAt,\n ]\n );\n }\n\n /**\n * Insert a saga with explicit correlation ID.\n */\n async insertWithCorrelation(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;\n\n await this.pool.query(\n `INSERT INTO ${this.fullTableName}\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted,\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 const { sagaId, version, isCompleted, updatedAt } = state.metadata;\n\n const result = await this.pool.query(\n `UPDATE ${this.fullTableName}\n SET version = $1, is_completed = $2, state = $3, updated_at = $4\n WHERE id = $5 AND saga_name = $6 AND version = $7`,\n [\n version,\n isCompleted,\n JSON.stringify(state),\n updatedAt,\n sagaId,\n sagaName,\n expectedVersion,\n ]\n );\n\n if (result.rowCount === 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 await this.pool.query(\n `DELETE FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,\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 = 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,\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) {\n await this.pool.end();\n }\n }\n\n /**\n * Get the underlying pool for advanced operations.\n */\n getPool(): Pool {\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 let query = `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1`;\n const params: unknown[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = $${params.length + 1}`;\n params.push(options.completed);\n }\n\n query += ` ORDER BY created_at DESC`;\n\n if (options?.limit) {\n query += ` LIMIT $${params.length + 1}`;\n params.push(options.limit);\n }\n\n if (options?.offset) {\n query += ` OFFSET $${params.length + 1}`;\n params.push(options.offset);\n }\n\n const result = await this.pool.query<SagaInstanceRow>(query, params);\n return result.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 let query = `SELECT COUNT(*) FROM ${this.fullTableName} WHERE saga_name = $1`;\n const params: unknown[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = $${params.length + 1}`;\n params.push(options.completed);\n }\n\n const result = await this.pool.query<{ count: string }>(query, params);\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\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 const result = await this.pool.query(\n `DELETE FROM ${this.fullTableName}\n WHERE saga_name = $1 AND is_completed = true AND updated_at < $2`,\n [sagaName, before]\n );\n\n return result.rowCount ?? 0;\n }\n}\n","import type { Pool } from \"pg\";\n\n/**\n * Get the schema SQL content.\n */\nexport function getSchemaSql(): string {\n return `\n-- Saga instances table\nCREATE TABLE IF NOT EXISTS saga_instances (\n id VARCHAR(128) NOT NULL,\n saga_name VARCHAR(128) NOT NULL,\n correlation_id VARCHAR(256) NOT NULL,\n version INTEGER NOT NULL,\n is_completed BOOLEAN NOT NULL DEFAULT FALSE,\n state JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (saga_name, id)\n);\n\n-- Index for looking up sagas by name\nCREATE INDEX IF NOT EXISTS idx_saga_instances_saga_name\n ON saga_instances (saga_name);\n\n-- Index for correlation ID lookups (saga_name + correlation_id)\nCREATE UNIQUE INDEX IF NOT EXISTS idx_saga_instances_correlation\n ON saga_instances (saga_name, correlation_id);\n\n-- Index for finding incomplete sagas\nCREATE INDEX IF NOT EXISTS idx_saga_instances_incomplete\n ON saga_instances (saga_name, is_completed)\n WHERE is_completed = FALSE;\n\n-- Index for cleanup queries (completed + updated_at)\nCREATE INDEX IF NOT EXISTS idx_saga_instances_cleanup\n ON saga_instances (saga_name, is_completed, updated_at)\n WHERE is_completed = TRUE;\n`.trim();\n}\n\n/**\n * Create the saga_instances table and indexes.\n */\nexport async function createSchema(\n pool: Pool,\n options?: { schema?: string; tableName?: string }\n): Promise<void> {\n const schema = options?.schema ?? \"public\";\n const tableName = options?.tableName ?? \"saga_instances\";\n\n // Set search path to the schema\n await pool.query(`SET search_path TO ${schema}`);\n\n // Create table with custom name if specified\n let sql = getSchemaSql();\n if (tableName !== \"saga_instances\") {\n sql = sql.replace(/saga_instances/g, tableName);\n }\n\n await pool.query(sql);\n}\n\n/**\n * Drop the saga_instances table.\n */\nexport async function dropSchema(\n pool: Pool,\n options?: { schema?: string; tableName?: string }\n): Promise<void> {\n const schema = options?.schema ?? \"public\";\n const tableName = options?.tableName ?? \"saga_instances\";\n\n await pool.query(`DROP TABLE IF EXISTS ${schema}.${tableName} CASCADE`);\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Pool, PoolConfig } from 'pg';
|
|
2
|
+
import { SagaState, SagaStore } from '@saga-bus/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for creating a PostgresSagaStore.
|
|
6
|
+
*/
|
|
7
|
+
interface PostgresSagaStoreOptions {
|
|
8
|
+
/**
|
|
9
|
+
* PostgreSQL connection pool or pool configuration.
|
|
10
|
+
*/
|
|
11
|
+
pool: Pool | PoolConfig;
|
|
12
|
+
/**
|
|
13
|
+
* Table name for saga instances. Default: "saga_instances"
|
|
14
|
+
*/
|
|
15
|
+
tableName?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Schema name. Default: "public"
|
|
18
|
+
*/
|
|
19
|
+
schema?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Row structure in the saga_instances table.
|
|
23
|
+
*/
|
|
24
|
+
interface SagaInstanceRow {
|
|
25
|
+
id: string;
|
|
26
|
+
saga_name: string;
|
|
27
|
+
correlation_id: string;
|
|
28
|
+
version: number;
|
|
29
|
+
is_completed: boolean;
|
|
30
|
+
state: unknown;
|
|
31
|
+
created_at: Date;
|
|
32
|
+
updated_at: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PostgreSQL-backed saga store using native pg driver.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const store = new PostgresSagaStore<OrderState>({
|
|
41
|
+
* pool: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Or with pool config
|
|
45
|
+
* const store = new PostgresSagaStore<OrderState>({
|
|
46
|
+
* pool: { connectionString: process.env.DATABASE_URL },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare class PostgresSagaStore<TState extends SagaState> implements SagaStore<TState> {
|
|
51
|
+
private readonly pool;
|
|
52
|
+
private readonly tableName;
|
|
53
|
+
private readonly schema;
|
|
54
|
+
private readonly ownsPool;
|
|
55
|
+
constructor(options: PostgresSagaStoreOptions);
|
|
56
|
+
/**
|
|
57
|
+
* Get the full table name with schema.
|
|
58
|
+
*/
|
|
59
|
+
private get fullTableName();
|
|
60
|
+
getById(sagaName: string, sagaId: string): Promise<TState | null>;
|
|
61
|
+
getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
|
|
62
|
+
insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Insert a saga with explicit correlation ID.
|
|
65
|
+
*/
|
|
66
|
+
insertWithCorrelation(sagaName: string, correlationId: string, state: TState): Promise<void>;
|
|
67
|
+
update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
|
|
68
|
+
delete(sagaName: string, sagaId: string): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Convert a database row to saga state.
|
|
71
|
+
*/
|
|
72
|
+
private rowToState;
|
|
73
|
+
/**
|
|
74
|
+
* Close the connection pool (if owned by this store).
|
|
75
|
+
*/
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Get the underlying pool for advanced operations.
|
|
79
|
+
*/
|
|
80
|
+
getPool(): Pool;
|
|
81
|
+
/**
|
|
82
|
+
* Find sagas by name with pagination.
|
|
83
|
+
*/
|
|
84
|
+
findByName(sagaName: string, options?: {
|
|
85
|
+
limit?: number;
|
|
86
|
+
offset?: number;
|
|
87
|
+
completed?: boolean;
|
|
88
|
+
}): Promise<TState[]>;
|
|
89
|
+
/**
|
|
90
|
+
* Count sagas by name.
|
|
91
|
+
*/
|
|
92
|
+
countByName(sagaName: string, options?: {
|
|
93
|
+
completed?: boolean;
|
|
94
|
+
}): Promise<number>;
|
|
95
|
+
/**
|
|
96
|
+
* Delete completed sagas older than a given date.
|
|
97
|
+
*/
|
|
98
|
+
deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the schema SQL content.
|
|
103
|
+
*/
|
|
104
|
+
declare function getSchemaSql(): string;
|
|
105
|
+
/**
|
|
106
|
+
* Create the saga_instances table and indexes.
|
|
107
|
+
*/
|
|
108
|
+
declare function createSchema(pool: Pool, options?: {
|
|
109
|
+
schema?: string;
|
|
110
|
+
tableName?: string;
|
|
111
|
+
}): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Drop the saga_instances table.
|
|
114
|
+
*/
|
|
115
|
+
declare function dropSchema(pool: Pool, options?: {
|
|
116
|
+
schema?: string;
|
|
117
|
+
tableName?: string;
|
|
118
|
+
}): Promise<void>;
|
|
119
|
+
|
|
120
|
+
export { PostgresSagaStore, type PostgresSagaStoreOptions, type SagaInstanceRow, createSchema, dropSchema, getSchemaSql };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Pool, PoolConfig } from 'pg';
|
|
2
|
+
import { SagaState, SagaStore } from '@saga-bus/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for creating a PostgresSagaStore.
|
|
6
|
+
*/
|
|
7
|
+
interface PostgresSagaStoreOptions {
|
|
8
|
+
/**
|
|
9
|
+
* PostgreSQL connection pool or pool configuration.
|
|
10
|
+
*/
|
|
11
|
+
pool: Pool | PoolConfig;
|
|
12
|
+
/**
|
|
13
|
+
* Table name for saga instances. Default: "saga_instances"
|
|
14
|
+
*/
|
|
15
|
+
tableName?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Schema name. Default: "public"
|
|
18
|
+
*/
|
|
19
|
+
schema?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Row structure in the saga_instances table.
|
|
23
|
+
*/
|
|
24
|
+
interface SagaInstanceRow {
|
|
25
|
+
id: string;
|
|
26
|
+
saga_name: string;
|
|
27
|
+
correlation_id: string;
|
|
28
|
+
version: number;
|
|
29
|
+
is_completed: boolean;
|
|
30
|
+
state: unknown;
|
|
31
|
+
created_at: Date;
|
|
32
|
+
updated_at: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PostgreSQL-backed saga store using native pg driver.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const store = new PostgresSagaStore<OrderState>({
|
|
41
|
+
* pool: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Or with pool config
|
|
45
|
+
* const store = new PostgresSagaStore<OrderState>({
|
|
46
|
+
* pool: { connectionString: process.env.DATABASE_URL },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare class PostgresSagaStore<TState extends SagaState> implements SagaStore<TState> {
|
|
51
|
+
private readonly pool;
|
|
52
|
+
private readonly tableName;
|
|
53
|
+
private readonly schema;
|
|
54
|
+
private readonly ownsPool;
|
|
55
|
+
constructor(options: PostgresSagaStoreOptions);
|
|
56
|
+
/**
|
|
57
|
+
* Get the full table name with schema.
|
|
58
|
+
*/
|
|
59
|
+
private get fullTableName();
|
|
60
|
+
getById(sagaName: string, sagaId: string): Promise<TState | null>;
|
|
61
|
+
getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
|
|
62
|
+
insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Insert a saga with explicit correlation ID.
|
|
65
|
+
*/
|
|
66
|
+
insertWithCorrelation(sagaName: string, correlationId: string, state: TState): Promise<void>;
|
|
67
|
+
update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
|
|
68
|
+
delete(sagaName: string, sagaId: string): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Convert a database row to saga state.
|
|
71
|
+
*/
|
|
72
|
+
private rowToState;
|
|
73
|
+
/**
|
|
74
|
+
* Close the connection pool (if owned by this store).
|
|
75
|
+
*/
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Get the underlying pool for advanced operations.
|
|
79
|
+
*/
|
|
80
|
+
getPool(): Pool;
|
|
81
|
+
/**
|
|
82
|
+
* Find sagas by name with pagination.
|
|
83
|
+
*/
|
|
84
|
+
findByName(sagaName: string, options?: {
|
|
85
|
+
limit?: number;
|
|
86
|
+
offset?: number;
|
|
87
|
+
completed?: boolean;
|
|
88
|
+
}): Promise<TState[]>;
|
|
89
|
+
/**
|
|
90
|
+
* Count sagas by name.
|
|
91
|
+
*/
|
|
92
|
+
countByName(sagaName: string, options?: {
|
|
93
|
+
completed?: boolean;
|
|
94
|
+
}): Promise<number>;
|
|
95
|
+
/**
|
|
96
|
+
* Delete completed sagas older than a given date.
|
|
97
|
+
*/
|
|
98
|
+
deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the schema SQL content.
|
|
103
|
+
*/
|
|
104
|
+
declare function getSchemaSql(): string;
|
|
105
|
+
/**
|
|
106
|
+
* Create the saga_instances table and indexes.
|
|
107
|
+
*/
|
|
108
|
+
declare function createSchema(pool: Pool, options?: {
|
|
109
|
+
schema?: string;
|
|
110
|
+
tableName?: string;
|
|
111
|
+
}): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Drop the saga_instances table.
|
|
114
|
+
*/
|
|
115
|
+
declare function dropSchema(pool: Pool, options?: {
|
|
116
|
+
schema?: string;
|
|
117
|
+
tableName?: string;
|
|
118
|
+
}): Promise<void>;
|
|
119
|
+
|
|
120
|
+
export { PostgresSagaStore, type PostgresSagaStoreOptions, type SagaInstanceRow, createSchema, dropSchema, getSchemaSql };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/PostgresSagaStore.ts
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { ConcurrencyError } from "@saga-bus/core";
|
|
4
|
+
var PostgresSagaStore = class {
|
|
5
|
+
pool;
|
|
6
|
+
tableName;
|
|
7
|
+
schema;
|
|
8
|
+
ownsPool;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
if (options.pool instanceof Pool) {
|
|
11
|
+
this.pool = options.pool;
|
|
12
|
+
this.ownsPool = false;
|
|
13
|
+
} else {
|
|
14
|
+
this.pool = new Pool(options.pool);
|
|
15
|
+
this.ownsPool = true;
|
|
16
|
+
}
|
|
17
|
+
this.tableName = options.tableName ?? "saga_instances";
|
|
18
|
+
this.schema = options.schema ?? "public";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Get the full table name with schema.
|
|
22
|
+
*/
|
|
23
|
+
get fullTableName() {
|
|
24
|
+
return `${this.schema}.${this.tableName}`;
|
|
25
|
+
}
|
|
26
|
+
async getById(sagaName, sagaId) {
|
|
27
|
+
const result = await this.pool.query(
|
|
28
|
+
`SELECT * FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,
|
|
29
|
+
[sagaId, sagaName]
|
|
30
|
+
);
|
|
31
|
+
if (result.rows.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return this.rowToState(result.rows[0]);
|
|
35
|
+
}
|
|
36
|
+
async getByCorrelationId(sagaName, correlationId) {
|
|
37
|
+
const result = await this.pool.query(
|
|
38
|
+
`SELECT * FROM ${this.fullTableName} WHERE saga_name = $1 AND correlation_id = $2`,
|
|
39
|
+
[sagaName, correlationId]
|
|
40
|
+
);
|
|
41
|
+
if (result.rows.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return this.rowToState(result.rows[0]);
|
|
45
|
+
}
|
|
46
|
+
async insert(sagaName, correlationId, state) {
|
|
47
|
+
const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
|
|
48
|
+
await this.pool.query(
|
|
49
|
+
`INSERT INTO ${this.fullTableName}
|
|
50
|
+
(id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
|
|
51
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
52
|
+
[
|
|
53
|
+
sagaId,
|
|
54
|
+
sagaName,
|
|
55
|
+
correlationId,
|
|
56
|
+
version,
|
|
57
|
+
isCompleted,
|
|
58
|
+
JSON.stringify(state),
|
|
59
|
+
createdAt,
|
|
60
|
+
updatedAt
|
|
61
|
+
]
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Insert a saga with explicit correlation ID.
|
|
66
|
+
*/
|
|
67
|
+
async insertWithCorrelation(sagaName, correlationId, state) {
|
|
68
|
+
const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
|
|
69
|
+
await this.pool.query(
|
|
70
|
+
`INSERT INTO ${this.fullTableName}
|
|
71
|
+
(id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)
|
|
72
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
73
|
+
[
|
|
74
|
+
sagaId,
|
|
75
|
+
sagaName,
|
|
76
|
+
correlationId,
|
|
77
|
+
version,
|
|
78
|
+
isCompleted,
|
|
79
|
+
JSON.stringify(state),
|
|
80
|
+
createdAt,
|
|
81
|
+
updatedAt
|
|
82
|
+
]
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
async update(sagaName, state, expectedVersion) {
|
|
86
|
+
const { sagaId, version, isCompleted, updatedAt } = state.metadata;
|
|
87
|
+
const result = await this.pool.query(
|
|
88
|
+
`UPDATE ${this.fullTableName}
|
|
89
|
+
SET version = $1, is_completed = $2, state = $3, updated_at = $4
|
|
90
|
+
WHERE id = $5 AND saga_name = $6 AND version = $7`,
|
|
91
|
+
[
|
|
92
|
+
version,
|
|
93
|
+
isCompleted,
|
|
94
|
+
JSON.stringify(state),
|
|
95
|
+
updatedAt,
|
|
96
|
+
sagaId,
|
|
97
|
+
sagaName,
|
|
98
|
+
expectedVersion
|
|
99
|
+
]
|
|
100
|
+
);
|
|
101
|
+
if (result.rowCount === 0) {
|
|
102
|
+
const existing = await this.getById(sagaName, sagaId);
|
|
103
|
+
if (existing) {
|
|
104
|
+
throw new ConcurrencyError(
|
|
105
|
+
sagaId,
|
|
106
|
+
expectedVersion,
|
|
107
|
+
existing.metadata.version
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
throw new Error(`Saga ${sagaId} not found`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async delete(sagaName, sagaId) {
|
|
115
|
+
await this.pool.query(
|
|
116
|
+
`DELETE FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,
|
|
117
|
+
[sagaId, sagaName]
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Convert a database row to saga state.
|
|
122
|
+
*/
|
|
123
|
+
rowToState(row) {
|
|
124
|
+
const state = row.state;
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
metadata: {
|
|
128
|
+
...state.metadata,
|
|
129
|
+
sagaId: row.id,
|
|
130
|
+
version: row.version,
|
|
131
|
+
isCompleted: row.is_completed,
|
|
132
|
+
createdAt: new Date(row.created_at),
|
|
133
|
+
updatedAt: new Date(row.updated_at)
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Close the connection pool (if owned by this store).
|
|
139
|
+
*/
|
|
140
|
+
async close() {
|
|
141
|
+
if (this.ownsPool) {
|
|
142
|
+
await this.pool.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Get the underlying pool for advanced operations.
|
|
147
|
+
*/
|
|
148
|
+
getPool() {
|
|
149
|
+
return this.pool;
|
|
150
|
+
}
|
|
151
|
+
// ============ Query Helpers ============
|
|
152
|
+
/**
|
|
153
|
+
* Find sagas by name with pagination.
|
|
154
|
+
*/
|
|
155
|
+
async findByName(sagaName, options) {
|
|
156
|
+
let query = `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1`;
|
|
157
|
+
const params = [sagaName];
|
|
158
|
+
if (options?.completed !== void 0) {
|
|
159
|
+
query += ` AND is_completed = $${params.length + 1}`;
|
|
160
|
+
params.push(options.completed);
|
|
161
|
+
}
|
|
162
|
+
query += ` ORDER BY created_at DESC`;
|
|
163
|
+
if (options?.limit) {
|
|
164
|
+
query += ` LIMIT $${params.length + 1}`;
|
|
165
|
+
params.push(options.limit);
|
|
166
|
+
}
|
|
167
|
+
if (options?.offset) {
|
|
168
|
+
query += ` OFFSET $${params.length + 1}`;
|
|
169
|
+
params.push(options.offset);
|
|
170
|
+
}
|
|
171
|
+
const result = await this.pool.query(query, params);
|
|
172
|
+
return result.rows.map((row) => this.rowToState(row));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Count sagas by name.
|
|
176
|
+
*/
|
|
177
|
+
async countByName(sagaName, options) {
|
|
178
|
+
let query = `SELECT COUNT(*) FROM ${this.fullTableName} WHERE saga_name = $1`;
|
|
179
|
+
const params = [sagaName];
|
|
180
|
+
if (options?.completed !== void 0) {
|
|
181
|
+
query += ` AND is_completed = $${params.length + 1}`;
|
|
182
|
+
params.push(options.completed);
|
|
183
|
+
}
|
|
184
|
+
const result = await this.pool.query(query, params);
|
|
185
|
+
return parseInt(result.rows[0]?.count ?? "0", 10);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Delete completed sagas older than a given date.
|
|
189
|
+
*/
|
|
190
|
+
async deleteCompletedBefore(sagaName, before) {
|
|
191
|
+
const result = await this.pool.query(
|
|
192
|
+
`DELETE FROM ${this.fullTableName}
|
|
193
|
+
WHERE saga_name = $1 AND is_completed = true AND updated_at < $2`,
|
|
194
|
+
[sagaName, before]
|
|
195
|
+
);
|
|
196
|
+
return result.rowCount ?? 0;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/schema.ts
|
|
201
|
+
function getSchemaSql() {
|
|
202
|
+
return `
|
|
203
|
+
-- Saga instances table
|
|
204
|
+
CREATE TABLE IF NOT EXISTS saga_instances (
|
|
205
|
+
id VARCHAR(128) NOT NULL,
|
|
206
|
+
saga_name VARCHAR(128) NOT NULL,
|
|
207
|
+
correlation_id VARCHAR(256) NOT NULL,
|
|
208
|
+
version INTEGER NOT NULL,
|
|
209
|
+
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
210
|
+
state JSONB NOT NULL,
|
|
211
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
212
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
213
|
+
PRIMARY KEY (saga_name, id)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
-- Index for looking up sagas by name
|
|
217
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_saga_name
|
|
218
|
+
ON saga_instances (saga_name);
|
|
219
|
+
|
|
220
|
+
-- Index for correlation ID lookups (saga_name + correlation_id)
|
|
221
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_saga_instances_correlation
|
|
222
|
+
ON saga_instances (saga_name, correlation_id);
|
|
223
|
+
|
|
224
|
+
-- Index for finding incomplete sagas
|
|
225
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_incomplete
|
|
226
|
+
ON saga_instances (saga_name, is_completed)
|
|
227
|
+
WHERE is_completed = FALSE;
|
|
228
|
+
|
|
229
|
+
-- Index for cleanup queries (completed + updated_at)
|
|
230
|
+
CREATE INDEX IF NOT EXISTS idx_saga_instances_cleanup
|
|
231
|
+
ON saga_instances (saga_name, is_completed, updated_at)
|
|
232
|
+
WHERE is_completed = TRUE;
|
|
233
|
+
`.trim();
|
|
234
|
+
}
|
|
235
|
+
async function createSchema(pool, options) {
|
|
236
|
+
const schema = options?.schema ?? "public";
|
|
237
|
+
const tableName = options?.tableName ?? "saga_instances";
|
|
238
|
+
await pool.query(`SET search_path TO ${schema}`);
|
|
239
|
+
let sql = getSchemaSql();
|
|
240
|
+
if (tableName !== "saga_instances") {
|
|
241
|
+
sql = sql.replace(/saga_instances/g, tableName);
|
|
242
|
+
}
|
|
243
|
+
await pool.query(sql);
|
|
244
|
+
}
|
|
245
|
+
async function dropSchema(pool, options) {
|
|
246
|
+
const schema = options?.schema ?? "public";
|
|
247
|
+
const tableName = options?.tableName ?? "saga_instances";
|
|
248
|
+
await pool.query(`DROP TABLE IF EXISTS ${schema}.${tableName} CASCADE`);
|
|
249
|
+
}
|
|
250
|
+
export {
|
|
251
|
+
PostgresSagaStore,
|
|
252
|
+
createSchema,
|
|
253
|
+
dropSchema,
|
|
254
|
+
getSchemaSql
|
|
255
|
+
};
|
|
256
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/PostgresSagaStore.ts","../src/schema.ts"],"sourcesContent":["import { Pool } from \"pg\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { PostgresSagaStoreOptions, SagaInstanceRow } from \"./types.js\";\n\n/**\n * PostgreSQL-backed saga store using native pg driver.\n *\n * @example\n * ```typescript\n * const store = new PostgresSagaStore<OrderState>({\n * pool: new Pool({ connectionString: process.env.DATABASE_URL }),\n * });\n *\n * // Or with pool config\n * const store = new PostgresSagaStore<OrderState>({\n * pool: { connectionString: process.env.DATABASE_URL },\n * });\n * ```\n */\nexport class PostgresSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private readonly pool: Pool;\n private readonly tableName: string;\n private readonly schema: string;\n private readonly ownsPool: boolean;\n\n constructor(options: PostgresSagaStoreOptions) {\n if (options.pool instanceof Pool) {\n this.pool = options.pool;\n this.ownsPool = false;\n } else {\n this.pool = new Pool(options.pool);\n this.ownsPool = true;\n }\n\n this.tableName = options.tableName ?? \"saga_instances\";\n this.schema = options.schema ?? \"public\";\n }\n\n /**\n * Get the full table name with schema.\n */\n private get fullTableName(): string {\n return `${this.schema}.${this.tableName}`;\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n const result = await this.pool.query<SagaInstanceRow>(\n `SELECT * FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,\n [sagaId, sagaName]\n );\n\n if (result.rows.length === 0) {\n return null;\n }\n\n return this.rowToState(result.rows[0]!);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n const result = await this.pool.query<SagaInstanceRow>(\n `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1 AND correlation_id = $2`,\n [sagaName, correlationId]\n );\n\n if (result.rows.length === 0) {\n return null;\n }\n\n return this.rowToState(result.rows[0]!);\n }\n\n async insert(sagaName: string, correlationId: string, state: TState): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;\n\n await this.pool.query(\n `INSERT INTO ${this.fullTableName}\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted,\n JSON.stringify(state),\n createdAt,\n updatedAt,\n ]\n );\n }\n\n /**\n * Insert a saga with explicit correlation ID.\n */\n async insertWithCorrelation(\n sagaName: string,\n correlationId: string,\n state: TState\n ): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;\n\n await this.pool.query(\n `INSERT INTO ${this.fullTableName}\n (id, saga_name, correlation_id, version, is_completed, state, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,\n [\n sagaId,\n sagaName,\n correlationId,\n version,\n isCompleted,\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 const { sagaId, version, isCompleted, updatedAt } = state.metadata;\n\n const result = await this.pool.query(\n `UPDATE ${this.fullTableName}\n SET version = $1, is_completed = $2, state = $3, updated_at = $4\n WHERE id = $5 AND saga_name = $6 AND version = $7`,\n [\n version,\n isCompleted,\n JSON.stringify(state),\n updatedAt,\n sagaId,\n sagaName,\n expectedVersion,\n ]\n );\n\n if (result.rowCount === 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 await this.pool.query(\n `DELETE FROM ${this.fullTableName} WHERE id = $1 AND saga_name = $2`,\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 = 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,\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) {\n await this.pool.end();\n }\n }\n\n /**\n * Get the underlying pool for advanced operations.\n */\n getPool(): Pool {\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 let query = `SELECT * FROM ${this.fullTableName} WHERE saga_name = $1`;\n const params: unknown[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = $${params.length + 1}`;\n params.push(options.completed);\n }\n\n query += ` ORDER BY created_at DESC`;\n\n if (options?.limit) {\n query += ` LIMIT $${params.length + 1}`;\n params.push(options.limit);\n }\n\n if (options?.offset) {\n query += ` OFFSET $${params.length + 1}`;\n params.push(options.offset);\n }\n\n const result = await this.pool.query<SagaInstanceRow>(query, params);\n return result.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 let query = `SELECT COUNT(*) FROM ${this.fullTableName} WHERE saga_name = $1`;\n const params: unknown[] = [sagaName];\n\n if (options?.completed !== undefined) {\n query += ` AND is_completed = $${params.length + 1}`;\n params.push(options.completed);\n }\n\n const result = await this.pool.query<{ count: string }>(query, params);\n return parseInt(result.rows[0]?.count ?? \"0\", 10);\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 const result = await this.pool.query(\n `DELETE FROM ${this.fullTableName}\n WHERE saga_name = $1 AND is_completed = true AND updated_at < $2`,\n [sagaName, before]\n );\n\n return result.rowCount ?? 0;\n }\n}\n","import type { Pool } from \"pg\";\n\n/**\n * Get the schema SQL content.\n */\nexport function getSchemaSql(): string {\n return `\n-- Saga instances table\nCREATE TABLE IF NOT EXISTS saga_instances (\n id VARCHAR(128) NOT NULL,\n saga_name VARCHAR(128) NOT NULL,\n correlation_id VARCHAR(256) NOT NULL,\n version INTEGER NOT NULL,\n is_completed BOOLEAN NOT NULL DEFAULT FALSE,\n state JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (saga_name, id)\n);\n\n-- Index for looking up sagas by name\nCREATE INDEX IF NOT EXISTS idx_saga_instances_saga_name\n ON saga_instances (saga_name);\n\n-- Index for correlation ID lookups (saga_name + correlation_id)\nCREATE UNIQUE INDEX IF NOT EXISTS idx_saga_instances_correlation\n ON saga_instances (saga_name, correlation_id);\n\n-- Index for finding incomplete sagas\nCREATE INDEX IF NOT EXISTS idx_saga_instances_incomplete\n ON saga_instances (saga_name, is_completed)\n WHERE is_completed = FALSE;\n\n-- Index for cleanup queries (completed + updated_at)\nCREATE INDEX IF NOT EXISTS idx_saga_instances_cleanup\n ON saga_instances (saga_name, is_completed, updated_at)\n WHERE is_completed = TRUE;\n`.trim();\n}\n\n/**\n * Create the saga_instances table and indexes.\n */\nexport async function createSchema(\n pool: Pool,\n options?: { schema?: string; tableName?: string }\n): Promise<void> {\n const schema = options?.schema ?? \"public\";\n const tableName = options?.tableName ?? \"saga_instances\";\n\n // Set search path to the schema\n await pool.query(`SET search_path TO ${schema}`);\n\n // Create table with custom name if specified\n let sql = getSchemaSql();\n if (tableName !== \"saga_instances\") {\n sql = sql.replace(/saga_instances/g, tableName);\n }\n\n await pool.query(sql);\n}\n\n/**\n * Drop the saga_instances table.\n */\nexport async function dropSchema(\n pool: Pool,\n options?: { schema?: string; tableName?: string }\n): Promise<void> {\n const schema = options?.schema ?? \"public\";\n const tableName = options?.tableName ?? \"saga_instances\";\n\n await pool.query(`DROP TABLE IF EXISTS ${schema}.${tableName} CASCADE`);\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saga-bus/store-postgres",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PostgreSQL saga store for saga-bus using native pg driver",
|
|
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-postgres"
|
|
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
|
+
"postgres",
|
|
37
|
+
"postgresql"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"pg": "^8.11.0",
|
|
41
|
+
"@saga-bus/core": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@testcontainers/postgresql": "^10.0.0",
|
|
45
|
+
"@types/pg": "^8.11.0",
|
|
46
|
+
"tsup": "^8.0.0",
|
|
47
|
+
"typescript": "^5.9.2",
|
|
48
|
+
"vitest": "^3.0.0",
|
|
49
|
+
"@repo/eslint-config": "0.0.0",
|
|
50
|
+
"@repo/typescript-config": "0.0.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@saga-bus/core": ">=0.1.0",
|
|
54
|
+
"pg": ">=8.0.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"dev": "tsup --watch",
|
|
59
|
+
"lint": "eslint src/",
|
|
60
|
+
"check-types": "tsc --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest"
|
|
63
|
+
}
|
|
64
|
+
}
|