@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.
@@ -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
- const LOCK_TTL_SECONDS = 300; // 5 minutes
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 + LOCK_TTL_SECONDS * 1000;
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
- ConditionExpression: 'attribute_not_exists(PK) OR expiresAt < :now',
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
- if (existing) {
180
- // Update existing record (re-applying a rolled back or failed migration)
181
- // Build dynamic update expression - DynamoDB doesn't accept undefined values
182
- const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
183
- const expressionValues: Record<string, any> = {
184
- ':status': 'applied',
185
- ':appliedAt': now,
186
- ':name': name,
187
- };
188
-
189
- if (schemaDefinition !== undefined) {
190
- setParts.push('schemaDefinition = :schemaDef');
191
- expressionValues[':schemaDef'] = schemaDefinition;
192
- }
193
- if (schemaChanges !== undefined) {
194
- setParts.push('schemaChanges = :schemaChanges');
195
- expressionValues[':schemaChanges'] = schemaChanges;
196
- }
197
- if (checksum !== undefined) {
198
- setParts.push('checksum = :checksum');
199
- expressionValues[':checksum'] = checksum;
200
- }
201
-
202
- await this.client.send(
203
- new TransactWriteCommand({
204
- TransactItems: [
205
- {
206
- Update: {
207
- TableName: this.tableName,
208
- Key: {
209
- PK: this.trackingPrefix,
210
- SK: version,
211
- },
212
- UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
213
- ExpressionAttributeNames: {
214
- '#status': 'status',
215
- '#name': 'name',
216
- '#error': 'error',
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
- Update: {
225
- TableName: this.tableName,
226
- Key: {
227
- PK: `${this.trackingPrefix}#CURRENT`,
228
- SK: `${this.trackingPrefix}#CURRENT`,
229
- },
230
- UpdateExpression:
231
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
232
- ExpressionAttributeValues: {
233
- ':version': version,
234
- ':updatedAt': now,
235
- ':gsi1sk': version,
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
- } else {
243
- // Create new migration record
244
- await this.client.send(
245
- new TransactWriteCommand({
246
- TransactItems: [
247
- {
248
- Put: {
249
- TableName: this.tableName,
250
- Item: {
251
- PK: this.trackingPrefix,
252
- SK: version,
253
- version,
254
- name,
255
- timestamp: now,
256
- appliedAt: now,
257
- status: 'applied',
258
- schemaDefinition,
259
- schemaChanges,
260
- checksum,
261
- } as MigrationRecord,
262
- // Ensure migration wasn't already applied
263
- ConditionExpression: 'attribute_not_exists(PK)',
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
- UpdateExpression:
274
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
275
- ExpressionAttributeValues: {
276
- ':version': version,
277
- ':updatedAt': now,
278
- ':gsi1sk': version,
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 UpdateCommand({
352
- TableName: this.tableName,
353
- Key: {
354
- PK: this.trackingPrefix,
355
- SK: version,
356
- },
357
- UpdateExpression: 'SET #status = :status, #error = :error, failedAt = :timestamp',
358
- ExpressionAttributeNames: {
359
- '#status': 'status',
360
- '#error': 'error',
361
- },
362
- ExpressionAttributeValues: {
363
- ':status': 'failed',
364
- ':error': error,
365
- ':timestamp': new Date().toISOString(),
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 PutCommand({
373
- TableName: this.tableName,
374
- Item: {
375
- PK: this.trackingPrefix,
376
- SK: version,
377
- version,
378
- name: 'unknown',
379
- status: 'failed',
380
- error,
381
- failedAt: new Date().toISOString(),
382
- timestamp: new Date().toISOString(),
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 UpdateCommand({
398
- TableName: this.tableName,
399
- Key: {
400
- PK: this.trackingPrefix,
401
- SK: currentVersion,
402
- },
403
- UpdateExpression:
404
- 'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
405
- ExpressionAttributeValues: {
406
- ':empty_list': [],
407
- ':change': [change],
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';
@@ -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
  */