@ftschopp/dynatable-migrations 1.2.3 → 1.2.5
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/dist/core/errors.d.ts +25 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/lock-heartbeat.d.ts +15 -0
- package/dist/core/lock-heartbeat.d.ts.map +1 -0
- package/dist/core/lock-heartbeat.js +44 -0
- package/dist/core/lock-heartbeat.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +11 -1
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tracker.d.ts +30 -0
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +262 -148
- package/dist/core/tracker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/core/errors.ts +55 -0
- package/src/core/lock-heartbeat.test.ts +78 -0
- package/src/core/lock-heartbeat.ts +48 -0
- package/src/core/runner.ts +9 -1
- package/src/core/tracker.test.ts +281 -0
- package/src/core/tracker.ts +286 -158
- package/src/index.ts +1 -0
- package/src/types/index.ts +12 -0
package/src/core/tracker.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
} from '@aws-sdk/lib-dynamodb';
|
|
10
10
|
import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
|
|
11
11
|
import { compareSemver } from './semver';
|
|
12
|
+
import { MigrationAlreadyAppliedError, MigrationLockLostError } from './errors';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
/** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
|
|
15
|
+
export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
|
|
14
16
|
|
|
15
17
|
export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
16
18
|
private client: DynamoDBDocumentClient;
|
|
@@ -18,12 +20,47 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
18
20
|
private trackingPrefix: string;
|
|
19
21
|
private gsi1Name: string;
|
|
20
22
|
private lockId: string | null = null;
|
|
23
|
+
/** TTL for newly-acquired or refreshed locks. */
|
|
24
|
+
public readonly lockTtlSeconds: number;
|
|
21
25
|
|
|
22
26
|
constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
|
|
23
27
|
this.client = client;
|
|
24
28
|
this.tableName = config.tableName;
|
|
25
29
|
this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
|
|
26
30
|
this.gsi1Name = config.gsi1Name || 'GSI1';
|
|
31
|
+
this.lockTtlSeconds = config.lockTtlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private get lockKey() {
|
|
35
|
+
return {
|
|
36
|
+
PK: `${this.trackingPrefix}#LOCK`,
|
|
37
|
+
SK: `${this.trackingPrefix}#LOCK`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Throws if there is no active lock. Call before any tracker write. */
|
|
42
|
+
private requireLock(): void {
|
|
43
|
+
if (!this.lockId) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Tracker has no active lock; refusing to issue tracker mutations. ' +
|
|
46
|
+
'Call acquireLock() first.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
|
|
52
|
+
private lockOwnershipCheck() {
|
|
53
|
+
this.requireLock();
|
|
54
|
+
return {
|
|
55
|
+
ConditionCheck: {
|
|
56
|
+
TableName: this.tableName,
|
|
57
|
+
Key: this.lockKey,
|
|
58
|
+
ConditionExpression: 'lockId = :lockId',
|
|
59
|
+
ExpressionAttributeValues: {
|
|
60
|
+
':lockId': this.lockId,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
27
64
|
}
|
|
28
65
|
|
|
29
66
|
async initialize(): Promise<void> {
|
|
@@ -51,13 +88,8 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
51
88
|
* Acquire a distributed lock to prevent concurrent migrations
|
|
52
89
|
*/
|
|
53
90
|
async acquireLock(): Promise<boolean> {
|
|
54
|
-
const lockKey = {
|
|
55
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
56
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
91
|
const now = Date.now();
|
|
60
|
-
const expiresAt = now +
|
|
92
|
+
const expiresAt = now + this.lockTtlSeconds * 1000;
|
|
61
93
|
this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
|
|
62
94
|
|
|
63
95
|
try {
|
|
@@ -65,13 +97,14 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
65
97
|
new PutCommand({
|
|
66
98
|
TableName: this.tableName,
|
|
67
99
|
Item: {
|
|
68
|
-
...lockKey,
|
|
100
|
+
...this.lockKey,
|
|
69
101
|
lockId: this.lockId,
|
|
70
102
|
acquiredAt: new Date().toISOString(),
|
|
71
103
|
expiresAt,
|
|
72
104
|
},
|
|
73
|
-
// Only succeed if lock doesn't exist or has expired
|
|
74
|
-
|
|
105
|
+
// Only succeed if lock doesn't exist or has expired. The `<=`
|
|
106
|
+
// covers the boundary where two clocks read the exact ms.
|
|
107
|
+
ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
|
|
75
108
|
ExpressionAttributeValues: {
|
|
76
109
|
':now': now,
|
|
77
110
|
},
|
|
@@ -87,22 +120,42 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
87
120
|
}
|
|
88
121
|
}
|
|
89
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Extend the lock's expiration by another `lockTtlSeconds`. Throws
|
|
125
|
+
* `ConditionalCheckFailedException` if another worker has already taken
|
|
126
|
+
* the lock — callers should treat that as "we lost the race; stop
|
|
127
|
+
* making writes". Silent no-op if no lock is currently held.
|
|
128
|
+
*/
|
|
129
|
+
async refreshLock(): Promise<void> {
|
|
130
|
+
if (!this.lockId) return;
|
|
131
|
+
|
|
132
|
+
const newExpiresAt = Date.now() + this.lockTtlSeconds * 1000;
|
|
133
|
+
|
|
134
|
+
await this.client.send(
|
|
135
|
+
new UpdateCommand({
|
|
136
|
+
TableName: this.tableName,
|
|
137
|
+
Key: this.lockKey,
|
|
138
|
+
UpdateExpression: 'SET expiresAt = :exp',
|
|
139
|
+
ConditionExpression: 'lockId = :lockId',
|
|
140
|
+
ExpressionAttributeValues: {
|
|
141
|
+
':exp': newExpiresAt,
|
|
142
|
+
':lockId': this.lockId,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
90
148
|
/**
|
|
91
149
|
* Release the distributed lock
|
|
92
150
|
*/
|
|
93
151
|
async releaseLock(): Promise<void> {
|
|
94
152
|
if (!this.lockId) return;
|
|
95
153
|
|
|
96
|
-
const lockKey = {
|
|
97
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
98
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
99
|
-
};
|
|
100
|
-
|
|
101
154
|
try {
|
|
102
155
|
await this.client.send(
|
|
103
156
|
new DeleteCommand({
|
|
104
157
|
TableName: this.tableName,
|
|
105
|
-
Key: lockKey,
|
|
158
|
+
Key: this.lockKey,
|
|
106
159
|
// Only delete if we own the lock
|
|
107
160
|
ConditionExpression: 'lockId = :lockId',
|
|
108
161
|
ExpressionAttributeValues: {
|
|
@@ -171,124 +224,173 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
171
224
|
schemaChanges?: SchemaChange[],
|
|
172
225
|
checksum?: string
|
|
173
226
|
): Promise<void> {
|
|
227
|
+
this.requireLock();
|
|
174
228
|
const now = new Date().toISOString();
|
|
175
229
|
|
|
176
230
|
// Check if migration record already exists (e.g., rolled back or failed)
|
|
177
231
|
const existing = await this.getMigration(version);
|
|
178
232
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
233
|
+
try {
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Update existing record (re-applying a rolled back or failed migration)
|
|
236
|
+
// Build dynamic update expression - DynamoDB doesn't accept undefined values
|
|
237
|
+
const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
|
|
238
|
+
const expressionValues: Record<string, any> = {
|
|
239
|
+
':status': 'applied',
|
|
240
|
+
':appliedAt': now,
|
|
241
|
+
':name': name,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (schemaDefinition !== undefined) {
|
|
245
|
+
setParts.push('schemaDefinition = :schemaDef');
|
|
246
|
+
expressionValues[':schemaDef'] = schemaDefinition;
|
|
247
|
+
}
|
|
248
|
+
if (schemaChanges !== undefined) {
|
|
249
|
+
setParts.push('schemaChanges = :schemaChanges');
|
|
250
|
+
expressionValues[':schemaChanges'] = schemaChanges;
|
|
251
|
+
}
|
|
252
|
+
if (checksum !== undefined) {
|
|
253
|
+
setParts.push('checksum = :checksum');
|
|
254
|
+
expressionValues[':checksum'] = checksum;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await this.client.send(
|
|
258
|
+
new TransactWriteCommand({
|
|
259
|
+
TransactItems: [
|
|
260
|
+
this.lockOwnershipCheck(),
|
|
261
|
+
{
|
|
262
|
+
Update: {
|
|
263
|
+
TableName: this.tableName,
|
|
264
|
+
Key: {
|
|
265
|
+
PK: this.trackingPrefix,
|
|
266
|
+
SK: version,
|
|
267
|
+
},
|
|
268
|
+
UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
|
|
269
|
+
ExpressionAttributeNames: {
|
|
270
|
+
'#status': 'status',
|
|
271
|
+
'#name': 'name',
|
|
272
|
+
'#error': 'error',
|
|
273
|
+
},
|
|
274
|
+
ExpressionAttributeValues: expressionValues,
|
|
275
|
+
// Only allow re-applying if not currently applied
|
|
276
|
+
ConditionExpression: '#status <> :status',
|
|
217
277
|
},
|
|
218
|
-
ExpressionAttributeValues: expressionValues,
|
|
219
|
-
// Only allow re-applying if not currently applied
|
|
220
|
-
ConditionExpression: '#status <> :status',
|
|
221
278
|
},
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
279
|
+
{
|
|
280
|
+
Update: {
|
|
281
|
+
TableName: this.tableName,
|
|
282
|
+
Key: {
|
|
283
|
+
PK: `${this.trackingPrefix}#CURRENT`,
|
|
284
|
+
SK: `${this.trackingPrefix}#CURRENT`,
|
|
285
|
+
},
|
|
286
|
+
UpdateExpression:
|
|
287
|
+
'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
|
|
288
|
+
ExpressionAttributeValues: {
|
|
289
|
+
':version': version,
|
|
290
|
+
':updatedAt': now,
|
|
291
|
+
':gsi1sk': version,
|
|
292
|
+
},
|
|
236
293
|
},
|
|
237
294
|
},
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
},
|
|
266
|
-
{
|
|
267
|
-
Update: {
|
|
268
|
-
TableName: this.tableName,
|
|
269
|
-
Key: {
|
|
270
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
271
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
295
|
+
],
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
} else {
|
|
299
|
+
// Create new migration record
|
|
300
|
+
await this.client.send(
|
|
301
|
+
new TransactWriteCommand({
|
|
302
|
+
TransactItems: [
|
|
303
|
+
this.lockOwnershipCheck(),
|
|
304
|
+
{
|
|
305
|
+
Put: {
|
|
306
|
+
TableName: this.tableName,
|
|
307
|
+
Item: {
|
|
308
|
+
PK: this.trackingPrefix,
|
|
309
|
+
SK: version,
|
|
310
|
+
version,
|
|
311
|
+
name,
|
|
312
|
+
timestamp: now,
|
|
313
|
+
appliedAt: now,
|
|
314
|
+
status: 'applied',
|
|
315
|
+
schemaDefinition,
|
|
316
|
+
schemaChanges,
|
|
317
|
+
checksum,
|
|
318
|
+
} as MigrationRecord,
|
|
319
|
+
// Ensure migration wasn't already applied (uniqueness on
|
|
320
|
+
// SK; PK is shared across all migration rows).
|
|
321
|
+
ConditionExpression: 'attribute_not_exists(SK)',
|
|
272
322
|
},
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
Update: {
|
|
326
|
+
TableName: this.tableName,
|
|
327
|
+
Key: {
|
|
328
|
+
PK: `${this.trackingPrefix}#CURRENT`,
|
|
329
|
+
SK: `${this.trackingPrefix}#CURRENT`,
|
|
330
|
+
},
|
|
331
|
+
UpdateExpression:
|
|
332
|
+
'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
|
|
333
|
+
ExpressionAttributeValues: {
|
|
334
|
+
':version': version,
|
|
335
|
+
':updatedAt': now,
|
|
336
|
+
':gsi1sk': version,
|
|
337
|
+
},
|
|
279
338
|
},
|
|
280
339
|
},
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
340
|
+
],
|
|
341
|
+
})
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
} catch (err: unknown) {
|
|
345
|
+
await this.handleMarkAsAppliedCancellation(err, version);
|
|
285
346
|
}
|
|
286
347
|
}
|
|
287
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Inspect a `TransactionCanceledException` raised by `markAsApplied` and
|
|
351
|
+
* either swallow it (idempotent re-apply) or re-throw a typed error.
|
|
352
|
+
*
|
|
353
|
+
* The TransactWrite items are: [lockOwnershipCheck, migrationRow, currentPointer].
|
|
354
|
+
* Each item produces an entry in `CancellationReasons`.
|
|
355
|
+
* - reasons[0] failing → the lock was lost → MigrationLockLostError.
|
|
356
|
+
* - reasons[1] failing → the migration row already exists in a state the
|
|
357
|
+
* write refused. Re-fetch the record:
|
|
358
|
+
* * status === 'applied' → idempotent re-apply, return silently.
|
|
359
|
+
* * other states → MigrationAlreadyAppliedError with the
|
|
360
|
+
* current state, so the caller can decide what to do.
|
|
361
|
+
*/
|
|
362
|
+
private async handleMarkAsAppliedCancellation(
|
|
363
|
+
err: unknown,
|
|
364
|
+
version: string
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const error = err as { name?: string; CancellationReasons?: Array<{ Code?: string }> } | null;
|
|
367
|
+
if (error?.name !== 'TransactionCanceledException') {
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
const reasons = error.CancellationReasons ?? [];
|
|
371
|
+
const lockReason = reasons[0];
|
|
372
|
+
const migrationReason = reasons[1];
|
|
373
|
+
|
|
374
|
+
if (lockReason?.Code === 'ConditionalCheckFailed') {
|
|
375
|
+
throw new MigrationLockLostError(version);
|
|
376
|
+
}
|
|
377
|
+
if (migrationReason?.Code === 'ConditionalCheckFailed') {
|
|
378
|
+
const current = await this.getMigration(version);
|
|
379
|
+
if (current?.status === 'applied') {
|
|
380
|
+
// Either we re-tried our own write or another worker applied the same
|
|
381
|
+
// version. Either way, the desired post-state is already satisfied.
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
throw new MigrationAlreadyAppliedError(version, current?.status);
|
|
385
|
+
}
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
|
|
288
389
|
/**
|
|
289
390
|
* Mark migration as rolled back using TransactWrite for atomicity
|
|
290
391
|
*/
|
|
291
392
|
async markAsRolledBack(version: string): Promise<void> {
|
|
393
|
+
this.requireLock();
|
|
292
394
|
const now = new Date().toISOString();
|
|
293
395
|
|
|
294
396
|
// Find previous version
|
|
@@ -303,6 +405,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
303
405
|
await this.client.send(
|
|
304
406
|
new TransactWriteCommand({
|
|
305
407
|
TransactItems: [
|
|
408
|
+
this.lockOwnershipCheck(),
|
|
306
409
|
{
|
|
307
410
|
Update: {
|
|
308
411
|
TableName: this.tableName,
|
|
@@ -342,70 +445,95 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
342
445
|
}
|
|
343
446
|
|
|
344
447
|
async markAsFailed(version: string, error: string): Promise<void> {
|
|
448
|
+
this.requireLock();
|
|
345
449
|
// Check if the migration record exists first
|
|
346
450
|
const existing = await this.getMigration(version);
|
|
451
|
+
const now = new Date().toISOString();
|
|
347
452
|
|
|
348
453
|
if (existing) {
|
|
349
|
-
// Update existing record
|
|
454
|
+
// Update existing record, gated by lock ownership
|
|
350
455
|
await this.client.send(
|
|
351
|
-
new
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
456
|
+
new TransactWriteCommand({
|
|
457
|
+
TransactItems: [
|
|
458
|
+
this.lockOwnershipCheck(),
|
|
459
|
+
{
|
|
460
|
+
Update: {
|
|
461
|
+
TableName: this.tableName,
|
|
462
|
+
Key: {
|
|
463
|
+
PK: this.trackingPrefix,
|
|
464
|
+
SK: version,
|
|
465
|
+
},
|
|
466
|
+
UpdateExpression:
|
|
467
|
+
'SET #status = :status, #error = :error, failedAt = :timestamp',
|
|
468
|
+
ExpressionAttributeNames: {
|
|
469
|
+
'#status': 'status',
|
|
470
|
+
'#error': 'error',
|
|
471
|
+
},
|
|
472
|
+
ExpressionAttributeValues: {
|
|
473
|
+
':status': 'failed',
|
|
474
|
+
':error': error,
|
|
475
|
+
':timestamp': now,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
],
|
|
367
480
|
})
|
|
368
481
|
);
|
|
369
482
|
} else {
|
|
370
|
-
// Create new record for failed migration
|
|
483
|
+
// Create new record for failed migration, gated by lock ownership
|
|
371
484
|
await this.client.send(
|
|
372
|
-
new
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
485
|
+
new TransactWriteCommand({
|
|
486
|
+
TransactItems: [
|
|
487
|
+
this.lockOwnershipCheck(),
|
|
488
|
+
{
|
|
489
|
+
Put: {
|
|
490
|
+
TableName: this.tableName,
|
|
491
|
+
Item: {
|
|
492
|
+
PK: this.trackingPrefix,
|
|
493
|
+
SK: version,
|
|
494
|
+
version,
|
|
495
|
+
name: 'unknown',
|
|
496
|
+
status: 'failed',
|
|
497
|
+
error,
|
|
498
|
+
failedAt: now,
|
|
499
|
+
timestamp: now,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
],
|
|
384
504
|
})
|
|
385
505
|
);
|
|
386
506
|
}
|
|
387
507
|
}
|
|
388
508
|
|
|
389
509
|
async recordSchemaChange(change: SchemaChange): Promise<void> {
|
|
510
|
+
this.requireLock();
|
|
390
511
|
const currentVersion = await this.getCurrentVersion();
|
|
391
512
|
if (!currentVersion || currentVersion === 'v0000') {
|
|
392
513
|
throw new Error('Cannot record schema change: No migration has been applied yet');
|
|
393
514
|
}
|
|
394
515
|
|
|
395
|
-
// Append to schemaChanges array
|
|
516
|
+
// Append to schemaChanges array, gated by lock ownership
|
|
396
517
|
await this.client.send(
|
|
397
|
-
new
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
518
|
+
new TransactWriteCommand({
|
|
519
|
+
TransactItems: [
|
|
520
|
+
this.lockOwnershipCheck(),
|
|
521
|
+
{
|
|
522
|
+
Update: {
|
|
523
|
+
TableName: this.tableName,
|
|
524
|
+
Key: {
|
|
525
|
+
PK: this.trackingPrefix,
|
|
526
|
+
SK: currentVersion,
|
|
527
|
+
},
|
|
528
|
+
UpdateExpression:
|
|
529
|
+
'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
|
|
530
|
+
ExpressionAttributeValues: {
|
|
531
|
+
':empty_list': [],
|
|
532
|
+
':change': [change],
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
],
|
|
409
537
|
})
|
|
410
538
|
);
|
|
411
539
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { MigrationLoader } from './core/loader';
|
|
|
12
12
|
export { MigrationRunner, type RunOptions } from './core/runner';
|
|
13
13
|
export { ConfigLoader, loadConfig } from './core/config';
|
|
14
14
|
export { createDynamoDBClient } from './core/client';
|
|
15
|
+
export { MigrationAlreadyAppliedError, MigrationLockLostError } from './core/errors';
|
|
15
16
|
|
|
16
17
|
// Template
|
|
17
18
|
export { generateMigrationTemplate } from './templates/migration';
|
package/src/types/index.ts
CHANGED
|
@@ -117,6 +117,12 @@ export interface MigrationConfig {
|
|
|
117
117
|
migrationsDir?: string;
|
|
118
118
|
trackingPrefix?: string; // Default: "_SCHEMA#VERSION"
|
|
119
119
|
gsi1Name?: string; // Default: "GSI1"
|
|
120
|
+
/**
|
|
121
|
+
* How long an acquired migration lock stays valid before another worker
|
|
122
|
+
* can take it over, in seconds. The runner sends a heartbeat every
|
|
123
|
+
* `lockTtlSeconds / 3` to extend it. Default: 300 (5 minutes).
|
|
124
|
+
*/
|
|
125
|
+
lockTtlSeconds?: number;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/**
|
|
@@ -148,6 +154,12 @@ export interface MigrationTracker {
|
|
|
148
154
|
*/
|
|
149
155
|
releaseLock(): Promise<void>;
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Extend the lock's expiration. Throws `ConditionalCheckFailedException`
|
|
159
|
+
* if the lock has already been taken by someone else.
|
|
160
|
+
*/
|
|
161
|
+
refreshLock(): Promise<void>;
|
|
162
|
+
|
|
151
163
|
/**
|
|
152
164
|
* Mark migration as applied
|
|
153
165
|
*/
|