@ftschopp/dynatable-migrations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.gitkeep +0 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +421 -0
  4. package/USAGE.md +557 -0
  5. package/bin/dynatable-migrate.js +30 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +104 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/create.d.ts +2 -0
  11. package/dist/commands/create.d.ts.map +1 -0
  12. package/dist/commands/create.js +123 -0
  13. package/dist/commands/create.js.map +1 -0
  14. package/dist/commands/down.d.ts +3 -0
  15. package/dist/commands/down.d.ts.map +1 -0
  16. package/dist/commands/down.js +37 -0
  17. package/dist/commands/down.js.map +1 -0
  18. package/dist/commands/init.d.ts +2 -0
  19. package/dist/commands/init.d.ts.map +1 -0
  20. package/dist/commands/init.js +70 -0
  21. package/dist/commands/init.js.map +1 -0
  22. package/dist/commands/status.d.ts +3 -0
  23. package/dist/commands/status.d.ts.map +1 -0
  24. package/dist/commands/status.js +84 -0
  25. package/dist/commands/status.js.map +1 -0
  26. package/dist/commands/up.d.ts +3 -0
  27. package/dist/commands/up.d.ts.map +1 -0
  28. package/dist/commands/up.js +37 -0
  29. package/dist/commands/up.js.map +1 -0
  30. package/dist/core/config.d.ts +29 -0
  31. package/dist/core/config.d.ts.map +1 -0
  32. package/dist/core/config.js +160 -0
  33. package/dist/core/config.js.map +1 -0
  34. package/dist/core/loader.d.ts +47 -0
  35. package/dist/core/loader.d.ts.map +1 -0
  36. package/dist/core/loader.js +226 -0
  37. package/dist/core/loader.js.map +1 -0
  38. package/dist/core/runner.d.ts +38 -0
  39. package/dist/core/runner.d.ts.map +1 -0
  40. package/dist/core/runner.js +166 -0
  41. package/dist/core/runner.js.map +1 -0
  42. package/dist/core/tracker.d.ts +19 -0
  43. package/dist/core/tracker.d.ts.map +1 -0
  44. package/dist/core/tracker.js +172 -0
  45. package/dist/core/tracker.js.map +1 -0
  46. package/dist/index.d.ts +16 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +48 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/templates/migration.d.ts +5 -0
  51. package/dist/templates/migration.d.ts.map +1 -0
  52. package/dist/templates/migration.js +96 -0
  53. package/dist/templates/migration.js.map +1 -0
  54. package/dist/types/index.d.ts +159 -0
  55. package/dist/types/index.d.ts.map +1 -0
  56. package/dist/types/index.js +3 -0
  57. package/dist/types/index.js.map +1 -0
  58. package/migrations/.gitkeep +0 -0
  59. package/package.json +43 -0
  60. package/src/cli.ts +106 -0
  61. package/src/commands/create.ts +110 -0
  62. package/src/commands/down.ts +42 -0
  63. package/src/commands/init.ts +37 -0
  64. package/src/commands/status.ts +92 -0
  65. package/src/commands/up.ts +39 -0
  66. package/src/core/config.ts +140 -0
  67. package/src/core/loader.ts +233 -0
  68. package/src/core/runner.ts +223 -0
  69. package/src/core/tracker.ts +219 -0
  70. package/src/index.ts +23 -0
  71. package/src/templates/migration.ts +92 -0
  72. package/src/types/index.ts +196 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,233 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { Migration, MigrationFile } from '../types';
4
+
5
+ /**
6
+ * Load all migration files from directory
7
+ */
8
+ export class MigrationLoader {
9
+ private migrationsDir: string;
10
+
11
+ constructor(migrationsDir: string) {
12
+ this.migrationsDir = migrationsDir;
13
+ }
14
+
15
+ /**
16
+ * Load all migration files
17
+ */
18
+ async loadMigrations(): Promise<MigrationFile[]> {
19
+ if (!fs.existsSync(this.migrationsDir)) {
20
+ throw new Error(`Migrations directory not found: ${this.migrationsDir}`);
21
+ }
22
+
23
+ const files = fs
24
+ .readdirSync(this.migrationsDir)
25
+ .filter(
26
+ (f) =>
27
+ (f.endsWith('.ts') || f.endsWith('.js')) &&
28
+ !f.endsWith('.d.ts') &&
29
+ this.isValidMigrationFileName(f)
30
+ )
31
+ .sort((a, b) => {
32
+ // Sort by semver
33
+ const versionA = a.match(/^(\d+\.\d+\.\d+)/)?.[1] || '0.0.0';
34
+ const versionB = b.match(/^(\d+\.\d+\.\d+)/)?.[1] || '0.0.0';
35
+ return this.compareSemver(versionA, versionB);
36
+ });
37
+
38
+ const migrations: MigrationFile[] = [];
39
+
40
+ for (const file of files) {
41
+ const filePath = path.join(this.migrationsDir, file);
42
+ const migration = await this.loadMigration(filePath);
43
+
44
+ if (migration) {
45
+ migrations.push(migration);
46
+ }
47
+ }
48
+
49
+ return migrations;
50
+ }
51
+
52
+ /**
53
+ * Load single migration file
54
+ */
55
+ 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];
84
+
85
+ module = require(absolutePath);
86
+ } else {
87
+ // For JavaScript files, use dynamic import
88
+ const fileUrl = `file://${absolutePath}`;
89
+ module = await import(fileUrl);
90
+ }
91
+
92
+ const migration: Migration = module.migration || module.default;
93
+
94
+ if (!migration) {
95
+ console.warn(`Warning: No migration export found in ${filePath}`);
96
+ return null;
97
+ }
98
+
99
+ // Validate migration structure
100
+ this.validateMigration(migration, filePath);
101
+
102
+ const fileName = path.basename(filePath);
103
+ const { version, name } = this.parseMigrationFileName(fileName);
104
+
105
+ return {
106
+ version,
107
+ name,
108
+ filePath,
109
+ migration,
110
+ };
111
+ } catch (error: any) {
112
+ throw new Error(`Failed to load migration ${filePath}: ${error.message}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Validate migration file name format
118
+ * Expected: 1.0.0_migration_name.ts (semver format)
119
+ */
120
+ private isValidMigrationFileName(fileName: string): boolean {
121
+ // Match semver: 1.0.0_name.ts or 1.2.3_name.ts
122
+ return /^\d+\.\d+\.\d+_[\w-]+\.(ts|js)$/.test(fileName);
123
+ }
124
+
125
+ /**
126
+ * Parse version and name from file name
127
+ */
128
+ private parseMigrationFileName(fileName: string): {
129
+ version: string;
130
+ name: string;
131
+ } {
132
+ const match = fileName.match(/^(\d+\.\d+\.\d+)_([\w-]+)\.(ts|js)$/);
133
+
134
+ if (!match || !match[1] || !match[2]) {
135
+ throw new Error(
136
+ `Invalid migration file name format: ${fileName}. Expected: X.Y.Z_name.ts (semver)`
137
+ );
138
+ }
139
+
140
+ const version = match[1];
141
+ const name = match[2];
142
+
143
+ return { version, name };
144
+ }
145
+
146
+ /**
147
+ * Validate migration structure
148
+ */
149
+ private validateMigration(migration: Migration, filePath: string): void {
150
+ if (!migration.version) {
151
+ throw new Error(`Migration ${filePath} is missing 'version' property`);
152
+ }
153
+
154
+ if (!migration.name) {
155
+ throw new Error(`Migration ${filePath} is missing 'name' property`);
156
+ }
157
+
158
+ if (typeof migration.up !== 'function') {
159
+ throw new Error(`Migration ${filePath} is missing 'up' function or it's not a function`);
160
+ }
161
+
162
+ if (typeof migration.down !== 'function') {
163
+ throw new Error(`Migration ${filePath} is missing 'down' function or it's not a function`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Get pending migrations (not yet applied)
169
+ */
170
+ async getPendingMigrations(appliedVersions: string[]): Promise<MigrationFile[]> {
171
+ const allMigrations = await this.loadMigrations();
172
+ return allMigrations.filter((m) => !appliedVersions.includes(m.version));
173
+ }
174
+
175
+ /**
176
+ * Get migration by version
177
+ */
178
+ async getMigration(version: string): Promise<MigrationFile | null> {
179
+ const migrations = await this.loadMigrations();
180
+ return migrations.find((m) => m.version === version) || null;
181
+ }
182
+
183
+ /**
184
+ * Generate next version number (increments patch by default)
185
+ */
186
+ async getNextVersion(): Promise<string> {
187
+ const migrations = await this.loadMigrations();
188
+
189
+ if (migrations.length === 0) {
190
+ return '0.1.0';
191
+ }
192
+
193
+ // Get last version
194
+ const lastMigration = migrations[migrations.length - 1];
195
+ if (!lastMigration) {
196
+ return '0.1.0';
197
+ }
198
+
199
+ const lastVersion = lastMigration.version;
200
+
201
+ // Parse semver and increment patch
202
+ const parts = lastVersion.split('.');
203
+ if (parts.length === 3 && parts[0] && parts[1] && parts[2]) {
204
+ const major = parseInt(parts[0], 10);
205
+ const minor = parseInt(parts[1], 10);
206
+ const patch = parseInt(parts[2], 10);
207
+
208
+ // Increment patch version
209
+ return `${major}.${minor}.${patch + 1}`;
210
+ }
211
+
212
+ return '0.1.0';
213
+ }
214
+
215
+ /**
216
+ * Compare semver versions
217
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
218
+ */
219
+ private compareSemver(a: string, b: string): number {
220
+ const partsA = a.split('.').map((n) => parseInt(n, 10));
221
+ const partsB = b.split('.').map((n) => parseInt(n, 10));
222
+
223
+ for (let i = 0; i < 3; i++) {
224
+ const numA = partsA[i] || 0;
225
+ const numB = partsB[i] || 0;
226
+
227
+ if (numA > numB) return 1;
228
+ if (numA < numB) return -1;
229
+ }
230
+
231
+ return 0;
232
+ }
233
+ }
@@ -0,0 +1,223 @@
1
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
+ import { MigrationConfig, MigrationContext, MigrationFile, MigrationStatus } from '../types';
3
+ import { DynamoDBMigrationTracker } from './tracker';
4
+ import { MigrationLoader } from './loader';
5
+
6
+ export class MigrationRunner {
7
+ private client: DynamoDBDocumentClient;
8
+ private config: MigrationConfig;
9
+ private tracker: DynamoDBMigrationTracker;
10
+ private loader: MigrationLoader;
11
+
12
+ constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
13
+ this.client = client;
14
+ this.config = config;
15
+ this.tracker = new DynamoDBMigrationTracker(client, config);
16
+ this.loader = new MigrationLoader(config.migrationsDir || './migrations');
17
+ }
18
+
19
+ /**
20
+ * Initialize migration system
21
+ */
22
+ async initialize(): Promise<void> {
23
+ await this.tracker.initialize();
24
+ }
25
+
26
+ /**
27
+ * Run all pending migrations
28
+ */
29
+ async up(limit?: number): Promise<MigrationFile[]> {
30
+ await this.initialize();
31
+
32
+ const appliedMigrations = await this.tracker.getAppliedMigrations();
33
+ const appliedVersions = appliedMigrations
34
+ .filter((m) => m.status === 'applied')
35
+ .map((m) => m.version);
36
+
37
+ let pendingMigrations = await this.loader.getPendingMigrations(appliedVersions);
38
+
39
+ // Apply limit if specified
40
+ if (limit) {
41
+ pendingMigrations = pendingMigrations.slice(0, limit);
42
+ }
43
+
44
+ if (pendingMigrations.length === 0) {
45
+ console.log('✅ No pending migrations');
46
+ return [];
47
+ }
48
+
49
+ console.log(`\n📦 Found ${pendingMigrations.length} pending migration(s)\n`);
50
+
51
+ const executed: MigrationFile[] = [];
52
+
53
+ for (const migrationFile of pendingMigrations) {
54
+ try {
55
+ console.log(`⬆️ Applying ${migrationFile.version}: ${migrationFile.name}`);
56
+
57
+ const context = this.createContext();
58
+ await migrationFile.migration.up(context);
59
+
60
+ await this.tracker.markAsApplied(
61
+ migrationFile.version,
62
+ migrationFile.name,
63
+ migrationFile.migration.schema
64
+ );
65
+
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`);
70
+
71
+ await this.tracker.markAsFailed(migrationFile.version, error.message);
72
+
73
+ throw new Error(`Migration ${migrationFile.version} failed: ${error.message}`);
74
+ }
75
+ }
76
+
77
+ return executed;
78
+ }
79
+
80
+ /**
81
+ * Rollback last migration
82
+ */
83
+ async down(steps: number = 1): Promise<MigrationFile[]> {
84
+ await this.initialize();
85
+
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 [];
95
+ }
96
+
97
+ console.log(`\n📦 Rolling back ${applied.length} migration(s)\n`);
98
+
99
+ const rolledBack: MigrationFile[] = [];
100
+
101
+ for (const record of applied) {
102
+ try {
103
+ const migrationFile = await this.loader.getMigration(record.version);
104
+
105
+ if (!migrationFile) {
106
+ throw new Error(`Migration file not found for version ${record.version}`);
107
+ }
108
+
109
+ console.log(`⬇️ Rolling back ${migrationFile.version}: ${migrationFile.name}`);
110
+
111
+ const context = this.createContext();
112
+ await migrationFile.migration.down(context);
113
+
114
+ await this.tracker.markAsRolledBack(migrationFile.version);
115
+
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`);
120
+
121
+ await this.tracker.markAsFailed(record.version, error.message);
122
+
123
+ throw new Error(`Rollback of ${record.version} failed: ${error.message}`);
124
+ }
125
+ }
126
+
127
+ return rolledBack;
128
+ }
129
+
130
+ /**
131
+ * Get migration status
132
+ */
133
+ async status(): Promise<MigrationStatus[]> {
134
+ await this.initialize();
135
+
136
+ const allMigrations = await this.loader.loadMigrations();
137
+ const appliedMigrations = await this.tracker.getAppliedMigrations();
138
+
139
+ const appliedMap = new Map(appliedMigrations.map((m) => [m.version, m]));
140
+
141
+ return allMigrations.map((migrationFile) => {
142
+ const record = appliedMap.get(migrationFile.version);
143
+
144
+ if (!record) {
145
+ return {
146
+ version: migrationFile.version,
147
+ name: migrationFile.name,
148
+ status: 'pending' as const,
149
+ };
150
+ }
151
+
152
+ return {
153
+ version: record.version,
154
+ name: record.name,
155
+ status: record.status,
156
+ appliedAt: record.appliedAt,
157
+ error: record.error,
158
+ };
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Get current version
164
+ */
165
+ async getCurrentVersion(): Promise<string | null> {
166
+ await this.initialize();
167
+ return this.tracker.getCurrentVersion();
168
+ }
169
+
170
+ /**
171
+ * Reset all migrations (rollback everything)
172
+ */
173
+ async reset(): Promise<void> {
174
+ const appliedMigrations = await this.tracker.getAppliedMigrations();
175
+ const appliedCount = appliedMigrations.filter((m) => m.status === 'applied').length;
176
+
177
+ if (appliedCount === 0) {
178
+ console.log('✅ No migrations to reset');
179
+ return;
180
+ }
181
+
182
+ console.log(`\n⚠️ Resetting ${appliedCount} migration(s)\n`);
183
+ await this.down(appliedCount);
184
+ }
185
+
186
+ /**
187
+ * Create migration context
188
+ */
189
+ 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
+ return {
205
+ client: this.client,
206
+ tableName: this.config.tableName,
207
+ tracker: this.tracker,
208
+ config: this.config,
209
+ dynamodb: {
210
+ ScanCommand,
211
+ QueryCommand,
212
+ GetCommand,
213
+ PutCommand,
214
+ UpdateCommand,
215
+ DeleteCommand,
216
+ BatchGetCommand,
217
+ BatchWriteCommand,
218
+ TransactWriteCommand,
219
+ TransactGetCommand,
220
+ },
221
+ };
222
+ }
223
+ }
@@ -0,0 +1,219 @@
1
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
+ import { PutCommand, GetCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
3
+ import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
4
+
5
+ export class DynamoDBMigrationTracker implements MigrationTracker {
6
+ private client: DynamoDBDocumentClient;
7
+ private tableName: string;
8
+ private trackingPrefix: string;
9
+ private gsi1Name: string;
10
+
11
+ constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
12
+ this.client = client;
13
+ this.tableName = config.tableName;
14
+ this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
15
+ this.gsi1Name = config.gsi1Name || 'GSI1';
16
+ }
17
+
18
+ async initialize(): Promise<void> {
19
+ // Check if current version pointer exists, if not create it
20
+ const current = await this.getCurrentVersion();
21
+ if (!current) {
22
+ // Create initial version pointer
23
+ await this.client.send(
24
+ new PutCommand({
25
+ TableName: this.tableName,
26
+ Item: {
27
+ PK: `${this.trackingPrefix}#CURRENT`,
28
+ SK: `${this.trackingPrefix}#CURRENT`,
29
+ GSI1PK: 'SCHEMA#CURRENT',
30
+ GSI1SK: 'v0000',
31
+ currentVersion: 'v0000',
32
+ updatedAt: new Date().toISOString(),
33
+ },
34
+ })
35
+ );
36
+ }
37
+ }
38
+
39
+ 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
+ );
49
+
50
+ return (result.Items || []) as MigrationRecord[];
51
+ }
52
+
53
+ async getCurrentVersion(): Promise<string | null> {
54
+ const result = await this.client.send(
55
+ new GetCommand({
56
+ TableName: this.tableName,
57
+ Key: {
58
+ PK: `${this.trackingPrefix}#CURRENT`,
59
+ SK: `${this.trackingPrefix}#CURRENT`,
60
+ },
61
+ })
62
+ );
63
+
64
+ return result.Item?.currentVersion || null;
65
+ }
66
+
67
+ async markAsApplied(
68
+ version: string,
69
+ name: string,
70
+ schemaDefinition?: Record<string, any>,
71
+ schemaChanges?: SchemaChange[]
72
+ ): Promise<void> {
73
+ const now = new Date().toISOString();
74
+
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
+ );
92
+
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
+ );
109
+ }
110
+
111
+ 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
+ );
129
+
130
+ // Find previous version and update current pointer
131
+ const migrations = await this.getAppliedMigrations();
132
+ const appliedMigrations = migrations
133
+ .filter((m) => m.status === 'applied' && m.version !== version)
134
+ .sort((a, b) => b.version.localeCompare(a.version));
135
+
136
+ const previousVersion = appliedMigrations[0]?.version || 'v0000';
137
+
138
+ 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
+ },
151
+ })
152
+ );
153
+ }
154
+
155
+ 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
+ );
175
+ }
176
+
177
+ async recordSchemaChange(change: SchemaChange): Promise<void> {
178
+ const currentVersion = await this.getCurrentVersion();
179
+ if (!currentVersion || currentVersion === 'v0000') {
180
+ throw new Error('Cannot record schema change: No migration has been applied yet');
181
+ }
182
+
183
+ // Append to schemaChanges array
184
+ await this.client.send(
185
+ new UpdateCommand({
186
+ TableName: this.tableName,
187
+ Key: {
188
+ PK: this.trackingPrefix,
189
+ SK: currentVersion,
190
+ },
191
+ UpdateExpression:
192
+ 'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
193
+ ExpressionAttributeValues: {
194
+ ':empty_list': [],
195
+ ':change': [change],
196
+ },
197
+ })
198
+ );
199
+ }
200
+
201
+ async getMigration(version: string): Promise<MigrationRecord | null> {
202
+ const result = await this.client.send(
203
+ new GetCommand({
204
+ TableName: this.tableName,
205
+ Key: {
206
+ PK: this.trackingPrefix,
207
+ SK: version,
208
+ },
209
+ })
210
+ );
211
+
212
+ return (result.Item as MigrationRecord) || null;
213
+ }
214
+
215
+ async isApplied(version: string): Promise<boolean> {
216
+ const migration = await this.getMigration(version);
217
+ return migration?.status === 'applied';
218
+ }
219
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @ftschopp/dynatable-migrations
3
+ * DynamoDB migration tool for single table design with schema versioning
4
+ */
5
+
6
+ // Types
7
+ export * from './types';
8
+
9
+ // Core
10
+ export { DynamoDBMigrationTracker } from './core/tracker';
11
+ export { MigrationLoader } from './core/loader';
12
+ export { MigrationRunner } from './core/runner';
13
+ export { ConfigLoader, loadConfig } from './core/config';
14
+
15
+ // Template
16
+ export { generateMigrationTemplate } from './templates/migration';
17
+
18
+ // Commands (for programmatic use)
19
+ export { createMigration } from './commands/create';
20
+ export { runMigrations } from './commands/up';
21
+ export { rollbackMigrations } from './commands/down';
22
+ export { showStatus } from './commands/status';
23
+ export { initProject } from './commands/init';