@ftschopp/dynatable-migrations 1.2.6 → 1.3.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.
- package/CHANGELOG.md +14 -0
- package/README.md +12 -0
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/down.d.ts +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +61 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/unlock.d.ts +8 -0
- package/dist/commands/unlock.d.ts.map +1 -0
- package/dist/commands/unlock.js +87 -0
- package/dist/commands/unlock.js.map +1 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +14 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/loader.d.ts +22 -1
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +56 -6
- package/dist/core/loader.js.map +1 -1
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +29 -8
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tracker.d.ts +18 -3
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +60 -23
- package/dist/core/tracker.js.map +1 -1
- package/dist/types/index.d.ts +7 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +35 -4
- package/.gitkeep +0 -0
- package/jest.config.js +0 -8
- package/migrations/.gitkeep +0 -0
- package/src/cli-utils.test.ts +0 -45
- package/src/cli-utils.ts +0 -18
- package/src/cli.ts +0 -110
- package/src/commands/create.ts +0 -111
- package/src/commands/down.ts +0 -26
- package/src/commands/init.ts +0 -37
- package/src/commands/status.ts +0 -75
- package/src/commands/up.ts +0 -26
- package/src/core/client.ts +0 -24
- package/src/core/config.ts +0 -140
- package/src/core/errors.ts +0 -55
- package/src/core/loader.ts +0 -256
- package/src/core/lock-heartbeat.test.ts +0 -78
- package/src/core/lock-heartbeat.ts +0 -48
- package/src/core/runner.test.ts +0 -51
- package/src/core/runner.ts +0 -315
- package/src/core/semver.test.ts +0 -33
- package/src/core/semver.ts +0 -26
- package/src/core/tracker.test.ts +0 -281
- package/src/core/tracker.ts +0 -559
- package/src/index.ts +0 -25
- package/src/templates/migration.ts +0 -92
- package/src/types/index.ts +0 -220
- package/tsconfig.json +0 -19
package/src/core/runner.ts
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
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';
|
|
14
|
-
import { MigrationConfig, MigrationContext, MigrationFile, MigrationStatus } from '../types';
|
|
15
|
-
import { DynamoDBMigrationTracker } from './tracker';
|
|
16
|
-
import { MigrationLoader } from './loader';
|
|
17
|
-
import { compareSemver } from './semver';
|
|
18
|
-
import { startLockHeartbeat } from './lock-heartbeat';
|
|
19
|
-
|
|
20
|
-
export interface RunOptions {
|
|
21
|
-
limit?: number;
|
|
22
|
-
dryRun?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export class MigrationRunner {
|
|
26
|
-
private client: DynamoDBDocumentClient;
|
|
27
|
-
private config: MigrationConfig;
|
|
28
|
-
private tracker: DynamoDBMigrationTracker;
|
|
29
|
-
private loader: MigrationLoader;
|
|
30
|
-
|
|
31
|
-
constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
|
|
32
|
-
this.client = client;
|
|
33
|
-
this.config = config;
|
|
34
|
-
this.tracker = new DynamoDBMigrationTracker(client, config);
|
|
35
|
-
this.loader = new MigrationLoader(config.migrationsDir || './migrations');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Initialize migration system
|
|
40
|
-
*/
|
|
41
|
-
async initialize(): Promise<void> {
|
|
42
|
-
await this.tracker.initialize();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Run all pending migrations
|
|
47
|
-
*/
|
|
48
|
-
async up(options: RunOptions = {}): Promise<MigrationFile[]> {
|
|
49
|
-
const { limit, dryRun = false } = options;
|
|
50
|
-
|
|
51
|
-
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`runner.up({ limit }) requires a positive integer (got ${JSON.stringify(limit)}).`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
await this.initialize();
|
|
58
|
-
|
|
59
|
-
// Acquire lock unless dry run
|
|
60
|
-
let stopHeartbeat: (() => void) | undefined;
|
|
61
|
-
if (!dryRun) {
|
|
62
|
-
const lockAcquired = await this.tracker.acquireLock();
|
|
63
|
-
if (!lockAcquired) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
'Could not acquire migration lock. Another migration may be in progress. ' +
|
|
66
|
-
'If you believe this is an error, wait a few minutes and try again.'
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const appliedMigrations = await this.tracker.getAppliedMigrations();
|
|
74
|
-
const appliedVersions = appliedMigrations
|
|
75
|
-
.filter((m) => m.status === 'applied')
|
|
76
|
-
.map((m) => m.version);
|
|
77
|
-
|
|
78
|
-
// Check for checksum mismatches on applied migrations
|
|
79
|
-
for (const applied of appliedMigrations.filter((m) => m.status === 'applied')) {
|
|
80
|
-
const file = await this.loader.getMigration(applied.version);
|
|
81
|
-
if (file && applied.checksum && file.checksum !== applied.checksum) {
|
|
82
|
-
console.warn(
|
|
83
|
-
`⚠️ Warning: Migration ${applied.version} has been modified since it was applied. ` +
|
|
84
|
-
`Original checksum: ${applied.checksum}, Current: ${file.checksum}`
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
let pendingMigrations = await this.loader.getPendingMigrations(appliedVersions);
|
|
90
|
-
|
|
91
|
-
// Apply limit if specified
|
|
92
|
-
if (limit) {
|
|
93
|
-
pendingMigrations = pendingMigrations.slice(0, limit);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (pendingMigrations.length === 0) {
|
|
97
|
-
console.log('✅ No pending migrations');
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (dryRun) {
|
|
102
|
-
console.log(`\n🔍 DRY RUN - Would apply ${pendingMigrations.length} migration(s):\n`);
|
|
103
|
-
for (const migrationFile of pendingMigrations) {
|
|
104
|
-
console.log(` ${migrationFile.version}: ${migrationFile.name}`);
|
|
105
|
-
if (migrationFile.migration.description) {
|
|
106
|
-
console.log(` ${migrationFile.migration.description}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
console.log('\nNo changes were made.\n');
|
|
110
|
-
return pendingMigrations;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
console.log(`\n📦 Found ${pendingMigrations.length} pending migration(s)\n`);
|
|
114
|
-
|
|
115
|
-
const executed: MigrationFile[] = [];
|
|
116
|
-
|
|
117
|
-
for (const migrationFile of pendingMigrations) {
|
|
118
|
-
try {
|
|
119
|
-
console.log(`⬆️ Applying ${migrationFile.version}: ${migrationFile.name}`);
|
|
120
|
-
|
|
121
|
-
const context = this.createContext();
|
|
122
|
-
await migrationFile.migration.up(context);
|
|
123
|
-
|
|
124
|
-
await this.tracker.markAsApplied(
|
|
125
|
-
migrationFile.version,
|
|
126
|
-
migrationFile.name,
|
|
127
|
-
migrationFile.migration.schema,
|
|
128
|
-
undefined,
|
|
129
|
-
migrationFile.checksum
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
console.log(`✅ Applied ${migrationFile.version}: ${migrationFile.name}\n`);
|
|
133
|
-
executed.push(migrationFile);
|
|
134
|
-
} catch (error: any) {
|
|
135
|
-
console.error(`❌ Failed to apply ${migrationFile.version}: ${error.message}\n`);
|
|
136
|
-
|
|
137
|
-
await this.tracker.markAsFailed(migrationFile.version, error.message);
|
|
138
|
-
|
|
139
|
-
throw new Error(`Migration ${migrationFile.version} failed: ${error.message}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return executed;
|
|
144
|
-
} finally {
|
|
145
|
-
// Always stop the heartbeat and release the lock — in that order, so
|
|
146
|
-
// we don't refresh a lock we're about to delete.
|
|
147
|
-
if (stopHeartbeat) stopHeartbeat();
|
|
148
|
-
if (!dryRun) {
|
|
149
|
-
await this.tracker.releaseLock();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Rollback last migration
|
|
156
|
-
*/
|
|
157
|
-
async down(steps: number = 1, dryRun: boolean = false): Promise<MigrationFile[]> {
|
|
158
|
-
if (!Number.isInteger(steps) || steps <= 0) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`runner.down(steps) requires a positive integer (got ${JSON.stringify(steps)}).`
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
await this.initialize();
|
|
165
|
-
|
|
166
|
-
// Acquire lock unless dry run
|
|
167
|
-
let stopHeartbeat: (() => void) | undefined;
|
|
168
|
-
if (!dryRun) {
|
|
169
|
-
const lockAcquired = await this.tracker.acquireLock();
|
|
170
|
-
if (!lockAcquired) {
|
|
171
|
-
throw new Error('Could not acquire migration lock. Another migration may be in progress.');
|
|
172
|
-
}
|
|
173
|
-
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const appliedMigrations = await this.tracker.getAppliedMigrations();
|
|
178
|
-
const applied = appliedMigrations
|
|
179
|
-
.filter((m) => m.status === 'applied')
|
|
180
|
-
.sort((a, b) => compareSemver(b.version, a.version))
|
|
181
|
-
.slice(0, steps);
|
|
182
|
-
|
|
183
|
-
if (applied.length === 0) {
|
|
184
|
-
console.log('✅ No migrations to rollback');
|
|
185
|
-
return [];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (dryRun) {
|
|
189
|
-
console.log(`\n🔍 DRY RUN - Would rollback ${applied.length} migration(s):\n`);
|
|
190
|
-
for (const record of applied) {
|
|
191
|
-
console.log(` ${record.version}: ${record.name}`);
|
|
192
|
-
}
|
|
193
|
-
console.log('\nNo changes were made.\n');
|
|
194
|
-
return [];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
console.log(`\n📦 Rolling back ${applied.length} migration(s)\n`);
|
|
198
|
-
|
|
199
|
-
const rolledBack: MigrationFile[] = [];
|
|
200
|
-
|
|
201
|
-
for (const record of applied) {
|
|
202
|
-
try {
|
|
203
|
-
const migrationFile = await this.loader.getMigration(record.version);
|
|
204
|
-
|
|
205
|
-
if (!migrationFile) {
|
|
206
|
-
throw new Error(`Migration file not found for version ${record.version}`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
console.log(`⬇️ Rolling back ${migrationFile.version}: ${migrationFile.name}`);
|
|
210
|
-
|
|
211
|
-
const context = this.createContext();
|
|
212
|
-
await migrationFile.migration.down(context);
|
|
213
|
-
|
|
214
|
-
await this.tracker.markAsRolledBack(migrationFile.version);
|
|
215
|
-
|
|
216
|
-
console.log(`✅ Rolled back ${migrationFile.version}: ${migrationFile.name}\n`);
|
|
217
|
-
rolledBack.push(migrationFile);
|
|
218
|
-
} catch (error: any) {
|
|
219
|
-
console.error(`❌ Failed to rollback ${record.version}: ${error.message}\n`);
|
|
220
|
-
|
|
221
|
-
await this.tracker.markAsFailed(record.version, error.message);
|
|
222
|
-
|
|
223
|
-
throw new Error(`Rollback of ${record.version} failed: ${error.message}`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return rolledBack;
|
|
228
|
-
} finally {
|
|
229
|
-
if (stopHeartbeat) stopHeartbeat();
|
|
230
|
-
if (!dryRun) {
|
|
231
|
-
await this.tracker.releaseLock();
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Get migration status
|
|
238
|
-
*/
|
|
239
|
-
async status(): Promise<MigrationStatus[]> {
|
|
240
|
-
await this.initialize();
|
|
241
|
-
|
|
242
|
-
const allMigrations = await this.loader.loadMigrations();
|
|
243
|
-
const appliedMigrations = await this.tracker.getAppliedMigrations();
|
|
244
|
-
|
|
245
|
-
const appliedMap = new Map(appliedMigrations.map((m) => [m.version, m]));
|
|
246
|
-
|
|
247
|
-
return allMigrations.map((migrationFile) => {
|
|
248
|
-
const record = appliedMap.get(migrationFile.version);
|
|
249
|
-
|
|
250
|
-
if (!record) {
|
|
251
|
-
return {
|
|
252
|
-
version: migrationFile.version,
|
|
253
|
-
name: migrationFile.name,
|
|
254
|
-
status: 'pending' as const,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
version: record.version,
|
|
260
|
-
name: record.name,
|
|
261
|
-
status: record.status,
|
|
262
|
-
appliedAt: record.appliedAt,
|
|
263
|
-
error: record.error,
|
|
264
|
-
};
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Get current version
|
|
270
|
-
*/
|
|
271
|
-
async getCurrentVersion(): Promise<string | null> {
|
|
272
|
-
await this.initialize();
|
|
273
|
-
return this.tracker.getCurrentVersion();
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Reset all migrations (rollback everything)
|
|
278
|
-
*/
|
|
279
|
-
async reset(): Promise<void> {
|
|
280
|
-
const appliedMigrations = await this.tracker.getAppliedMigrations();
|
|
281
|
-
const appliedCount = appliedMigrations.filter((m) => m.status === 'applied').length;
|
|
282
|
-
|
|
283
|
-
if (appliedCount === 0) {
|
|
284
|
-
console.log('✅ No migrations to reset');
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
console.log(`\n⚠️ Resetting ${appliedCount} migration(s)\n`);
|
|
289
|
-
await this.down(appliedCount);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Create migration context
|
|
294
|
-
*/
|
|
295
|
-
private createContext(): MigrationContext {
|
|
296
|
-
return {
|
|
297
|
-
client: this.client,
|
|
298
|
-
tableName: this.config.tableName,
|
|
299
|
-
tracker: this.tracker,
|
|
300
|
-
config: this.config,
|
|
301
|
-
dynamodb: {
|
|
302
|
-
ScanCommand,
|
|
303
|
-
QueryCommand,
|
|
304
|
-
GetCommand,
|
|
305
|
-
PutCommand,
|
|
306
|
-
UpdateCommand,
|
|
307
|
-
DeleteCommand,
|
|
308
|
-
BatchGetCommand,
|
|
309
|
-
BatchWriteCommand,
|
|
310
|
-
TransactWriteCommand,
|
|
311
|
-
TransactGetCommand,
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
}
|
package/src/core/semver.test.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { compareSemver } from './semver';
|
|
2
|
-
|
|
3
|
-
describe('compareSemver', () => {
|
|
4
|
-
test('returns -1 when a < b across the minor digit boundary (the original bug)', () => {
|
|
5
|
-
expect(compareSemver('0.9.0', '0.10.0')).toBe(-1);
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
test('returns 1 when a > b across the minor digit boundary', () => {
|
|
9
|
-
expect(compareSemver('0.10.0', '0.9.0')).toBe(1);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('returns -1 when a < b across the patch digit boundary', () => {
|
|
13
|
-
expect(compareSemver('0.1.0', '0.1.10')).toBe(-1);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('handles major beats minor', () => {
|
|
17
|
-
expect(compareSemver('1.0.0', '0.99.99')).toBe(1);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('returns 0 for equal versions', () => {
|
|
21
|
-
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('sorts a list of versions ascending in the correct order', () => {
|
|
25
|
-
const sorted = ['0.10.0', '1.0.0', '0.1.0', '0.9.0'].sort(compareSemver);
|
|
26
|
-
expect(sorted).toEqual(['0.1.0', '0.9.0', '0.10.0', '1.0.0']);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('sorts a list of versions descending when the comparator is inverted', () => {
|
|
30
|
-
const sorted = ['0.10.0', '1.0.0', '0.1.0', '0.9.0'].sort((a, b) => compareSemver(b, a));
|
|
31
|
-
expect(sorted).toEqual(['1.0.0', '0.10.0', '0.9.0', '0.1.0']);
|
|
32
|
-
});
|
|
33
|
-
});
|
package/src/core/semver.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compare two semver versions of the form `MAJOR.MINOR.PATCH`.
|
|
3
|
-
*
|
|
4
|
-
* Returns:
|
|
5
|
-
* -1 if `a < b`
|
|
6
|
-
* 0 if `a === b`
|
|
7
|
-
* 1 if `a > b`
|
|
8
|
-
*
|
|
9
|
-
* Designed for migration filenames (`0.1.0_name.ts`) which the loader
|
|
10
|
-
* already validates as strict three-segment semver — prerelease and build
|
|
11
|
-
* metadata are not handled.
|
|
12
|
-
*/
|
|
13
|
-
export function compareSemver(a: string, b: string): number {
|
|
14
|
-
const partsA = a.split('.').map((n) => parseInt(n, 10));
|
|
15
|
-
const partsB = b.split('.').map((n) => parseInt(n, 10));
|
|
16
|
-
|
|
17
|
-
for (let i = 0; i < 3; i++) {
|
|
18
|
-
const numA = partsA[i] || 0;
|
|
19
|
-
const numB = partsB[i] || 0;
|
|
20
|
-
|
|
21
|
-
if (numA > numB) return 1;
|
|
22
|
-
if (numA < numB) return -1;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return 0;
|
|
26
|
-
}
|
package/src/core/tracker.test.ts
DELETED
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
-
import {
|
|
4
|
-
DynamoDBDocumentClient,
|
|
5
|
-
GetCommand,
|
|
6
|
-
PutCommand,
|
|
7
|
-
UpdateCommand,
|
|
8
|
-
TransactWriteCommand,
|
|
9
|
-
} from '@aws-sdk/lib-dynamodb';
|
|
10
|
-
import { mockClient } from 'aws-sdk-client-mock';
|
|
11
|
-
import { DynamoDBMigrationTracker, DEFAULT_LOCK_TTL_SECONDS } from './tracker';
|
|
12
|
-
import type { MigrationConfig } from '../types';
|
|
13
|
-
|
|
14
|
-
const ddbMock = mockClient(DynamoDBDocumentClient);
|
|
15
|
-
|
|
16
|
-
const baseConfig: MigrationConfig = {
|
|
17
|
-
tableName: 'TestTable',
|
|
18
|
-
client: { region: 'us-east-1' },
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function makeTracker(config: Partial<MigrationConfig> = {}) {
|
|
22
|
-
const client = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'us-east-1' }));
|
|
23
|
-
return new DynamoDBMigrationTracker(client, { ...baseConfig, ...config });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
ddbMock.reset();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('DynamoDBMigrationTracker - lock TTL', () => {
|
|
31
|
-
test('uses the default 5-minute TTL when none is configured', () => {
|
|
32
|
-
const tracker = makeTracker();
|
|
33
|
-
expect(tracker.lockTtlSeconds).toBe(DEFAULT_LOCK_TTL_SECONDS);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('honors a custom lockTtlSeconds from config', () => {
|
|
37
|
-
const tracker = makeTracker({ lockTtlSeconds: 30 });
|
|
38
|
-
expect(tracker.lockTtlSeconds).toBe(30);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('DynamoDBMigrationTracker - acquireLock', () => {
|
|
43
|
-
test('uses <= for the expiry boundary so simultaneous-ms expiries are takeable', async () => {
|
|
44
|
-
ddbMock.on(PutCommand).resolves({});
|
|
45
|
-
const tracker = makeTracker();
|
|
46
|
-
|
|
47
|
-
await tracker.acquireLock();
|
|
48
|
-
|
|
49
|
-
const calls = ddbMock.commandCalls(PutCommand);
|
|
50
|
-
expect(calls).toHaveLength(1);
|
|
51
|
-
const expr = (calls[0]!.args[0].input as any).ConditionExpression as string;
|
|
52
|
-
expect(expr).toContain('expiresAt <= :now');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('returns false when the lock row exists and has not expired', async () => {
|
|
56
|
-
const err = Object.assign(new Error('cond failed'), {
|
|
57
|
-
name: 'ConditionalCheckFailedException',
|
|
58
|
-
});
|
|
59
|
-
ddbMock.on(PutCommand).rejects(err);
|
|
60
|
-
const tracker = makeTracker();
|
|
61
|
-
|
|
62
|
-
await expect(tracker.acquireLock()).resolves.toBe(false);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe('DynamoDBMigrationTracker - refreshLock', () => {
|
|
67
|
-
test('extends the lock with a ConditionExpression on the current lockId', async () => {
|
|
68
|
-
ddbMock.on(PutCommand).resolves({});
|
|
69
|
-
ddbMock.on(UpdateCommand).resolves({});
|
|
70
|
-
|
|
71
|
-
const tracker = makeTracker({ lockTtlSeconds: 60 });
|
|
72
|
-
await tracker.acquireLock();
|
|
73
|
-
await tracker.refreshLock();
|
|
74
|
-
|
|
75
|
-
const updates = ddbMock.commandCalls(UpdateCommand);
|
|
76
|
-
expect(updates).toHaveLength(1);
|
|
77
|
-
const input = updates[0]!.args[0].input as any;
|
|
78
|
-
expect(input.ConditionExpression).toBe('lockId = :lockId');
|
|
79
|
-
expect(typeof input.ExpressionAttributeValues[':lockId']).toBe('string');
|
|
80
|
-
expect(typeof input.ExpressionAttributeValues[':exp']).toBe('number');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test('throws when the lockId no longer matches (lost race)', async () => {
|
|
84
|
-
ddbMock.on(PutCommand).resolves({});
|
|
85
|
-
const err = Object.assign(new Error('cond failed'), {
|
|
86
|
-
name: 'ConditionalCheckFailedException',
|
|
87
|
-
});
|
|
88
|
-
ddbMock.on(UpdateCommand).rejects(err);
|
|
89
|
-
|
|
90
|
-
const tracker = makeTracker();
|
|
91
|
-
await tracker.acquireLock();
|
|
92
|
-
|
|
93
|
-
await expect(tracker.refreshLock()).rejects.toMatchObject({
|
|
94
|
-
name: 'ConditionalCheckFailedException',
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('is a silent no-op when no lock has been acquired yet', async () => {
|
|
99
|
-
const tracker = makeTracker();
|
|
100
|
-
await expect(tracker.refreshLock()).resolves.toBeUndefined();
|
|
101
|
-
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('DynamoDBMigrationTracker - tracker writes are gated by lock ownership', () => {
|
|
106
|
-
test('markAsApplied (new record path) emits a ConditionCheck on the lock row', async () => {
|
|
107
|
-
ddbMock.on(PutCommand).resolves({}); // acquireLock
|
|
108
|
-
ddbMock.on(TransactWriteCommand).resolves({});
|
|
109
|
-
// getMigration returns no existing record → take the Put branch
|
|
110
|
-
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
111
|
-
|
|
112
|
-
const tracker = makeTracker();
|
|
113
|
-
await tracker.acquireLock();
|
|
114
|
-
await tracker.markAsApplied('0.1.0', 'init');
|
|
115
|
-
|
|
116
|
-
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
117
|
-
expect(tx).toHaveLength(1);
|
|
118
|
-
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
119
|
-
// First item should be the lock-row ConditionCheck
|
|
120
|
-
expect(items[0].ConditionCheck).toBeDefined();
|
|
121
|
-
expect(items[0].ConditionCheck.ConditionExpression).toBe('lockId = :lockId');
|
|
122
|
-
expect(items[0].ConditionCheck.Key).toEqual({
|
|
123
|
-
PK: '_SCHEMA#VERSION#LOCK',
|
|
124
|
-
SK: '_SCHEMA#VERSION#LOCK',
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('markAsApplied throws when no lock has been acquired', async () => {
|
|
129
|
-
const tracker = makeTracker();
|
|
130
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toThrow(
|
|
131
|
-
/Tracker has no active lock/i
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('markAsApplied (new record path) uses attribute_not_exists(SK), not (PK)', async () => {
|
|
136
|
-
ddbMock.on(PutCommand).resolves({});
|
|
137
|
-
ddbMock.on(TransactWriteCommand).resolves({});
|
|
138
|
-
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
139
|
-
|
|
140
|
-
const tracker = makeTracker();
|
|
141
|
-
await tracker.acquireLock();
|
|
142
|
-
await tracker.markAsApplied('0.1.0', 'init');
|
|
143
|
-
|
|
144
|
-
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
145
|
-
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
146
|
-
// The Put is the second item (index 1) after the ConditionCheck
|
|
147
|
-
const put = items.find((i) => i.Put);
|
|
148
|
-
expect(put.Put.ConditionExpression).toBe('attribute_not_exists(SK)');
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('DynamoDBMigrationTracker - markAsApplied idempotency (#10)', () => {
|
|
153
|
-
function cancelledTransactError(reasons: Array<{ Code?: string }>) {
|
|
154
|
-
return Object.assign(new Error('Transaction cancelled'), {
|
|
155
|
-
name: 'TransactionCanceledException',
|
|
156
|
-
CancellationReasons: reasons,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
test('returns silently when the cancellation is because the row is already in status=applied', async () => {
|
|
161
|
-
ddbMock.on(PutCommand).resolves({}); // acquireLock
|
|
162
|
-
// First getMigration → no record (so we take the Put branch)
|
|
163
|
-
// Second getMigration (after cancellation) → already applied
|
|
164
|
-
ddbMock
|
|
165
|
-
.on(GetCommand)
|
|
166
|
-
.resolvesOnce({ Item: undefined })
|
|
167
|
-
.resolvesOnce({
|
|
168
|
-
Item: {
|
|
169
|
-
PK: '_SCHEMA#VERSION',
|
|
170
|
-
SK: '0.1.0',
|
|
171
|
-
version: '0.1.0',
|
|
172
|
-
status: 'applied',
|
|
173
|
-
name: 'init',
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// Migration row condition fails (reasons[1]); lock check passed (reasons[0] = None)
|
|
178
|
-
ddbMock.on(TransactWriteCommand).rejects(
|
|
179
|
-
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const tracker = makeTracker();
|
|
183
|
-
await tracker.acquireLock();
|
|
184
|
-
|
|
185
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).resolves.toBeUndefined();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('throws MigrationAlreadyAppliedError when the row exists in a non-applied state', async () => {
|
|
189
|
-
ddbMock.on(PutCommand).resolves({});
|
|
190
|
-
ddbMock
|
|
191
|
-
.on(GetCommand)
|
|
192
|
-
.resolvesOnce({ Item: undefined })
|
|
193
|
-
.resolvesOnce({
|
|
194
|
-
Item: {
|
|
195
|
-
PK: '_SCHEMA#VERSION',
|
|
196
|
-
SK: '0.1.0',
|
|
197
|
-
version: '0.1.0',
|
|
198
|
-
status: 'failed',
|
|
199
|
-
name: 'init',
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
ddbMock.on(TransactWriteCommand).rejects(
|
|
204
|
-
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const tracker = makeTracker();
|
|
208
|
-
await tracker.acquireLock();
|
|
209
|
-
|
|
210
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toMatchObject({
|
|
211
|
-
name: 'MigrationAlreadyAppliedError',
|
|
212
|
-
version: '0.1.0',
|
|
213
|
-
currentStatus: 'failed',
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test('throws MigrationLockLostError when the lock-row ConditionCheck is what failed', async () => {
|
|
218
|
-
ddbMock.on(PutCommand).resolves({});
|
|
219
|
-
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
220
|
-
|
|
221
|
-
ddbMock.on(TransactWriteCommand).rejects(
|
|
222
|
-
cancelledTransactError([{ Code: 'ConditionalCheckFailed' }, { Code: 'None' }])
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
const tracker = makeTracker();
|
|
226
|
-
await tracker.acquireLock();
|
|
227
|
-
|
|
228
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toMatchObject({
|
|
229
|
-
name: 'MigrationLockLostError',
|
|
230
|
-
version: '0.1.0',
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('re-throws unexpected errors unchanged', async () => {
|
|
235
|
-
ddbMock.on(PutCommand).resolves({});
|
|
236
|
-
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
237
|
-
|
|
238
|
-
const boom = Object.assign(new Error('upstream blew up'), { name: 'InternalServerError' });
|
|
239
|
-
ddbMock.on(TransactWriteCommand).rejects(boom);
|
|
240
|
-
|
|
241
|
-
const tracker = makeTracker();
|
|
242
|
-
await tracker.acquireLock();
|
|
243
|
-
|
|
244
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toBe(boom);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test('idempotent path also covers the Update branch (existing record was rolled_back, applied by a racing worker)', async () => {
|
|
248
|
-
ddbMock.on(PutCommand).resolves({});
|
|
249
|
-
// First getMigration → existing rolled_back record (so we take the Update branch)
|
|
250
|
-
// Second getMigration (after cancellation) → already applied (race)
|
|
251
|
-
ddbMock
|
|
252
|
-
.on(GetCommand)
|
|
253
|
-
.resolvesOnce({
|
|
254
|
-
Item: {
|
|
255
|
-
PK: '_SCHEMA#VERSION',
|
|
256
|
-
SK: '0.1.0',
|
|
257
|
-
version: '0.1.0',
|
|
258
|
-
status: 'rolled_back',
|
|
259
|
-
name: 'init',
|
|
260
|
-
},
|
|
261
|
-
})
|
|
262
|
-
.resolvesOnce({
|
|
263
|
-
Item: {
|
|
264
|
-
PK: '_SCHEMA#VERSION',
|
|
265
|
-
SK: '0.1.0',
|
|
266
|
-
version: '0.1.0',
|
|
267
|
-
status: 'applied',
|
|
268
|
-
name: 'init',
|
|
269
|
-
},
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
ddbMock.on(TransactWriteCommand).rejects(
|
|
273
|
-
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const tracker = makeTracker();
|
|
277
|
-
await tracker.acquireLock();
|
|
278
|
-
|
|
279
|
-
await expect(tracker.markAsApplied('0.1.0', 'init')).resolves.toBeUndefined();
|
|
280
|
-
});
|
|
281
|
-
});
|