@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.
- package/.gitkeep +0 -0
- package/CHANGELOG.md +13 -0
- package/README.md +421 -0
- package/USAGE.md +557 -0
- package/bin/dynatable-migrate.js +30 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +104 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/create.d.ts +2 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +123 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/down.d.ts +3 -0
- package/dist/commands/down.d.ts.map +1 -0
- package/dist/commands/down.js +37 -0
- package/dist/commands/down.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +70 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +84 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/up.d.ts +3 -0
- package/dist/commands/up.d.ts.map +1 -0
- package/dist/commands/up.js +37 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/core/config.d.ts +29 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +160 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/loader.d.ts +47 -0
- package/dist/core/loader.d.ts.map +1 -0
- package/dist/core/loader.js +226 -0
- package/dist/core/loader.js.map +1 -0
- package/dist/core/runner.d.ts +38 -0
- package/dist/core/runner.d.ts.map +1 -0
- package/dist/core/runner.js +166 -0
- package/dist/core/runner.js.map +1 -0
- package/dist/core/tracker.d.ts +19 -0
- package/dist/core/tracker.d.ts.map +1 -0
- package/dist/core/tracker.js +172 -0
- package/dist/core/tracker.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/templates/migration.d.ts +5 -0
- package/dist/templates/migration.d.ts.map +1 -0
- package/dist/templates/migration.js +96 -0
- package/dist/templates/migration.js.map +1 -0
- package/dist/types/index.d.ts +159 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/migrations/.gitkeep +0 -0
- package/package.json +43 -0
- package/src/cli.ts +106 -0
- package/src/commands/create.ts +110 -0
- package/src/commands/down.ts +42 -0
- package/src/commands/init.ts +37 -0
- package/src/commands/status.ts +92 -0
- package/src/commands/up.ts +39 -0
- package/src/core/config.ts +140 -0
- package/src/core/loader.ts +233 -0
- package/src/core/runner.ts +223 -0
- package/src/core/tracker.ts +219 -0
- package/src/index.ts +23 -0
- package/src/templates/migration.ts +92 -0
- package/src/types/index.ts +196 -0
- 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';
|