@ftschopp/dynatable-migrations 1.1.0 → 1.2.1

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +98 -134
  3. package/USAGE.md +195 -60
  4. package/dist/cli.js +7 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/down.d.ts +1 -1
  7. package/dist/commands/down.d.ts.map +1 -1
  8. package/dist/commands/down.js +5 -20
  9. package/dist/commands/down.js.map +1 -1
  10. package/dist/commands/status.d.ts.map +1 -1
  11. package/dist/commands/status.js +2 -17
  12. package/dist/commands/status.js.map +1 -1
  13. package/dist/commands/up.d.ts +1 -1
  14. package/dist/commands/up.d.ts.map +1 -1
  15. package/dist/commands/up.js +5 -20
  16. package/dist/commands/up.js.map +1 -1
  17. package/dist/core/client.d.ts +7 -0
  18. package/dist/core/client.d.ts.map +1 -0
  19. package/dist/core/client.js +25 -0
  20. package/dist/core/client.js.map +1 -0
  21. package/dist/core/loader.d.ts +8 -0
  22. package/dist/core/loader.d.ts.map +1 -1
  23. package/dist/core/loader.js +76 -43
  24. package/dist/core/loader.js.map +1 -1
  25. package/dist/core/runner.d.ts +6 -2
  26. package/dist/core/runner.d.ts.map +1 -1
  27. package/dist/core/runner.js +124 -67
  28. package/dist/core/runner.js.map +1 -1
  29. package/dist/core/tracker.d.ts +20 -1
  30. package/dist/core/tracker.d.ts.map +1 -1
  31. package/dist/core/tracker.js +273 -83
  32. package/dist/core/tracker.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/types/index.d.ts +10 -1
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/package.json +1 -3
  40. package/src/cli.ts +8 -3
  41. package/src/commands/down.ts +6 -22
  42. package/src/commands/status.ts +2 -19
  43. package/src/commands/up.ts +9 -22
  44. package/src/core/client.ts +24 -0
  45. package/src/core/loader.ts +86 -46
  46. package/src/core/runner.ts +149 -78
  47. package/src/core/tracker.ts +305 -94
  48. package/src/index.ts +2 -1
  49. package/src/types/index.ts +13 -1
@@ -1,12 +1,22 @@
1
1
  import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
- import { PutCommand, GetCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
2
+ import {
3
+ PutCommand,
4
+ GetCommand,
5
+ QueryCommand,
6
+ UpdateCommand,
7
+ DeleteCommand,
8
+ TransactWriteCommand,
9
+ } from '@aws-sdk/lib-dynamodb';
3
10
  import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
4
11
 
12
+ const LOCK_TTL_SECONDS = 300; // 5 minutes
13
+
5
14
  export class DynamoDBMigrationTracker implements MigrationTracker {
6
15
  private client: DynamoDBDocumentClient;
7
16
  private tableName: string;
8
17
  private trackingPrefix: string;
9
18
  private gsi1Name: string;
19
+ private lockId: string | null = null;
10
20
 
11
21
  constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
12
22
  this.client = client;
@@ -36,18 +46,103 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
36
46
  }
37
47
  }
38
48
 
49
+ /**
50
+ * Acquire a distributed lock to prevent concurrent migrations
51
+ */
52
+ async acquireLock(): Promise<boolean> {
53
+ const lockKey = {
54
+ PK: `${this.trackingPrefix}#LOCK`,
55
+ SK: `${this.trackingPrefix}#LOCK`,
56
+ };
57
+
58
+ const now = Date.now();
59
+ const expiresAt = now + LOCK_TTL_SECONDS * 1000;
60
+ this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
61
+
62
+ try {
63
+ await this.client.send(
64
+ new PutCommand({
65
+ TableName: this.tableName,
66
+ Item: {
67
+ ...lockKey,
68
+ lockId: this.lockId,
69
+ acquiredAt: new Date().toISOString(),
70
+ expiresAt,
71
+ },
72
+ // Only succeed if lock doesn't exist or has expired
73
+ ConditionExpression: 'attribute_not_exists(PK) OR expiresAt < :now',
74
+ ExpressionAttributeValues: {
75
+ ':now': now,
76
+ },
77
+ })
78
+ );
79
+ return true;
80
+ } catch (error: any) {
81
+ if (error.name === 'ConditionalCheckFailedException') {
82
+ this.lockId = null;
83
+ return false;
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Release the distributed lock
91
+ */
92
+ async releaseLock(): Promise<void> {
93
+ if (!this.lockId) return;
94
+
95
+ const lockKey = {
96
+ PK: `${this.trackingPrefix}#LOCK`,
97
+ SK: `${this.trackingPrefix}#LOCK`,
98
+ };
99
+
100
+ try {
101
+ await this.client.send(
102
+ new DeleteCommand({
103
+ TableName: this.tableName,
104
+ Key: lockKey,
105
+ // Only delete if we own the lock
106
+ ConditionExpression: 'lockId = :lockId',
107
+ ExpressionAttributeValues: {
108
+ ':lockId': this.lockId,
109
+ },
110
+ })
111
+ );
112
+ } catch (error: any) {
113
+ // Lock may have expired or been taken by someone else
114
+ if (error.name !== 'ConditionalCheckFailedException') {
115
+ throw error;
116
+ }
117
+ } finally {
118
+ this.lockId = null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get all applied migrations with pagination support
124
+ */
39
125
  async getAppliedMigrations(): Promise<MigrationRecord[]> {
40
- const result = await this.client.send(
41
- new QueryCommand({
42
- TableName: this.tableName,
43
- KeyConditionExpression: 'PK = :pk',
44
- ExpressionAttributeValues: {
45
- ':pk': this.trackingPrefix,
46
- },
47
- })
48
- );
126
+ const items: MigrationRecord[] = [];
127
+ let lastKey: Record<string, any> | undefined;
128
+
129
+ do {
130
+ const result = await this.client.send(
131
+ new QueryCommand({
132
+ TableName: this.tableName,
133
+ KeyConditionExpression: 'PK = :pk',
134
+ ExpressionAttributeValues: {
135
+ ':pk': this.trackingPrefix,
136
+ },
137
+ ExclusiveStartKey: lastKey,
138
+ })
139
+ );
140
+
141
+ items.push(...((result.Items || []) as MigrationRecord[]));
142
+ lastKey = result.LastEvaluatedKey;
143
+ } while (lastKey);
49
144
 
50
- return (result.Items || []) as MigrationRecord[];
145
+ return items;
51
146
  }
52
147
 
53
148
  async getCurrentVersion(): Promise<string | null> {
@@ -64,70 +159,138 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
64
159
  return result.Item?.currentVersion || null;
65
160
  }
66
161
 
162
+ /**
163
+ * Mark migration as applied using TransactWrite for atomicity
164
+ * Handles both new migrations and re-applying rolled back migrations
165
+ */
67
166
  async markAsApplied(
68
167
  version: string,
69
168
  name: string,
70
169
  schemaDefinition?: Record<string, any>,
71
- schemaChanges?: SchemaChange[]
170
+ schemaChanges?: SchemaChange[],
171
+ checksum?: string
72
172
  ): Promise<void> {
73
173
  const now = new Date().toISOString();
74
174
 
75
- // Create migration record
76
- await this.client.send(
77
- new PutCommand({
78
- TableName: this.tableName,
79
- Item: {
80
- PK: this.trackingPrefix,
81
- SK: version,
82
- version,
83
- name,
84
- timestamp: now,
85
- appliedAt: now,
86
- status: 'applied',
87
- schemaDefinition,
88
- schemaChanges,
89
- } as MigrationRecord,
90
- })
91
- );
175
+ // Check if migration record already exists (e.g., rolled back or failed)
176
+ const existing = await this.getMigration(version);
92
177
 
93
- // Update current version pointer
94
- await this.client.send(
95
- new UpdateCommand({
96
- TableName: this.tableName,
97
- Key: {
98
- PK: `${this.trackingPrefix}#CURRENT`,
99
- SK: `${this.trackingPrefix}#CURRENT`,
100
- },
101
- UpdateExpression: 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
102
- ExpressionAttributeValues: {
103
- ':version': version,
104
- ':updatedAt': now,
105
- ':gsi1sk': version,
106
- },
107
- })
108
- );
178
+ if (existing) {
179
+ // Update existing record (re-applying a rolled back or failed migration)
180
+ // Build dynamic update expression - DynamoDB doesn't accept undefined values
181
+ const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
182
+ const expressionValues: Record<string, any> = {
183
+ ':status': 'applied',
184
+ ':appliedAt': now,
185
+ ':name': name,
186
+ };
187
+
188
+ if (schemaDefinition !== undefined) {
189
+ setParts.push('schemaDefinition = :schemaDef');
190
+ expressionValues[':schemaDef'] = schemaDefinition;
191
+ }
192
+ if (schemaChanges !== undefined) {
193
+ setParts.push('schemaChanges = :schemaChanges');
194
+ expressionValues[':schemaChanges'] = schemaChanges;
195
+ }
196
+ if (checksum !== undefined) {
197
+ setParts.push('checksum = :checksum');
198
+ expressionValues[':checksum'] = checksum;
199
+ }
200
+
201
+ await this.client.send(
202
+ new TransactWriteCommand({
203
+ TransactItems: [
204
+ {
205
+ Update: {
206
+ TableName: this.tableName,
207
+ Key: {
208
+ PK: this.trackingPrefix,
209
+ SK: version,
210
+ },
211
+ UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
212
+ ExpressionAttributeNames: {
213
+ '#status': 'status',
214
+ '#name': 'name',
215
+ '#error': 'error',
216
+ },
217
+ ExpressionAttributeValues: expressionValues,
218
+ // Only allow re-applying if not currently applied
219
+ ConditionExpression: '#status <> :status',
220
+ },
221
+ },
222
+ {
223
+ Update: {
224
+ TableName: this.tableName,
225
+ Key: {
226
+ PK: `${this.trackingPrefix}#CURRENT`,
227
+ SK: `${this.trackingPrefix}#CURRENT`,
228
+ },
229
+ UpdateExpression:
230
+ 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
231
+ ExpressionAttributeValues: {
232
+ ':version': version,
233
+ ':updatedAt': now,
234
+ ':gsi1sk': version,
235
+ },
236
+ },
237
+ },
238
+ ],
239
+ })
240
+ );
241
+ } else {
242
+ // Create new migration record
243
+ await this.client.send(
244
+ new TransactWriteCommand({
245
+ TransactItems: [
246
+ {
247
+ Put: {
248
+ TableName: this.tableName,
249
+ Item: {
250
+ PK: this.trackingPrefix,
251
+ SK: version,
252
+ version,
253
+ name,
254
+ timestamp: now,
255
+ appliedAt: now,
256
+ status: 'applied',
257
+ schemaDefinition,
258
+ schemaChanges,
259
+ checksum,
260
+ } as MigrationRecord,
261
+ // Ensure migration wasn't already applied
262
+ ConditionExpression: 'attribute_not_exists(PK)',
263
+ },
264
+ },
265
+ {
266
+ Update: {
267
+ TableName: this.tableName,
268
+ Key: {
269
+ PK: `${this.trackingPrefix}#CURRENT`,
270
+ SK: `${this.trackingPrefix}#CURRENT`,
271
+ },
272
+ UpdateExpression:
273
+ 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
274
+ ExpressionAttributeValues: {
275
+ ':version': version,
276
+ ':updatedAt': now,
277
+ ':gsi1sk': version,
278
+ },
279
+ },
280
+ },
281
+ ],
282
+ })
283
+ );
284
+ }
109
285
  }
110
286
 
287
+ /**
288
+ * Mark migration as rolled back using TransactWrite for atomicity
289
+ */
111
290
  async markAsRolledBack(version: string): Promise<void> {
112
- await this.client.send(
113
- new UpdateCommand({
114
- TableName: this.tableName,
115
- Key: {
116
- PK: this.trackingPrefix,
117
- SK: version,
118
- },
119
- UpdateExpression: 'SET #status = :status, rolledBackAt = :timestamp',
120
- ExpressionAttributeNames: {
121
- '#status': 'status',
122
- },
123
- ExpressionAttributeValues: {
124
- ':status': 'rolled_back',
125
- ':timestamp': new Date().toISOString(),
126
- },
127
- })
128
- );
291
+ const now = new Date().toISOString();
129
292
 
130
- // Find previous version and update current pointer
293
+ // Find previous version
131
294
  const migrations = await this.getAppliedMigrations();
132
295
  const appliedMigrations = migrations
133
296
  .filter((m) => m.status === 'applied' && m.version !== version)
@@ -135,43 +298,91 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
135
298
 
136
299
  const previousVersion = appliedMigrations[0]?.version || 'v0000';
137
300
 
301
+ // Use TransactWrite to atomically update record and pointer
138
302
  await this.client.send(
139
- new UpdateCommand({
140
- TableName: this.tableName,
141
- Key: {
142
- PK: `${this.trackingPrefix}#CURRENT`,
143
- SK: `${this.trackingPrefix}#CURRENT`,
144
- },
145
- UpdateExpression: 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
146
- ExpressionAttributeValues: {
147
- ':version': previousVersion,
148
- ':updatedAt': new Date().toISOString(),
149
- ':gsi1sk': previousVersion,
150
- },
303
+ new TransactWriteCommand({
304
+ TransactItems: [
305
+ {
306
+ Update: {
307
+ TableName: this.tableName,
308
+ Key: {
309
+ PK: this.trackingPrefix,
310
+ SK: version,
311
+ },
312
+ UpdateExpression: 'SET #status = :status, rolledBackAt = :timestamp',
313
+ ExpressionAttributeNames: {
314
+ '#status': 'status',
315
+ },
316
+ ExpressionAttributeValues: {
317
+ ':status': 'rolled_back',
318
+ ':timestamp': now,
319
+ },
320
+ },
321
+ },
322
+ {
323
+ Update: {
324
+ TableName: this.tableName,
325
+ Key: {
326
+ PK: `${this.trackingPrefix}#CURRENT`,
327
+ SK: `${this.trackingPrefix}#CURRENT`,
328
+ },
329
+ UpdateExpression:
330
+ 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
331
+ ExpressionAttributeValues: {
332
+ ':version': previousVersion,
333
+ ':updatedAt': now,
334
+ ':gsi1sk': previousVersion,
335
+ },
336
+ },
337
+ },
338
+ ],
151
339
  })
152
340
  );
153
341
  }
154
342
 
155
343
  async markAsFailed(version: string, error: string): Promise<void> {
156
- await this.client.send(
157
- new UpdateCommand({
158
- TableName: this.tableName,
159
- Key: {
160
- PK: this.trackingPrefix,
161
- SK: version,
162
- },
163
- UpdateExpression: 'SET #status = :status, #error = :error, failedAt = :timestamp',
164
- ExpressionAttributeNames: {
165
- '#status': 'status',
166
- '#error': 'error',
167
- },
168
- ExpressionAttributeValues: {
169
- ':status': 'failed',
170
- ':error': error,
171
- ':timestamp': new Date().toISOString(),
172
- },
173
- })
174
- );
344
+ // Check if the migration record exists first
345
+ const existing = await this.getMigration(version);
346
+
347
+ if (existing) {
348
+ // Update existing record
349
+ await this.client.send(
350
+ new UpdateCommand({
351
+ TableName: this.tableName,
352
+ Key: {
353
+ PK: this.trackingPrefix,
354
+ SK: version,
355
+ },
356
+ UpdateExpression: 'SET #status = :status, #error = :error, failedAt = :timestamp',
357
+ ExpressionAttributeNames: {
358
+ '#status': 'status',
359
+ '#error': 'error',
360
+ },
361
+ ExpressionAttributeValues: {
362
+ ':status': 'failed',
363
+ ':error': error,
364
+ ':timestamp': new Date().toISOString(),
365
+ },
366
+ })
367
+ );
368
+ } else {
369
+ // Create new record for failed migration
370
+ await this.client.send(
371
+ new PutCommand({
372
+ TableName: this.tableName,
373
+ Item: {
374
+ PK: this.trackingPrefix,
375
+ SK: version,
376
+ version,
377
+ name: 'unknown',
378
+ status: 'failed',
379
+ error,
380
+ failedAt: new Date().toISOString(),
381
+ timestamp: new Date().toISOString(),
382
+ },
383
+ })
384
+ );
385
+ }
175
386
  }
176
387
 
177
388
  async recordSchemaChange(change: SchemaChange): Promise<void> {
package/src/index.ts CHANGED
@@ -9,8 +9,9 @@ export * from './types';
9
9
  // Core
10
10
  export { DynamoDBMigrationTracker } from './core/tracker';
11
11
  export { MigrationLoader } from './core/loader';
12
- export { MigrationRunner } from './core/runner';
12
+ export { MigrationRunner, type RunOptions } from './core/runner';
13
13
  export { ConfigLoader, loadConfig } from './core/config';
14
+ export { createDynamoDBClient } from './core/client';
14
15
 
15
16
  // Template
16
17
  export { generateMigrationTemplate } from './templates/migration';
@@ -138,6 +138,16 @@ export interface MigrationTracker {
138
138
  */
139
139
  getCurrentVersion(): Promise<string | null>;
140
140
 
141
+ /**
142
+ * Acquire a distributed lock
143
+ */
144
+ acquireLock(): Promise<boolean>;
145
+
146
+ /**
147
+ * Release the distributed lock
148
+ */
149
+ releaseLock(): Promise<void>;
150
+
141
151
  /**
142
152
  * Mark migration as applied
143
153
  */
@@ -145,7 +155,8 @@ export interface MigrationTracker {
145
155
  version: string,
146
156
  name: string,
147
157
  schemaDefinition?: Record<string, any>,
148
- schemaChanges?: SchemaChange[]
158
+ schemaChanges?: SchemaChange[],
159
+ checksum?: string
149
160
  ): Promise<void>;
150
161
 
151
162
  /**
@@ -182,6 +193,7 @@ export interface MigrationFile {
182
193
  name: string;
183
194
  filePath: string;
184
195
  migration: Migration;
196
+ checksum?: string;
185
197
  }
186
198
 
187
199
  /**