@saga-bus/store-dynamodb 0.1.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,102 @@
1
+ # @saga-bus/store-dynamodb
2
+
3
+ AWS DynamoDB saga store for saga-bus.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @saga-bus/store-dynamodb @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
15
+ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
16
+ import { DynamoDBSagaStore, createTable } from "@saga-bus/store-dynamodb";
17
+ import { createBus } from "@saga-bus/core";
18
+
19
+ const dynamoClient = new DynamoDBClient({ region: "us-east-1" });
20
+ const docClient = DynamoDBDocumentClient.from(dynamoClient);
21
+
22
+ // Create table (run once, or use CloudFormation/CDK)
23
+ await createTable(dynamoClient, { tableName: "saga-instances" });
24
+
25
+ const store = new DynamoDBSagaStore({
26
+ client: docClient,
27
+ tableName: "saga-instances",
28
+ });
29
+
30
+ const bus = createBus({
31
+ sagas: [{ definition: mySaga, store }],
32
+ transport,
33
+ });
34
+ ```
35
+
36
+ ## Table Schema
37
+
38
+ The store uses a single-table design:
39
+
40
+ | Key | Pattern | Description |
41
+ |-----|---------|-------------|
42
+ | `PK` | `SAGA#<sagaName>` | Partition key |
43
+ | `SK` | `ID#<sagaId>` | Sort key |
44
+ | `GSI1PK` | `SAGA#<sagaName>` | Correlation index PK |
45
+ | `GSI1SK` | `CORR#<correlationId>` | Correlation index SK |
46
+ | `GSI2PK` | `COMPLETED#<date>` | Cleanup index PK |
47
+ | `GSI2SK` | `<sagaName>#<sagaId>` | Cleanup index SK |
48
+
49
+ ## Features
50
+
51
+ - Single-table design for cost efficiency
52
+ - Optimistic concurrency via conditional writes
53
+ - GSI for correlation ID lookups
54
+ - GSI for cleanup queries by completion date
55
+ - Automatic GSI index management
56
+
57
+ ## Configuration
58
+
59
+ | Option | Type | Default | Description |
60
+ |--------|------|---------|-------------|
61
+ | `client` | `DynamoDBDocumentClient` | required | DynamoDB Document Client |
62
+ | `tableName` | `string` | required | Table name |
63
+ | `correlationIndexName` | `string` | `"GSI1"` | Correlation GSI name |
64
+ | `cleanupIndexName` | `string` | `"GSI2"` | Cleanup GSI name |
65
+
66
+ ## Sharing Across Sagas
67
+
68
+ A single store instance can be shared across multiple sagas:
69
+
70
+ ```typescript
71
+ const store = new DynamoDBSagaStore({ client, tableName: "saga-instances" });
72
+
73
+ const bus = createBus({
74
+ transport,
75
+ store, // shared by all sagas
76
+ sagas: [
77
+ { definition: orderSaga },
78
+ { definition: paymentSaga },
79
+ ],
80
+ });
81
+ ```
82
+
83
+ Data is isolated by `sagaName` in the partition key, so different saga types won't conflict.
84
+
85
+ ## Table Management
86
+
87
+ ```typescript
88
+ import { createTable, deleteTable, getTableSchema } from "@saga-bus/store-dynamodb";
89
+
90
+ // Create with defaults
91
+ await createTable(dynamoClient, { tableName: "saga-instances" });
92
+
93
+ // Get schema for CloudFormation/CDK
94
+ const schema = getTableSchema("saga-instances");
95
+
96
+ // Delete table
97
+ await deleteTable(dynamoClient, "saga-instances");
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,333 @@
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
+ DynamoDBSagaStore: () => DynamoDBSagaStore,
24
+ createTable: () => createTable,
25
+ deleteTable: () => deleteTable,
26
+ getTableSchema: () => getTableSchema
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/DynamoDBSagaStore.ts
31
+ var import_lib_dynamodb = require("@aws-sdk/lib-dynamodb");
32
+ var import_core = require("@saga-bus/core");
33
+ var DynamoDBSagaStore = class {
34
+ client;
35
+ tableName;
36
+ correlationIndexName;
37
+ cleanupIndexName;
38
+ constructor(options) {
39
+ this.client = options.client;
40
+ this.tableName = options.tableName;
41
+ this.correlationIndexName = options.correlationIndexName ?? "GSI1";
42
+ this.cleanupIndexName = options.cleanupIndexName ?? "GSI2";
43
+ }
44
+ async getById(sagaName, sagaId) {
45
+ const result = await this.client.send(
46
+ new import_lib_dynamodb.GetCommand({
47
+ TableName: this.tableName,
48
+ Key: {
49
+ PK: sagaName,
50
+ SK: sagaId
51
+ }
52
+ })
53
+ );
54
+ if (!result.Item) {
55
+ return null;
56
+ }
57
+ return this.itemToState(result.Item);
58
+ }
59
+ async getByCorrelationId(sagaName, correlationId) {
60
+ const result = await this.client.send(
61
+ new import_lib_dynamodb.QueryCommand({
62
+ TableName: this.tableName,
63
+ IndexName: this.correlationIndexName,
64
+ KeyConditionExpression: "GSI1PK = :pk AND GSI1SK = :sk",
65
+ ExpressionAttributeValues: {
66
+ ":pk": sagaName,
67
+ ":sk": correlationId
68
+ },
69
+ Limit: 1
70
+ })
71
+ );
72
+ if (!result.Items || result.Items.length === 0) {
73
+ return null;
74
+ }
75
+ return this.itemToState(result.Items[0]);
76
+ }
77
+ async insert(sagaName, correlationId, state) {
78
+ const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
79
+ const serializedState = this.serializeState(state);
80
+ const item = {
81
+ PK: sagaName,
82
+ SK: sagaId,
83
+ sagaName,
84
+ sagaId,
85
+ correlationId,
86
+ version,
87
+ isCompleted,
88
+ state: serializedState,
89
+ createdAt: createdAt.toISOString(),
90
+ updatedAt: updatedAt.toISOString(),
91
+ GSI1PK: sagaName,
92
+ GSI1SK: correlationId
93
+ };
94
+ if (isCompleted) {
95
+ item.GSI2PK = sagaName;
96
+ item.GSI2SK = updatedAt.toISOString();
97
+ }
98
+ await this.client.send(
99
+ new import_lib_dynamodb.PutCommand({
100
+ TableName: this.tableName,
101
+ Item: item,
102
+ ConditionExpression: "attribute_not_exists(PK)"
103
+ })
104
+ );
105
+ }
106
+ async update(sagaName, state, expectedVersion) {
107
+ const { sagaId, version, isCompleted, updatedAt } = state.metadata;
108
+ try {
109
+ const updateExpression = isCompleted ? "SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt, GSI2PK = :gsi2pk, GSI2SK = :gsi2sk" : "SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt REMOVE GSI2PK, GSI2SK";
110
+ const serializedState = this.serializeState(state);
111
+ const expressionValues = {
112
+ ":version": version,
113
+ ":isCompleted": isCompleted,
114
+ ":state": serializedState,
115
+ ":updatedAt": updatedAt.toISOString(),
116
+ ":expectedVersion": expectedVersion
117
+ };
118
+ if (isCompleted) {
119
+ expressionValues[":gsi2pk"] = sagaName;
120
+ expressionValues[":gsi2sk"] = updatedAt.toISOString();
121
+ }
122
+ await this.client.send(
123
+ new import_lib_dynamodb.UpdateCommand({
124
+ TableName: this.tableName,
125
+ Key: {
126
+ PK: sagaName,
127
+ SK: sagaId
128
+ },
129
+ UpdateExpression: updateExpression,
130
+ ConditionExpression: "version = :expectedVersion",
131
+ ExpressionAttributeNames: {
132
+ "#state": "state"
133
+ },
134
+ ExpressionAttributeValues: expressionValues
135
+ })
136
+ );
137
+ } catch (error) {
138
+ if (error instanceof Error && error.name === "ConditionalCheckFailedException") {
139
+ const existing = await this.getById(sagaName, sagaId);
140
+ if (existing) {
141
+ throw new import_core.ConcurrencyError(
142
+ sagaId,
143
+ expectedVersion,
144
+ existing.metadata.version
145
+ );
146
+ } else {
147
+ throw new Error(`Saga ${sagaId} not found`);
148
+ }
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+ async delete(sagaName, sagaId) {
154
+ await this.client.send(
155
+ new import_lib_dynamodb.DeleteCommand({
156
+ TableName: this.tableName,
157
+ Key: {
158
+ PK: sagaName,
159
+ SK: sagaId
160
+ }
161
+ })
162
+ );
163
+ }
164
+ itemToState(item) {
165
+ const state = item.state;
166
+ return {
167
+ ...state,
168
+ metadata: {
169
+ ...state.metadata,
170
+ sagaId: item.sagaId,
171
+ version: item.version,
172
+ isCompleted: item.isCompleted,
173
+ createdAt: new Date(item.createdAt),
174
+ updatedAt: new Date(item.updatedAt)
175
+ }
176
+ };
177
+ }
178
+ /**
179
+ * Serialize state for DynamoDB storage by converting Date objects to ISO strings.
180
+ */
181
+ serializeState(state) {
182
+ return JSON.parse(JSON.stringify(state, (_, value) => {
183
+ if (value instanceof Date) {
184
+ return value.toISOString();
185
+ }
186
+ return value;
187
+ }));
188
+ }
189
+ // ============ Query Helpers ============
190
+ /**
191
+ * Find sagas by name with pagination.
192
+ */
193
+ async findByName(sagaName, options) {
194
+ let filterExpression;
195
+ const expressionValues = { ":pk": sagaName };
196
+ if (options?.completed !== void 0) {
197
+ filterExpression = "isCompleted = :isCompleted";
198
+ expressionValues[":isCompleted"] = options.completed;
199
+ }
200
+ const result = await this.client.send(
201
+ new import_lib_dynamodb.QueryCommand({
202
+ TableName: this.tableName,
203
+ KeyConditionExpression: "PK = :pk",
204
+ FilterExpression: filterExpression,
205
+ ExpressionAttributeValues: expressionValues,
206
+ Limit: options?.limit ?? 100,
207
+ ExclusiveStartKey: options?.startKey,
208
+ ScanIndexForward: false
209
+ })
210
+ );
211
+ const items = (result.Items ?? []).map(
212
+ (item) => this.itemToState(item)
213
+ );
214
+ return {
215
+ items,
216
+ lastKey: result.LastEvaluatedKey
217
+ };
218
+ }
219
+ /**
220
+ * Delete completed sagas older than a given date.
221
+ * Note: This performs a query + batch delete which may require pagination.
222
+ */
223
+ async deleteCompletedBefore(sagaName, before) {
224
+ let deletedCount = 0;
225
+ let lastKey;
226
+ do {
227
+ const result = await this.client.send(
228
+ new import_lib_dynamodb.QueryCommand({
229
+ TableName: this.tableName,
230
+ IndexName: this.cleanupIndexName,
231
+ KeyConditionExpression: "GSI2PK = :pk AND GSI2SK < :before",
232
+ ExpressionAttributeValues: {
233
+ ":pk": sagaName,
234
+ ":before": before.toISOString()
235
+ },
236
+ Limit: 25,
237
+ ExclusiveStartKey: lastKey
238
+ })
239
+ );
240
+ if (result.Items && result.Items.length > 0) {
241
+ for (const item of result.Items) {
242
+ await this.delete(sagaName, item.SK);
243
+ deletedCount++;
244
+ }
245
+ }
246
+ lastKey = result.LastEvaluatedKey;
247
+ } while (lastKey);
248
+ return deletedCount;
249
+ }
250
+ };
251
+
252
+ // src/schema.ts
253
+ var import_client_dynamodb = require("@aws-sdk/client-dynamodb");
254
+ function getTableSchema(tableName, options) {
255
+ return {
256
+ tableName,
257
+ correlationIndexName: options?.correlationIndexName ?? "GSI1",
258
+ cleanupIndexName: options?.cleanupIndexName ?? "GSI2"
259
+ };
260
+ }
261
+ async function createTable(client, schema) {
262
+ const command = new import_client_dynamodb.CreateTableCommand({
263
+ TableName: schema.tableName,
264
+ KeySchema: [
265
+ { AttributeName: "PK", KeyType: "HASH" },
266
+ { AttributeName: "SK", KeyType: "RANGE" }
267
+ ],
268
+ AttributeDefinitions: [
269
+ { AttributeName: "PK", AttributeType: "S" },
270
+ { AttributeName: "SK", AttributeType: "S" },
271
+ { AttributeName: "GSI1PK", AttributeType: "S" },
272
+ { AttributeName: "GSI1SK", AttributeType: "S" },
273
+ { AttributeName: "GSI2PK", AttributeType: "S" },
274
+ { AttributeName: "GSI2SK", AttributeType: "S" }
275
+ ],
276
+ GlobalSecondaryIndexes: [
277
+ {
278
+ IndexName: schema.correlationIndexName,
279
+ KeySchema: [
280
+ { AttributeName: "GSI1PK", KeyType: "HASH" },
281
+ { AttributeName: "GSI1SK", KeyType: "RANGE" }
282
+ ],
283
+ Projection: { ProjectionType: "ALL" },
284
+ ProvisionedThroughput: {
285
+ ReadCapacityUnits: 5,
286
+ WriteCapacityUnits: 5
287
+ }
288
+ },
289
+ {
290
+ IndexName: schema.cleanupIndexName,
291
+ KeySchema: [
292
+ { AttributeName: "GSI2PK", KeyType: "HASH" },
293
+ { AttributeName: "GSI2SK", KeyType: "RANGE" }
294
+ ],
295
+ Projection: { ProjectionType: "KEYS_ONLY" },
296
+ ProvisionedThroughput: {
297
+ ReadCapacityUnits: 5,
298
+ WriteCapacityUnits: 5
299
+ }
300
+ }
301
+ ],
302
+ BillingMode: "PROVISIONED",
303
+ ProvisionedThroughput: {
304
+ ReadCapacityUnits: 5,
305
+ WriteCapacityUnits: 5
306
+ }
307
+ });
308
+ await client.send(command);
309
+ await waitForTableActive(client, schema.tableName);
310
+ }
311
+ async function deleteTable(client, tableName) {
312
+ await client.send(new import_client_dynamodb.DeleteTableCommand({ TableName: tableName }));
313
+ }
314
+ async function waitForTableActive(client, tableName) {
315
+ for (let i = 0; i < 30; i++) {
316
+ const response = await client.send(
317
+ new import_client_dynamodb.DescribeTableCommand({ TableName: tableName })
318
+ );
319
+ if (response.Table?.TableStatus === "ACTIVE") {
320
+ return;
321
+ }
322
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
323
+ }
324
+ throw new Error(`Table ${tableName} did not become active`);
325
+ }
326
+ // Annotate the CommonJS export names for ESM import in node:
327
+ 0 && (module.exports = {
328
+ DynamoDBSagaStore,
329
+ createTable,
330
+ deleteTable,
331
+ getTableSchema
332
+ });
333
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/DynamoDBSagaStore.ts","../src/schema.ts"],"sourcesContent":["export { DynamoDBSagaStore } from \"./DynamoDBSagaStore.js\";\nexport { createTable, deleteTable, getTableSchema } from \"./schema.js\";\nexport type {\n DynamoDBSagaStoreOptions,\n SagaInstanceItem,\n TableSchema,\n} from \"./types.js\";\n","import {\n GetCommand,\n PutCommand,\n UpdateCommand,\n DeleteCommand,\n QueryCommand,\n} from \"@aws-sdk/lib-dynamodb\";\nimport type { DynamoDBDocumentClient } from \"@aws-sdk/lib-dynamodb\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { DynamoDBSagaStoreOptions, SagaInstanceItem } from \"./types.js\";\n\n/**\n * DynamoDB-backed saga store.\n *\n * @example\n * ```typescript\n * import { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\n * import { DynamoDBDocumentClient } from \"@aws-sdk/lib-dynamodb\";\n *\n * const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n * const store = new DynamoDBSagaStore<OrderState>({\n * client,\n * tableName: \"saga_instances\",\n * });\n * ```\n */\nexport class DynamoDBSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private readonly client: DynamoDBDocumentClient;\n private readonly tableName: string;\n private readonly correlationIndexName: string;\n private readonly cleanupIndexName: string;\n\n constructor(options: DynamoDBSagaStoreOptions) {\n this.client = options.client;\n this.tableName = options.tableName;\n this.correlationIndexName = options.correlationIndexName ?? \"GSI1\";\n this.cleanupIndexName = options.cleanupIndexName ?? \"GSI2\";\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n const result = await this.client.send(\n new GetCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n })\n );\n\n if (!result.Item) {\n return null;\n }\n\n return this.itemToState(result.Item as SagaInstanceItem);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n IndexName: this.correlationIndexName,\n KeyConditionExpression: \"GSI1PK = :pk AND GSI1SK = :sk\",\n ExpressionAttributeValues: {\n \":pk\": sagaName,\n \":sk\": correlationId,\n },\n Limit: 1,\n })\n );\n\n if (!result.Items || result.Items.length === 0) {\n return null;\n }\n\n return this.itemToState(result.Items[0] as SagaInstanceItem);\n }\n\n async insert(sagaName: string, correlationId: string, state: TState): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } =\n state.metadata;\n\n // Serialize state with dates converted to ISO strings for DynamoDB compatibility\n const serializedState = this.serializeState(state);\n\n const item: SagaInstanceItem = {\n PK: sagaName,\n SK: sagaId,\n sagaName,\n sagaId,\n correlationId,\n version,\n isCompleted,\n state: serializedState,\n createdAt: createdAt.toISOString(),\n updatedAt: updatedAt.toISOString(),\n GSI1PK: sagaName,\n GSI1SK: correlationId,\n };\n\n // Only add GSI2 keys for completed sagas (for cleanup queries)\n if (isCompleted) {\n item.GSI2PK = sagaName;\n item.GSI2SK = updatedAt.toISOString();\n }\n\n await this.client.send(\n new PutCommand({\n TableName: this.tableName,\n Item: item,\n ConditionExpression: \"attribute_not_exists(PK)\",\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 try {\n const updateExpression = isCompleted\n ? \"SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt, GSI2PK = :gsi2pk, GSI2SK = :gsi2sk\"\n : \"SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt REMOVE GSI2PK, GSI2SK\";\n\n // Serialize state with dates converted to ISO strings for DynamoDB compatibility\n const serializedState = this.serializeState(state);\n\n const expressionValues: Record<string, unknown> = {\n \":version\": version,\n \":isCompleted\": isCompleted,\n \":state\": serializedState,\n \":updatedAt\": updatedAt.toISOString(),\n \":expectedVersion\": expectedVersion,\n };\n\n if (isCompleted) {\n expressionValues[\":gsi2pk\"] = sagaName;\n expressionValues[\":gsi2sk\"] = updatedAt.toISOString();\n }\n\n await this.client.send(\n new UpdateCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n UpdateExpression: updateExpression,\n ConditionExpression: \"version = :expectedVersion\",\n ExpressionAttributeNames: {\n \"#state\": \"state\",\n },\n ExpressionAttributeValues: expressionValues,\n })\n );\n } catch (error) {\n if (\n error instanceof Error &&\n error.name === \"ConditionalCheckFailedException\"\n ) {\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 throw error;\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n await this.client.send(\n new DeleteCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n })\n );\n }\n\n private itemToState(item: SagaInstanceItem): TState {\n const state = item.state as TState;\n\n return {\n ...state,\n metadata: {\n ...state.metadata,\n sagaId: item.sagaId,\n version: item.version,\n isCompleted: item.isCompleted,\n createdAt: new Date(item.createdAt),\n updatedAt: new Date(item.updatedAt),\n },\n };\n }\n\n /**\n * Serialize state for DynamoDB storage by converting Date objects to ISO strings.\n */\n private serializeState(state: TState): Record<string, unknown> {\n return JSON.parse(JSON.stringify(state, (_, value) => {\n if (value instanceof Date) {\n return value.toISOString();\n }\n return value;\n }));\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 startKey?: Record<string, unknown>;\n completed?: boolean;\n }\n ): Promise<{ items: TState[]; lastKey?: Record<string, unknown> }> {\n let filterExpression: string | undefined;\n const expressionValues: Record<string, unknown> = { \":pk\": sagaName };\n\n if (options?.completed !== undefined) {\n filterExpression = \"isCompleted = :isCompleted\";\n expressionValues[\":isCompleted\"] = options.completed;\n }\n\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n KeyConditionExpression: \"PK = :pk\",\n FilterExpression: filterExpression,\n ExpressionAttributeValues: expressionValues,\n Limit: options?.limit ?? 100,\n ExclusiveStartKey: options?.startKey,\n ScanIndexForward: false,\n })\n );\n\n const items = (result.Items ?? []).map((item) =>\n this.itemToState(item as SagaInstanceItem)\n );\n\n return {\n items,\n lastKey: result.LastEvaluatedKey,\n };\n }\n\n /**\n * Delete completed sagas older than a given date.\n * Note: This performs a query + batch delete which may require pagination.\n */\n async deleteCompletedBefore(\n sagaName: string,\n before: Date\n ): Promise<number> {\n let deletedCount = 0;\n let lastKey: Record<string, unknown> | undefined;\n\n do {\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n IndexName: this.cleanupIndexName,\n KeyConditionExpression: \"GSI2PK = :pk AND GSI2SK < :before\",\n ExpressionAttributeValues: {\n \":pk\": sagaName,\n \":before\": before.toISOString(),\n },\n Limit: 25,\n ExclusiveStartKey: lastKey,\n })\n );\n\n if (result.Items && result.Items.length > 0) {\n for (const item of result.Items) {\n await this.delete(sagaName, item.SK as string);\n deletedCount++;\n }\n }\n\n lastKey = result.LastEvaluatedKey;\n } while (lastKey);\n\n return deletedCount;\n }\n}\n","import {\n CreateTableCommand,\n DeleteTableCommand,\n DescribeTableCommand,\n type DynamoDBClient,\n} from \"@aws-sdk/client-dynamodb\";\nimport type { TableSchema } from \"./types.js\";\n\n/**\n * Get the table schema definition.\n */\nexport function getTableSchema(\n tableName: string,\n options?: {\n correlationIndexName?: string;\n cleanupIndexName?: string;\n }\n): TableSchema {\n return {\n tableName,\n correlationIndexName: options?.correlationIndexName ?? \"GSI1\",\n cleanupIndexName: options?.cleanupIndexName ?? \"GSI2\",\n };\n}\n\n/**\n * Create the saga instances table with required indexes.\n */\nexport async function createTable(\n client: DynamoDBClient,\n schema: TableSchema\n): Promise<void> {\n const command = new CreateTableCommand({\n TableName: schema.tableName,\n KeySchema: [\n { AttributeName: \"PK\", KeyType: \"HASH\" },\n { AttributeName: \"SK\", KeyType: \"RANGE\" },\n ],\n AttributeDefinitions: [\n { AttributeName: \"PK\", AttributeType: \"S\" },\n { AttributeName: \"SK\", AttributeType: \"S\" },\n { AttributeName: \"GSI1PK\", AttributeType: \"S\" },\n { AttributeName: \"GSI1SK\", AttributeType: \"S\" },\n { AttributeName: \"GSI2PK\", AttributeType: \"S\" },\n { AttributeName: \"GSI2SK\", AttributeType: \"S\" },\n ],\n GlobalSecondaryIndexes: [\n {\n IndexName: schema.correlationIndexName,\n KeySchema: [\n { AttributeName: \"GSI1PK\", KeyType: \"HASH\" },\n { AttributeName: \"GSI1SK\", KeyType: \"RANGE\" },\n ],\n Projection: { ProjectionType: \"ALL\" },\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n },\n {\n IndexName: schema.cleanupIndexName,\n KeySchema: [\n { AttributeName: \"GSI2PK\", KeyType: \"HASH\" },\n { AttributeName: \"GSI2SK\", KeyType: \"RANGE\" },\n ],\n Projection: { ProjectionType: \"KEYS_ONLY\" },\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n },\n ],\n BillingMode: \"PROVISIONED\",\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n });\n\n await client.send(command);\n\n // Wait for table to be active\n await waitForTableActive(client, schema.tableName);\n}\n\n/**\n * Delete the saga instances table.\n */\nexport async function deleteTable(\n client: DynamoDBClient,\n tableName: string\n): Promise<void> {\n await client.send(new DeleteTableCommand({ TableName: tableName }));\n}\n\n/**\n * Wait for table to be active.\n */\nasync function waitForTableActive(\n client: DynamoDBClient,\n tableName: string\n): Promise<void> {\n for (let i = 0; i < 30; i++) {\n const response = await client.send(\n new DescribeTableCommand({ TableName: tableName })\n );\n\n if (response.Table?.TableStatus === \"ACTIVE\") {\n return;\n }\n\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n\n throw new Error(`Table ${tableName} did not become active`);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,121 @@
1
+ import { SagaState, SagaStore } from '@saga-bus/core';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
4
+
5
+ /**
6
+ * DynamoDB saga store configuration options.
7
+ */
8
+ interface DynamoDBSagaStoreOptions {
9
+ /**
10
+ * DynamoDB Document Client instance.
11
+ */
12
+ client: DynamoDBDocumentClient;
13
+ /**
14
+ * Table name for saga instances.
15
+ */
16
+ tableName: string;
17
+ /**
18
+ * Name of the correlation ID GSI.
19
+ * @default "GSI1"
20
+ */
21
+ correlationIndexName?: string;
22
+ /**
23
+ * Name of the cleanup GSI (for querying completed sagas by date).
24
+ * @default "GSI2"
25
+ */
26
+ cleanupIndexName?: string;
27
+ }
28
+ /**
29
+ * Shape of a saga instance item in DynamoDB.
30
+ */
31
+ interface SagaInstanceItem {
32
+ PK: string;
33
+ SK: string;
34
+ sagaName: string;
35
+ sagaId: string;
36
+ correlationId: string;
37
+ version: number;
38
+ isCompleted: boolean;
39
+ state: Record<string, unknown>;
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ GSI1PK: string;
43
+ GSI1SK: string;
44
+ GSI2PK?: string;
45
+ GSI2SK?: string;
46
+ }
47
+ /**
48
+ * Table schema definition for creating the table.
49
+ */
50
+ interface TableSchema {
51
+ tableName: string;
52
+ correlationIndexName: string;
53
+ cleanupIndexName: string;
54
+ }
55
+
56
+ /**
57
+ * DynamoDB-backed saga store.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
62
+ * import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
63
+ *
64
+ * const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
65
+ * const store = new DynamoDBSagaStore<OrderState>({
66
+ * client,
67
+ * tableName: "saga_instances",
68
+ * });
69
+ * ```
70
+ */
71
+ declare class DynamoDBSagaStore<TState extends SagaState> implements SagaStore<TState> {
72
+ private readonly client;
73
+ private readonly tableName;
74
+ private readonly correlationIndexName;
75
+ private readonly cleanupIndexName;
76
+ constructor(options: DynamoDBSagaStoreOptions);
77
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
78
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
79
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
80
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
81
+ delete(sagaName: string, sagaId: string): Promise<void>;
82
+ private itemToState;
83
+ /**
84
+ * Serialize state for DynamoDB storage by converting Date objects to ISO strings.
85
+ */
86
+ private serializeState;
87
+ /**
88
+ * Find sagas by name with pagination.
89
+ */
90
+ findByName(sagaName: string, options?: {
91
+ limit?: number;
92
+ startKey?: Record<string, unknown>;
93
+ completed?: boolean;
94
+ }): Promise<{
95
+ items: TState[];
96
+ lastKey?: Record<string, unknown>;
97
+ }>;
98
+ /**
99
+ * Delete completed sagas older than a given date.
100
+ * Note: This performs a query + batch delete which may require pagination.
101
+ */
102
+ deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
103
+ }
104
+
105
+ /**
106
+ * Get the table schema definition.
107
+ */
108
+ declare function getTableSchema(tableName: string, options?: {
109
+ correlationIndexName?: string;
110
+ cleanupIndexName?: string;
111
+ }): TableSchema;
112
+ /**
113
+ * Create the saga instances table with required indexes.
114
+ */
115
+ declare function createTable(client: DynamoDBClient, schema: TableSchema): Promise<void>;
116
+ /**
117
+ * Delete the saga instances table.
118
+ */
119
+ declare function deleteTable(client: DynamoDBClient, tableName: string): Promise<void>;
120
+
121
+ export { DynamoDBSagaStore, type DynamoDBSagaStoreOptions, type SagaInstanceItem, type TableSchema, createTable, deleteTable, getTableSchema };
@@ -0,0 +1,121 @@
1
+ import { SagaState, SagaStore } from '@saga-bus/core';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
4
+
5
+ /**
6
+ * DynamoDB saga store configuration options.
7
+ */
8
+ interface DynamoDBSagaStoreOptions {
9
+ /**
10
+ * DynamoDB Document Client instance.
11
+ */
12
+ client: DynamoDBDocumentClient;
13
+ /**
14
+ * Table name for saga instances.
15
+ */
16
+ tableName: string;
17
+ /**
18
+ * Name of the correlation ID GSI.
19
+ * @default "GSI1"
20
+ */
21
+ correlationIndexName?: string;
22
+ /**
23
+ * Name of the cleanup GSI (for querying completed sagas by date).
24
+ * @default "GSI2"
25
+ */
26
+ cleanupIndexName?: string;
27
+ }
28
+ /**
29
+ * Shape of a saga instance item in DynamoDB.
30
+ */
31
+ interface SagaInstanceItem {
32
+ PK: string;
33
+ SK: string;
34
+ sagaName: string;
35
+ sagaId: string;
36
+ correlationId: string;
37
+ version: number;
38
+ isCompleted: boolean;
39
+ state: Record<string, unknown>;
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ GSI1PK: string;
43
+ GSI1SK: string;
44
+ GSI2PK?: string;
45
+ GSI2SK?: string;
46
+ }
47
+ /**
48
+ * Table schema definition for creating the table.
49
+ */
50
+ interface TableSchema {
51
+ tableName: string;
52
+ correlationIndexName: string;
53
+ cleanupIndexName: string;
54
+ }
55
+
56
+ /**
57
+ * DynamoDB-backed saga store.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
62
+ * import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
63
+ *
64
+ * const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
65
+ * const store = new DynamoDBSagaStore<OrderState>({
66
+ * client,
67
+ * tableName: "saga_instances",
68
+ * });
69
+ * ```
70
+ */
71
+ declare class DynamoDBSagaStore<TState extends SagaState> implements SagaStore<TState> {
72
+ private readonly client;
73
+ private readonly tableName;
74
+ private readonly correlationIndexName;
75
+ private readonly cleanupIndexName;
76
+ constructor(options: DynamoDBSagaStoreOptions);
77
+ getById(sagaName: string, sagaId: string): Promise<TState | null>;
78
+ getByCorrelationId(sagaName: string, correlationId: string): Promise<TState | null>;
79
+ insert(sagaName: string, correlationId: string, state: TState): Promise<void>;
80
+ update(sagaName: string, state: TState, expectedVersion: number): Promise<void>;
81
+ delete(sagaName: string, sagaId: string): Promise<void>;
82
+ private itemToState;
83
+ /**
84
+ * Serialize state for DynamoDB storage by converting Date objects to ISO strings.
85
+ */
86
+ private serializeState;
87
+ /**
88
+ * Find sagas by name with pagination.
89
+ */
90
+ findByName(sagaName: string, options?: {
91
+ limit?: number;
92
+ startKey?: Record<string, unknown>;
93
+ completed?: boolean;
94
+ }): Promise<{
95
+ items: TState[];
96
+ lastKey?: Record<string, unknown>;
97
+ }>;
98
+ /**
99
+ * Delete completed sagas older than a given date.
100
+ * Note: This performs a query + batch delete which may require pagination.
101
+ */
102
+ deleteCompletedBefore(sagaName: string, before: Date): Promise<number>;
103
+ }
104
+
105
+ /**
106
+ * Get the table schema definition.
107
+ */
108
+ declare function getTableSchema(tableName: string, options?: {
109
+ correlationIndexName?: string;
110
+ cleanupIndexName?: string;
111
+ }): TableSchema;
112
+ /**
113
+ * Create the saga instances table with required indexes.
114
+ */
115
+ declare function createTable(client: DynamoDBClient, schema: TableSchema): Promise<void>;
116
+ /**
117
+ * Delete the saga instances table.
118
+ */
119
+ declare function deleteTable(client: DynamoDBClient, tableName: string): Promise<void>;
120
+
121
+ export { DynamoDBSagaStore, type DynamoDBSagaStoreOptions, type SagaInstanceItem, type TableSchema, createTable, deleteTable, getTableSchema };
package/dist/index.js ADDED
@@ -0,0 +1,313 @@
1
+ // src/DynamoDBSagaStore.ts
2
+ import {
3
+ GetCommand,
4
+ PutCommand,
5
+ UpdateCommand,
6
+ DeleteCommand,
7
+ QueryCommand
8
+ } from "@aws-sdk/lib-dynamodb";
9
+ import { ConcurrencyError } from "@saga-bus/core";
10
+ var DynamoDBSagaStore = class {
11
+ client;
12
+ tableName;
13
+ correlationIndexName;
14
+ cleanupIndexName;
15
+ constructor(options) {
16
+ this.client = options.client;
17
+ this.tableName = options.tableName;
18
+ this.correlationIndexName = options.correlationIndexName ?? "GSI1";
19
+ this.cleanupIndexName = options.cleanupIndexName ?? "GSI2";
20
+ }
21
+ async getById(sagaName, sagaId) {
22
+ const result = await this.client.send(
23
+ new GetCommand({
24
+ TableName: this.tableName,
25
+ Key: {
26
+ PK: sagaName,
27
+ SK: sagaId
28
+ }
29
+ })
30
+ );
31
+ if (!result.Item) {
32
+ return null;
33
+ }
34
+ return this.itemToState(result.Item);
35
+ }
36
+ async getByCorrelationId(sagaName, correlationId) {
37
+ const result = await this.client.send(
38
+ new QueryCommand({
39
+ TableName: this.tableName,
40
+ IndexName: this.correlationIndexName,
41
+ KeyConditionExpression: "GSI1PK = :pk AND GSI1SK = :sk",
42
+ ExpressionAttributeValues: {
43
+ ":pk": sagaName,
44
+ ":sk": correlationId
45
+ },
46
+ Limit: 1
47
+ })
48
+ );
49
+ if (!result.Items || result.Items.length === 0) {
50
+ return null;
51
+ }
52
+ return this.itemToState(result.Items[0]);
53
+ }
54
+ async insert(sagaName, correlationId, state) {
55
+ const { sagaId, version, isCompleted, createdAt, updatedAt } = state.metadata;
56
+ const serializedState = this.serializeState(state);
57
+ const item = {
58
+ PK: sagaName,
59
+ SK: sagaId,
60
+ sagaName,
61
+ sagaId,
62
+ correlationId,
63
+ version,
64
+ isCompleted,
65
+ state: serializedState,
66
+ createdAt: createdAt.toISOString(),
67
+ updatedAt: updatedAt.toISOString(),
68
+ GSI1PK: sagaName,
69
+ GSI1SK: correlationId
70
+ };
71
+ if (isCompleted) {
72
+ item.GSI2PK = sagaName;
73
+ item.GSI2SK = updatedAt.toISOString();
74
+ }
75
+ await this.client.send(
76
+ new PutCommand({
77
+ TableName: this.tableName,
78
+ Item: item,
79
+ ConditionExpression: "attribute_not_exists(PK)"
80
+ })
81
+ );
82
+ }
83
+ async update(sagaName, state, expectedVersion) {
84
+ const { sagaId, version, isCompleted, updatedAt } = state.metadata;
85
+ try {
86
+ const updateExpression = isCompleted ? "SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt, GSI2PK = :gsi2pk, GSI2SK = :gsi2sk" : "SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt REMOVE GSI2PK, GSI2SK";
87
+ const serializedState = this.serializeState(state);
88
+ const expressionValues = {
89
+ ":version": version,
90
+ ":isCompleted": isCompleted,
91
+ ":state": serializedState,
92
+ ":updatedAt": updatedAt.toISOString(),
93
+ ":expectedVersion": expectedVersion
94
+ };
95
+ if (isCompleted) {
96
+ expressionValues[":gsi2pk"] = sagaName;
97
+ expressionValues[":gsi2sk"] = updatedAt.toISOString();
98
+ }
99
+ await this.client.send(
100
+ new UpdateCommand({
101
+ TableName: this.tableName,
102
+ Key: {
103
+ PK: sagaName,
104
+ SK: sagaId
105
+ },
106
+ UpdateExpression: updateExpression,
107
+ ConditionExpression: "version = :expectedVersion",
108
+ ExpressionAttributeNames: {
109
+ "#state": "state"
110
+ },
111
+ ExpressionAttributeValues: expressionValues
112
+ })
113
+ );
114
+ } catch (error) {
115
+ if (error instanceof Error && error.name === "ConditionalCheckFailedException") {
116
+ const existing = await this.getById(sagaName, sagaId);
117
+ if (existing) {
118
+ throw new ConcurrencyError(
119
+ sagaId,
120
+ expectedVersion,
121
+ existing.metadata.version
122
+ );
123
+ } else {
124
+ throw new Error(`Saga ${sagaId} not found`);
125
+ }
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+ async delete(sagaName, sagaId) {
131
+ await this.client.send(
132
+ new DeleteCommand({
133
+ TableName: this.tableName,
134
+ Key: {
135
+ PK: sagaName,
136
+ SK: sagaId
137
+ }
138
+ })
139
+ );
140
+ }
141
+ itemToState(item) {
142
+ const state = item.state;
143
+ return {
144
+ ...state,
145
+ metadata: {
146
+ ...state.metadata,
147
+ sagaId: item.sagaId,
148
+ version: item.version,
149
+ isCompleted: item.isCompleted,
150
+ createdAt: new Date(item.createdAt),
151
+ updatedAt: new Date(item.updatedAt)
152
+ }
153
+ };
154
+ }
155
+ /**
156
+ * Serialize state for DynamoDB storage by converting Date objects to ISO strings.
157
+ */
158
+ serializeState(state) {
159
+ return JSON.parse(JSON.stringify(state, (_, value) => {
160
+ if (value instanceof Date) {
161
+ return value.toISOString();
162
+ }
163
+ return value;
164
+ }));
165
+ }
166
+ // ============ Query Helpers ============
167
+ /**
168
+ * Find sagas by name with pagination.
169
+ */
170
+ async findByName(sagaName, options) {
171
+ let filterExpression;
172
+ const expressionValues = { ":pk": sagaName };
173
+ if (options?.completed !== void 0) {
174
+ filterExpression = "isCompleted = :isCompleted";
175
+ expressionValues[":isCompleted"] = options.completed;
176
+ }
177
+ const result = await this.client.send(
178
+ new QueryCommand({
179
+ TableName: this.tableName,
180
+ KeyConditionExpression: "PK = :pk",
181
+ FilterExpression: filterExpression,
182
+ ExpressionAttributeValues: expressionValues,
183
+ Limit: options?.limit ?? 100,
184
+ ExclusiveStartKey: options?.startKey,
185
+ ScanIndexForward: false
186
+ })
187
+ );
188
+ const items = (result.Items ?? []).map(
189
+ (item) => this.itemToState(item)
190
+ );
191
+ return {
192
+ items,
193
+ lastKey: result.LastEvaluatedKey
194
+ };
195
+ }
196
+ /**
197
+ * Delete completed sagas older than a given date.
198
+ * Note: This performs a query + batch delete which may require pagination.
199
+ */
200
+ async deleteCompletedBefore(sagaName, before) {
201
+ let deletedCount = 0;
202
+ let lastKey;
203
+ do {
204
+ const result = await this.client.send(
205
+ new QueryCommand({
206
+ TableName: this.tableName,
207
+ IndexName: this.cleanupIndexName,
208
+ KeyConditionExpression: "GSI2PK = :pk AND GSI2SK < :before",
209
+ ExpressionAttributeValues: {
210
+ ":pk": sagaName,
211
+ ":before": before.toISOString()
212
+ },
213
+ Limit: 25,
214
+ ExclusiveStartKey: lastKey
215
+ })
216
+ );
217
+ if (result.Items && result.Items.length > 0) {
218
+ for (const item of result.Items) {
219
+ await this.delete(sagaName, item.SK);
220
+ deletedCount++;
221
+ }
222
+ }
223
+ lastKey = result.LastEvaluatedKey;
224
+ } while (lastKey);
225
+ return deletedCount;
226
+ }
227
+ };
228
+
229
+ // src/schema.ts
230
+ import {
231
+ CreateTableCommand,
232
+ DeleteTableCommand,
233
+ DescribeTableCommand
234
+ } from "@aws-sdk/client-dynamodb";
235
+ function getTableSchema(tableName, options) {
236
+ return {
237
+ tableName,
238
+ correlationIndexName: options?.correlationIndexName ?? "GSI1",
239
+ cleanupIndexName: options?.cleanupIndexName ?? "GSI2"
240
+ };
241
+ }
242
+ async function createTable(client, schema) {
243
+ const command = new CreateTableCommand({
244
+ TableName: schema.tableName,
245
+ KeySchema: [
246
+ { AttributeName: "PK", KeyType: "HASH" },
247
+ { AttributeName: "SK", KeyType: "RANGE" }
248
+ ],
249
+ AttributeDefinitions: [
250
+ { AttributeName: "PK", AttributeType: "S" },
251
+ { AttributeName: "SK", AttributeType: "S" },
252
+ { AttributeName: "GSI1PK", AttributeType: "S" },
253
+ { AttributeName: "GSI1SK", AttributeType: "S" },
254
+ { AttributeName: "GSI2PK", AttributeType: "S" },
255
+ { AttributeName: "GSI2SK", AttributeType: "S" }
256
+ ],
257
+ GlobalSecondaryIndexes: [
258
+ {
259
+ IndexName: schema.correlationIndexName,
260
+ KeySchema: [
261
+ { AttributeName: "GSI1PK", KeyType: "HASH" },
262
+ { AttributeName: "GSI1SK", KeyType: "RANGE" }
263
+ ],
264
+ Projection: { ProjectionType: "ALL" },
265
+ ProvisionedThroughput: {
266
+ ReadCapacityUnits: 5,
267
+ WriteCapacityUnits: 5
268
+ }
269
+ },
270
+ {
271
+ IndexName: schema.cleanupIndexName,
272
+ KeySchema: [
273
+ { AttributeName: "GSI2PK", KeyType: "HASH" },
274
+ { AttributeName: "GSI2SK", KeyType: "RANGE" }
275
+ ],
276
+ Projection: { ProjectionType: "KEYS_ONLY" },
277
+ ProvisionedThroughput: {
278
+ ReadCapacityUnits: 5,
279
+ WriteCapacityUnits: 5
280
+ }
281
+ }
282
+ ],
283
+ BillingMode: "PROVISIONED",
284
+ ProvisionedThroughput: {
285
+ ReadCapacityUnits: 5,
286
+ WriteCapacityUnits: 5
287
+ }
288
+ });
289
+ await client.send(command);
290
+ await waitForTableActive(client, schema.tableName);
291
+ }
292
+ async function deleteTable(client, tableName) {
293
+ await client.send(new DeleteTableCommand({ TableName: tableName }));
294
+ }
295
+ async function waitForTableActive(client, tableName) {
296
+ for (let i = 0; i < 30; i++) {
297
+ const response = await client.send(
298
+ new DescribeTableCommand({ TableName: tableName })
299
+ );
300
+ if (response.Table?.TableStatus === "ACTIVE") {
301
+ return;
302
+ }
303
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
304
+ }
305
+ throw new Error(`Table ${tableName} did not become active`);
306
+ }
307
+ export {
308
+ DynamoDBSagaStore,
309
+ createTable,
310
+ deleteTable,
311
+ getTableSchema
312
+ };
313
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/DynamoDBSagaStore.ts","../src/schema.ts"],"sourcesContent":["import {\n GetCommand,\n PutCommand,\n UpdateCommand,\n DeleteCommand,\n QueryCommand,\n} from \"@aws-sdk/lib-dynamodb\";\nimport type { DynamoDBDocumentClient } from \"@aws-sdk/lib-dynamodb\";\nimport type { SagaStore, SagaState } from \"@saga-bus/core\";\nimport { ConcurrencyError } from \"@saga-bus/core\";\nimport type { DynamoDBSagaStoreOptions, SagaInstanceItem } from \"./types.js\";\n\n/**\n * DynamoDB-backed saga store.\n *\n * @example\n * ```typescript\n * import { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\n * import { DynamoDBDocumentClient } from \"@aws-sdk/lib-dynamodb\";\n *\n * const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n * const store = new DynamoDBSagaStore<OrderState>({\n * client,\n * tableName: \"saga_instances\",\n * });\n * ```\n */\nexport class DynamoDBSagaStore<TState extends SagaState>\n implements SagaStore<TState>\n{\n private readonly client: DynamoDBDocumentClient;\n private readonly tableName: string;\n private readonly correlationIndexName: string;\n private readonly cleanupIndexName: string;\n\n constructor(options: DynamoDBSagaStoreOptions) {\n this.client = options.client;\n this.tableName = options.tableName;\n this.correlationIndexName = options.correlationIndexName ?? \"GSI1\";\n this.cleanupIndexName = options.cleanupIndexName ?? \"GSI2\";\n }\n\n async getById(sagaName: string, sagaId: string): Promise<TState | null> {\n const result = await this.client.send(\n new GetCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n })\n );\n\n if (!result.Item) {\n return null;\n }\n\n return this.itemToState(result.Item as SagaInstanceItem);\n }\n\n async getByCorrelationId(\n sagaName: string,\n correlationId: string\n ): Promise<TState | null> {\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n IndexName: this.correlationIndexName,\n KeyConditionExpression: \"GSI1PK = :pk AND GSI1SK = :sk\",\n ExpressionAttributeValues: {\n \":pk\": sagaName,\n \":sk\": correlationId,\n },\n Limit: 1,\n })\n );\n\n if (!result.Items || result.Items.length === 0) {\n return null;\n }\n\n return this.itemToState(result.Items[0] as SagaInstanceItem);\n }\n\n async insert(sagaName: string, correlationId: string, state: TState): Promise<void> {\n const { sagaId, version, isCompleted, createdAt, updatedAt } =\n state.metadata;\n\n // Serialize state with dates converted to ISO strings for DynamoDB compatibility\n const serializedState = this.serializeState(state);\n\n const item: SagaInstanceItem = {\n PK: sagaName,\n SK: sagaId,\n sagaName,\n sagaId,\n correlationId,\n version,\n isCompleted,\n state: serializedState,\n createdAt: createdAt.toISOString(),\n updatedAt: updatedAt.toISOString(),\n GSI1PK: sagaName,\n GSI1SK: correlationId,\n };\n\n // Only add GSI2 keys for completed sagas (for cleanup queries)\n if (isCompleted) {\n item.GSI2PK = sagaName;\n item.GSI2SK = updatedAt.toISOString();\n }\n\n await this.client.send(\n new PutCommand({\n TableName: this.tableName,\n Item: item,\n ConditionExpression: \"attribute_not_exists(PK)\",\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 try {\n const updateExpression = isCompleted\n ? \"SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt, GSI2PK = :gsi2pk, GSI2SK = :gsi2sk\"\n : \"SET version = :version, isCompleted = :isCompleted, #state = :state, updatedAt = :updatedAt REMOVE GSI2PK, GSI2SK\";\n\n // Serialize state with dates converted to ISO strings for DynamoDB compatibility\n const serializedState = this.serializeState(state);\n\n const expressionValues: Record<string, unknown> = {\n \":version\": version,\n \":isCompleted\": isCompleted,\n \":state\": serializedState,\n \":updatedAt\": updatedAt.toISOString(),\n \":expectedVersion\": expectedVersion,\n };\n\n if (isCompleted) {\n expressionValues[\":gsi2pk\"] = sagaName;\n expressionValues[\":gsi2sk\"] = updatedAt.toISOString();\n }\n\n await this.client.send(\n new UpdateCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n UpdateExpression: updateExpression,\n ConditionExpression: \"version = :expectedVersion\",\n ExpressionAttributeNames: {\n \"#state\": \"state\",\n },\n ExpressionAttributeValues: expressionValues,\n })\n );\n } catch (error) {\n if (\n error instanceof Error &&\n error.name === \"ConditionalCheckFailedException\"\n ) {\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 throw error;\n }\n }\n\n async delete(sagaName: string, sagaId: string): Promise<void> {\n await this.client.send(\n new DeleteCommand({\n TableName: this.tableName,\n Key: {\n PK: sagaName,\n SK: sagaId,\n },\n })\n );\n }\n\n private itemToState(item: SagaInstanceItem): TState {\n const state = item.state as TState;\n\n return {\n ...state,\n metadata: {\n ...state.metadata,\n sagaId: item.sagaId,\n version: item.version,\n isCompleted: item.isCompleted,\n createdAt: new Date(item.createdAt),\n updatedAt: new Date(item.updatedAt),\n },\n };\n }\n\n /**\n * Serialize state for DynamoDB storage by converting Date objects to ISO strings.\n */\n private serializeState(state: TState): Record<string, unknown> {\n return JSON.parse(JSON.stringify(state, (_, value) => {\n if (value instanceof Date) {\n return value.toISOString();\n }\n return value;\n }));\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 startKey?: Record<string, unknown>;\n completed?: boolean;\n }\n ): Promise<{ items: TState[]; lastKey?: Record<string, unknown> }> {\n let filterExpression: string | undefined;\n const expressionValues: Record<string, unknown> = { \":pk\": sagaName };\n\n if (options?.completed !== undefined) {\n filterExpression = \"isCompleted = :isCompleted\";\n expressionValues[\":isCompleted\"] = options.completed;\n }\n\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n KeyConditionExpression: \"PK = :pk\",\n FilterExpression: filterExpression,\n ExpressionAttributeValues: expressionValues,\n Limit: options?.limit ?? 100,\n ExclusiveStartKey: options?.startKey,\n ScanIndexForward: false,\n })\n );\n\n const items = (result.Items ?? []).map((item) =>\n this.itemToState(item as SagaInstanceItem)\n );\n\n return {\n items,\n lastKey: result.LastEvaluatedKey,\n };\n }\n\n /**\n * Delete completed sagas older than a given date.\n * Note: This performs a query + batch delete which may require pagination.\n */\n async deleteCompletedBefore(\n sagaName: string,\n before: Date\n ): Promise<number> {\n let deletedCount = 0;\n let lastKey: Record<string, unknown> | undefined;\n\n do {\n const result = await this.client.send(\n new QueryCommand({\n TableName: this.tableName,\n IndexName: this.cleanupIndexName,\n KeyConditionExpression: \"GSI2PK = :pk AND GSI2SK < :before\",\n ExpressionAttributeValues: {\n \":pk\": sagaName,\n \":before\": before.toISOString(),\n },\n Limit: 25,\n ExclusiveStartKey: lastKey,\n })\n );\n\n if (result.Items && result.Items.length > 0) {\n for (const item of result.Items) {\n await this.delete(sagaName, item.SK as string);\n deletedCount++;\n }\n }\n\n lastKey = result.LastEvaluatedKey;\n } while (lastKey);\n\n return deletedCount;\n }\n}\n","import {\n CreateTableCommand,\n DeleteTableCommand,\n DescribeTableCommand,\n type DynamoDBClient,\n} from \"@aws-sdk/client-dynamodb\";\nimport type { TableSchema } from \"./types.js\";\n\n/**\n * Get the table schema definition.\n */\nexport function getTableSchema(\n tableName: string,\n options?: {\n correlationIndexName?: string;\n cleanupIndexName?: string;\n }\n): TableSchema {\n return {\n tableName,\n correlationIndexName: options?.correlationIndexName ?? \"GSI1\",\n cleanupIndexName: options?.cleanupIndexName ?? \"GSI2\",\n };\n}\n\n/**\n * Create the saga instances table with required indexes.\n */\nexport async function createTable(\n client: DynamoDBClient,\n schema: TableSchema\n): Promise<void> {\n const command = new CreateTableCommand({\n TableName: schema.tableName,\n KeySchema: [\n { AttributeName: \"PK\", KeyType: \"HASH\" },\n { AttributeName: \"SK\", KeyType: \"RANGE\" },\n ],\n AttributeDefinitions: [\n { AttributeName: \"PK\", AttributeType: \"S\" },\n { AttributeName: \"SK\", AttributeType: \"S\" },\n { AttributeName: \"GSI1PK\", AttributeType: \"S\" },\n { AttributeName: \"GSI1SK\", AttributeType: \"S\" },\n { AttributeName: \"GSI2PK\", AttributeType: \"S\" },\n { AttributeName: \"GSI2SK\", AttributeType: \"S\" },\n ],\n GlobalSecondaryIndexes: [\n {\n IndexName: schema.correlationIndexName,\n KeySchema: [\n { AttributeName: \"GSI1PK\", KeyType: \"HASH\" },\n { AttributeName: \"GSI1SK\", KeyType: \"RANGE\" },\n ],\n Projection: { ProjectionType: \"ALL\" },\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n },\n {\n IndexName: schema.cleanupIndexName,\n KeySchema: [\n { AttributeName: \"GSI2PK\", KeyType: \"HASH\" },\n { AttributeName: \"GSI2SK\", KeyType: \"RANGE\" },\n ],\n Projection: { ProjectionType: \"KEYS_ONLY\" },\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n },\n ],\n BillingMode: \"PROVISIONED\",\n ProvisionedThroughput: {\n ReadCapacityUnits: 5,\n WriteCapacityUnits: 5,\n },\n });\n\n await client.send(command);\n\n // Wait for table to be active\n await waitForTableActive(client, schema.tableName);\n}\n\n/**\n * Delete the saga instances table.\n */\nexport async function deleteTable(\n client: DynamoDBClient,\n tableName: string\n): Promise<void> {\n await client.send(new DeleteTableCommand({ TableName: tableName }));\n}\n\n/**\n * Wait for table to be active.\n */\nasync function waitForTableActive(\n client: DynamoDBClient,\n tableName: string\n): Promise<void> {\n for (let i = 0; i < 30; i++) {\n const response = await client.send(\n new DescribeTableCommand({ TableName: tableName })\n );\n\n if (response.Table?.TableStatus === \"ACTIVE\") {\n return;\n }\n\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n\n throw new Error(`Table ${tableName} did not become active`);\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@saga-bus/store-dynamodb",
3
+ "version": "0.1.0",
4
+ "description": "DynamoDB saga store for saga-bus",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
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-dynamodb"
27
+ },
28
+ "keywords": [
29
+ "saga",
30
+ "message-bus",
31
+ "store",
32
+ "dynamodb",
33
+ "aws"
34
+ ],
35
+ "dependencies": {
36
+ "@aws-sdk/client-dynamodb": "^3.600.0",
37
+ "@aws-sdk/lib-dynamodb": "^3.600.0",
38
+ "@saga-bus/core": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@testcontainers/localstack": "^10.0.0",
42
+ "tsup": "^8.0.0",
43
+ "typescript": "^5.9.2",
44
+ "vitest": "^3.0.0",
45
+ "@repo/eslint-config": "0.0.0",
46
+ "@repo/typescript-config": "0.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@aws-sdk/client-dynamodb": ">=3.0.0",
50
+ "@aws-sdk/lib-dynamodb": ">=3.0.0"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "dev": "tsup --watch",
55
+ "lint": "eslint src/",
56
+ "check-types": "tsc --noEmit",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest"
59
+ }
60
+ }