@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,26 +1,9 @@
1
- import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
- import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
1
  import { MigrationRunner } from '../core/runner';
2
+ import { createDynamoDBClient } from '../core/client';
4
3
  import { MigrationConfig } from '../types';
5
4
 
6
5
  export async function showStatus(config: MigrationConfig): Promise<void> {
7
- // Create DynamoDB client
8
- const ddbClient = new DynamoDBClient({
9
- region: config.client.region,
10
- endpoint: config.client.endpoint,
11
- credentials: config.client.credentials,
12
- });
13
-
14
- const client = DynamoDBDocumentClient.from(ddbClient, {
15
- marshallOptions: {
16
- removeUndefinedValues: true,
17
- convertClassInstanceToMap: true,
18
- },
19
- unmarshallOptions: {
20
- wrapNumbers: false,
21
- },
22
- });
23
-
6
+ const client = createDynamoDBClient(config);
24
7
  const runner = new MigrationRunner(client, config);
25
8
 
26
9
  try {
@@ -1,32 +1,19 @@
1
- import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
- import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
1
  import { MigrationRunner } from '../core/runner';
2
+ import { createDynamoDBClient } from '../core/client';
4
3
  import { MigrationConfig } from '../types';
5
4
 
6
- export async function runMigrations(config: MigrationConfig, limit?: number): Promise<void> {
7
- // Create DynamoDB client
8
- const ddbClient = new DynamoDBClient({
9
- region: config.client.region,
10
- endpoint: config.client.endpoint,
11
- credentials: config.client.credentials,
12
- });
13
-
14
- const client = DynamoDBDocumentClient.from(ddbClient, {
15
- marshallOptions: {
16
- removeUndefinedValues: true,
17
- convertClassInstanceToMap: true,
18
- },
19
- unmarshallOptions: {
20
- wrapNumbers: false,
21
- },
22
- });
23
-
5
+ export async function runMigrations(
6
+ config: MigrationConfig,
7
+ limit?: number,
8
+ dryRun: boolean = false
9
+ ): Promise<void> {
10
+ const client = createDynamoDBClient(config);
24
11
  const runner = new MigrationRunner(client, config);
25
12
 
26
13
  try {
27
- const executed = await runner.up(limit);
14
+ const executed = await runner.up({ limit, dryRun });
28
15
 
29
- if (executed.length > 0) {
16
+ if (!dryRun && executed.length > 0) {
30
17
  console.log(`\n🎉 Successfully applied ${executed.length} migration(s)`);
31
18
 
32
19
  const currentVersion = await runner.getCurrentVersion();
@@ -0,0 +1,24 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { MigrationConfig } from '../types';
4
+
5
+ /**
6
+ * Create a DynamoDB Document Client from config
7
+ */
8
+ export function createDynamoDBClient(config: MigrationConfig): DynamoDBDocumentClient {
9
+ const ddbClient = new DynamoDBClient({
10
+ region: config.client.region,
11
+ endpoint: config.client.endpoint,
12
+ credentials: config.client.credentials,
13
+ });
14
+
15
+ return DynamoDBDocumentClient.from(ddbClient, {
16
+ marshallOptions: {
17
+ removeUndefinedValues: true,
18
+ convertClassInstanceToMap: true,
19
+ },
20
+ unmarshallOptions: {
21
+ wrapNumbers: false,
22
+ },
23
+ });
24
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import * as crypto from 'crypto';
3
4
  import { Migration, MigrationFile } from '../types';
4
5
 
5
6
  /**
@@ -12,6 +13,14 @@ export class MigrationLoader {
12
13
  this.migrationsDir = migrationsDir;
13
14
  }
14
15
 
16
+ /**
17
+ * Calculate checksum of a file
18
+ */
19
+ calculateChecksum(filePath: string): string {
20
+ const content = fs.readFileSync(filePath, 'utf-8');
21
+ return crypto.createHash('md5').update(content).digest('hex');
22
+ }
23
+
15
24
  /**
16
25
  * Load all migration files
17
26
  */
@@ -53,63 +62,96 @@ export class MigrationLoader {
53
62
  * Load single migration file
54
63
  */
55
64
  private async loadMigration(filePath: string): Promise<MigrationFile | null> {
56
- try {
57
- // Convert to absolute path
58
- const absolutePath = path.isAbsolute(filePath)
59
- ? filePath
60
- : path.resolve(process.cwd(), filePath);
61
-
62
- let module: any;
63
-
64
- // For TypeScript files, use require (ts-node must be registered)
65
- if (absolutePath.endsWith('.ts')) {
66
- // Register ts-node if not already registered
67
- try {
68
- require('ts-node').register({
69
- transpileOnly: true,
70
- skipProject: true, // Don't use project tsconfig
71
- compilerOptions: {
72
- module: 'commonjs',
73
- target: 'ES2020',
74
- esModuleInterop: true,
75
- moduleResolution: 'node',
76
- },
77
- });
78
- } catch {
79
- // ts-node might already be registered
80
- }
81
-
82
- // Clear require cache to reload file
83
- delete require.cache[absolutePath];
65
+ // Convert to absolute path
66
+ const absolutePath = path.isAbsolute(filePath)
67
+ ? filePath
68
+ : path.resolve(process.cwd(), filePath);
69
+
70
+ let module: any;
84
71
 
72
+ // For TypeScript files, use require (ts-node must be registered)
73
+ if (absolutePath.endsWith('.ts')) {
74
+ // Register ts-node if not already registered
75
+ this.registerTsNode();
76
+
77
+ // Clear require cache to reload file
78
+ delete require.cache[absolutePath];
79
+
80
+ try {
85
81
  module = require(absolutePath);
86
- } else {
87
- // For JavaScript files, use dynamic import
82
+ } catch (error: any) {
83
+ throw new Error(
84
+ `Failed to load TypeScript migration ${filePath}: ${error.message}. ` +
85
+ `Ensure ts-node is installed and the file has valid TypeScript syntax.`
86
+ );
87
+ }
88
+ } else {
89
+ // For JavaScript files, use dynamic import
90
+ try {
88
91
  const fileUrl = `file://${absolutePath}`;
89
92
  module = await import(fileUrl);
93
+ } catch (error: any) {
94
+ throw new Error(
95
+ `Failed to load JavaScript migration ${filePath}: ${error.message}`
96
+ );
90
97
  }
98
+ }
91
99
 
92
- const migration: Migration = module.migration || module.default;
100
+ const migration: Migration = module.migration || module.default;
93
101
 
94
- if (!migration) {
95
- console.warn(`Warning: No migration export found in ${filePath}`);
96
- return null;
97
- }
102
+ if (!migration) {
103
+ console.warn(`Warning: No migration export found in ${filePath}`);
104
+ return null;
105
+ }
106
+
107
+ // Validate migration structure
108
+ this.validateMigration(migration, filePath);
109
+
110
+ const fileName = path.basename(filePath);
111
+ const { version, name } = this.parseMigrationFileName(fileName);
98
112
 
99
- // Validate migration structure
100
- this.validateMigration(migration, filePath);
113
+ // Calculate checksum
114
+ const checksum = this.calculateChecksum(absolutePath);
101
115
 
102
- const fileName = path.basename(filePath);
103
- const { version, name } = this.parseMigrationFileName(fileName);
116
+ return {
117
+ version,
118
+ name,
119
+ filePath,
120
+ migration,
121
+ checksum,
122
+ };
123
+ }
104
124
 
105
- return {
106
- version,
107
- name,
108
- filePath,
109
- migration,
110
- };
125
+ /**
126
+ * Register ts-node for TypeScript file loading
127
+ */
128
+ private registerTsNode(): void {
129
+ try {
130
+ // Check if ts-node is already registered
131
+ const tsNodeSymbol = Symbol.for('ts-node.register.instance');
132
+ if ((process as any)[tsNodeSymbol]) {
133
+ return; // Already registered
134
+ }
135
+
136
+ require('ts-node').register({
137
+ transpileOnly: true,
138
+ skipProject: true, // Don't use project tsconfig
139
+ compilerOptions: {
140
+ module: 'commonjs',
141
+ target: 'ES2020',
142
+ esModuleInterop: true,
143
+ moduleResolution: 'node',
144
+ },
145
+ });
111
146
  } catch (error: any) {
112
- throw new Error(`Failed to load migration ${filePath}: ${error.message}`);
147
+ if (error.code === 'MODULE_NOT_FOUND') {
148
+ throw new Error(
149
+ 'ts-node is required to load TypeScript migrations. ' +
150
+ 'Install it with: npm install ts-node'
151
+ );
152
+ }
153
+ // Other errors might mean ts-node is already registered differently
154
+ console.warn(`Warning: Could not register ts-node: ${error.message}`);
113
155
  }
114
156
  }
115
157
 
@@ -1,8 +1,25 @@
1
1
  import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
+ import {
3
+ ScanCommand,
4
+ QueryCommand,
5
+ GetCommand,
6
+ PutCommand,
7
+ UpdateCommand,
8
+ DeleteCommand,
9
+ BatchGetCommand,
10
+ BatchWriteCommand,
11
+ TransactWriteCommand,
12
+ TransactGetCommand,
13
+ } from '@aws-sdk/lib-dynamodb';
2
14
  import { MigrationConfig, MigrationContext, MigrationFile, MigrationStatus } from '../types';
3
15
  import { DynamoDBMigrationTracker } from './tracker';
4
16
  import { MigrationLoader } from './loader';
5
17
 
18
+ export interface RunOptions {
19
+ limit?: number;
20
+ dryRun?: boolean;
21
+ }
22
+
6
23
  export class MigrationRunner {
7
24
  private client: DynamoDBDocumentClient;
8
25
  private config: MigrationConfig;
@@ -26,105 +43,175 @@ export class MigrationRunner {
26
43
  /**
27
44
  * Run all pending migrations
28
45
  */
29
- async up(limit?: number): Promise<MigrationFile[]> {
46
+ async up(options: RunOptions = {}): Promise<MigrationFile[]> {
47
+ const { limit, dryRun = false } = options;
48
+
30
49
  await this.initialize();
31
50
 
32
- const appliedMigrations = await this.tracker.getAppliedMigrations();
33
- const appliedVersions = appliedMigrations
34
- .filter((m) => m.status === 'applied')
35
- .map((m) => m.version);
51
+ // Acquire lock unless dry run
52
+ if (!dryRun) {
53
+ const lockAcquired = await this.tracker.acquireLock();
54
+ if (!lockAcquired) {
55
+ throw new Error(
56
+ 'Could not acquire migration lock. Another migration may be in progress. ' +
57
+ 'If you believe this is an error, wait a few minutes and try again.'
58
+ );
59
+ }
60
+ }
36
61
 
37
- let pendingMigrations = await this.loader.getPendingMigrations(appliedVersions);
62
+ try {
63
+ const appliedMigrations = await this.tracker.getAppliedMigrations();
64
+ const appliedVersions = appliedMigrations
65
+ .filter((m) => m.status === 'applied')
66
+ .map((m) => m.version);
67
+
68
+ // Check for checksum mismatches on applied migrations
69
+ for (const applied of appliedMigrations.filter((m) => m.status === 'applied')) {
70
+ const file = await this.loader.getMigration(applied.version);
71
+ if (file && applied.checksum && file.checksum !== applied.checksum) {
72
+ console.warn(
73
+ `⚠️ Warning: Migration ${applied.version} has been modified since it was applied. ` +
74
+ `Original checksum: ${applied.checksum}, Current: ${file.checksum}`
75
+ );
76
+ }
77
+ }
38
78
 
39
- // Apply limit if specified
40
- if (limit) {
41
- pendingMigrations = pendingMigrations.slice(0, limit);
42
- }
79
+ let pendingMigrations = await this.loader.getPendingMigrations(appliedVersions);
43
80
 
44
- if (pendingMigrations.length === 0) {
45
- console.log('✅ No pending migrations');
46
- return [];
47
- }
81
+ // Apply limit if specified
82
+ if (limit) {
83
+ pendingMigrations = pendingMigrations.slice(0, limit);
84
+ }
48
85
 
49
- console.log(`\n📦 Found ${pendingMigrations.length} pending migration(s)\n`);
86
+ if (pendingMigrations.length === 0) {
87
+ console.log('✅ No pending migrations');
88
+ return [];
89
+ }
50
90
 
51
- const executed: MigrationFile[] = [];
91
+ if (dryRun) {
92
+ console.log(`\n🔍 DRY RUN - Would apply ${pendingMigrations.length} migration(s):\n`);
93
+ for (const migrationFile of pendingMigrations) {
94
+ console.log(` ${migrationFile.version}: ${migrationFile.name}`);
95
+ if (migrationFile.migration.description) {
96
+ console.log(` ${migrationFile.migration.description}`);
97
+ }
98
+ }
99
+ console.log('\nNo changes were made.\n');
100
+ return pendingMigrations;
101
+ }
52
102
 
53
- for (const migrationFile of pendingMigrations) {
54
- try {
55
- console.log(`⬆️ Applying ${migrationFile.version}: ${migrationFile.name}`);
103
+ console.log(`\n📦 Found ${pendingMigrations.length} pending migration(s)\n`);
56
104
 
57
- const context = this.createContext();
58
- await migrationFile.migration.up(context);
105
+ const executed: MigrationFile[] = [];
59
106
 
60
- await this.tracker.markAsApplied(
61
- migrationFile.version,
62
- migrationFile.name,
63
- migrationFile.migration.schema
64
- );
107
+ for (const migrationFile of pendingMigrations) {
108
+ try {
109
+ console.log(`⬆️ Applying ${migrationFile.version}: ${migrationFile.name}`);
110
+
111
+ const context = this.createContext();
112
+ await migrationFile.migration.up(context);
65
113
 
66
- console.log(`✅ Applied ${migrationFile.version}: ${migrationFile.name}\n`);
67
- executed.push(migrationFile);
68
- } catch (error: any) {
69
- console.error(`❌ Failed to apply ${migrationFile.version}: ${error.message}\n`);
114
+ await this.tracker.markAsApplied(
115
+ migrationFile.version,
116
+ migrationFile.name,
117
+ migrationFile.migration.schema,
118
+ undefined,
119
+ migrationFile.checksum
120
+ );
70
121
 
71
- await this.tracker.markAsFailed(migrationFile.version, error.message);
122
+ console.log(`✅ Applied ${migrationFile.version}: ${migrationFile.name}\n`);
123
+ executed.push(migrationFile);
124
+ } catch (error: any) {
125
+ console.error(`❌ Failed to apply ${migrationFile.version}: ${error.message}\n`);
72
126
 
73
- throw new Error(`Migration ${migrationFile.version} failed: ${error.message}`);
127
+ await this.tracker.markAsFailed(migrationFile.version, error.message);
128
+
129
+ throw new Error(`Migration ${migrationFile.version} failed: ${error.message}`);
130
+ }
74
131
  }
75
- }
76
132
 
77
- return executed;
133
+ return executed;
134
+ } finally {
135
+ // Always release lock
136
+ if (!dryRun) {
137
+ await this.tracker.releaseLock();
138
+ }
139
+ }
78
140
  }
79
141
 
80
142
  /**
81
143
  * Rollback last migration
82
144
  */
83
- async down(steps: number = 1): Promise<MigrationFile[]> {
145
+ async down(steps: number = 1, dryRun: boolean = false): Promise<MigrationFile[]> {
84
146
  await this.initialize();
85
147
 
86
- const appliedMigrations = await this.tracker.getAppliedMigrations();
87
- const applied = appliedMigrations
88
- .filter((m) => m.status === 'applied')
89
- .sort((a, b) => b.version.localeCompare(a.version))
90
- .slice(0, steps);
91
-
92
- if (applied.length === 0) {
93
- console.log('✅ No migrations to rollback');
94
- return [];
148
+ // Acquire lock unless dry run
149
+ if (!dryRun) {
150
+ const lockAcquired = await this.tracker.acquireLock();
151
+ if (!lockAcquired) {
152
+ throw new Error(
153
+ 'Could not acquire migration lock. Another migration may be in progress.'
154
+ );
155
+ }
95
156
  }
96
157
 
97
- console.log(`\n📦 Rolling back ${applied.length} migration(s)\n`);
98
-
99
- const rolledBack: MigrationFile[] = [];
158
+ try {
159
+ const appliedMigrations = await this.tracker.getAppliedMigrations();
160
+ const applied = appliedMigrations
161
+ .filter((m) => m.status === 'applied')
162
+ .sort((a, b) => b.version.localeCompare(a.version))
163
+ .slice(0, steps);
100
164
 
101
- for (const record of applied) {
102
- try {
103
- const migrationFile = await this.loader.getMigration(record.version);
165
+ if (applied.length === 0) {
166
+ console.log('✅ No migrations to rollback');
167
+ return [];
168
+ }
104
169
 
105
- if (!migrationFile) {
106
- throw new Error(`Migration file not found for version ${record.version}`);
170
+ if (dryRun) {
171
+ console.log(`\n🔍 DRY RUN - Would rollback ${applied.length} migration(s):\n`);
172
+ for (const record of applied) {
173
+ console.log(` ${record.version}: ${record.name}`);
107
174
  }
175
+ console.log('\nNo changes were made.\n');
176
+ return [];
177
+ }
108
178
 
109
- console.log(`⬇️ Rolling back ${migrationFile.version}: ${migrationFile.name}`);
179
+ console.log(`\n📦 Rolling back ${applied.length} migration(s)\n`);
110
180
 
111
- const context = this.createContext();
112
- await migrationFile.migration.down(context);
181
+ const rolledBack: MigrationFile[] = [];
113
182
 
114
- await this.tracker.markAsRolledBack(migrationFile.version);
183
+ for (const record of applied) {
184
+ try {
185
+ const migrationFile = await this.loader.getMigration(record.version);
115
186
 
116
- console.log(`✅ Rolled back ${migrationFile.version}: ${migrationFile.name}\n`);
117
- rolledBack.push(migrationFile);
118
- } catch (error: any) {
119
- console.error(`❌ Failed to rollback ${record.version}: ${error.message}\n`);
187
+ if (!migrationFile) {
188
+ throw new Error(`Migration file not found for version ${record.version}`);
189
+ }
120
190
 
121
- await this.tracker.markAsFailed(record.version, error.message);
191
+ console.log(`⬇️ Rolling back ${migrationFile.version}: ${migrationFile.name}`);
122
192
 
123
- throw new Error(`Rollback of ${record.version} failed: ${error.message}`);
193
+ const context = this.createContext();
194
+ await migrationFile.migration.down(context);
195
+
196
+ await this.tracker.markAsRolledBack(migrationFile.version);
197
+
198
+ console.log(`✅ Rolled back ${migrationFile.version}: ${migrationFile.name}\n`);
199
+ rolledBack.push(migrationFile);
200
+ } catch (error: any) {
201
+ console.error(`❌ Failed to rollback ${record.version}: ${error.message}\n`);
202
+
203
+ await this.tracker.markAsFailed(record.version, error.message);
204
+
205
+ throw new Error(`Rollback of ${record.version} failed: ${error.message}`);
206
+ }
124
207
  }
125
- }
126
208
 
127
- return rolledBack;
209
+ return rolledBack;
210
+ } finally {
211
+ if (!dryRun) {
212
+ await this.tracker.releaseLock();
213
+ }
214
+ }
128
215
  }
129
216
 
130
217
  /**
@@ -187,20 +274,6 @@ export class MigrationRunner {
187
274
  * Create migration context
188
275
  */
189
276
  private createContext(): MigrationContext {
190
- // Import DynamoDB commands
191
- const {
192
- ScanCommand,
193
- QueryCommand,
194
- GetCommand,
195
- PutCommand,
196
- UpdateCommand,
197
- DeleteCommand,
198
- BatchGetCommand,
199
- BatchWriteCommand,
200
- TransactWriteCommand,
201
- TransactGetCommand,
202
- } = require('@aws-sdk/lib-dynamodb');
203
-
204
277
  return {
205
278
  client: this.client,
206
279
  tableName: this.config.tableName,