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