@ftschopp/dynatable-migrations 1.2.4 → 1.2.6

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,6 +9,7 @@ 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. */
14
15
  export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
@@ -229,116 +230,160 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
229
230
  // Check if migration record already exists (e.g., rolled back or failed)
230
231
  const existing = await this.getMigration(version);
231
232
 
232
- if (existing) {
233
- // Update existing record (re-applying a rolled back or failed migration)
234
- // Build dynamic update expression - DynamoDB doesn't accept undefined values
235
- const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
236
- const expressionValues: Record<string, any> = {
237
- ':status': 'applied',
238
- ':appliedAt': now,
239
- ':name': name,
240
- };
241
-
242
- if (schemaDefinition !== undefined) {
243
- setParts.push('schemaDefinition = :schemaDef');
244
- expressionValues[':schemaDef'] = schemaDefinition;
245
- }
246
- if (schemaChanges !== undefined) {
247
- setParts.push('schemaChanges = :schemaChanges');
248
- expressionValues[':schemaChanges'] = schemaChanges;
249
- }
250
- if (checksum !== undefined) {
251
- setParts.push('checksum = :checksum');
252
- expressionValues[':checksum'] = checksum;
253
- }
254
-
255
- await this.client.send(
256
- new TransactWriteCommand({
257
- TransactItems: [
258
- this.lockOwnershipCheck(),
259
- {
260
- Update: {
261
- TableName: this.tableName,
262
- Key: {
263
- PK: this.trackingPrefix,
264
- SK: version,
265
- },
266
- UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
267
- ExpressionAttributeNames: {
268
- '#status': 'status',
269
- '#name': 'name',
270
- '#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',
271
277
  },
272
- ExpressionAttributeValues: expressionValues,
273
- // Only allow re-applying if not currently applied
274
- ConditionExpression: '#status <> :status',
275
278
  },
276
- },
277
- {
278
- Update: {
279
- TableName: this.tableName,
280
- Key: {
281
- PK: `${this.trackingPrefix}#CURRENT`,
282
- SK: `${this.trackingPrefix}#CURRENT`,
283
- },
284
- UpdateExpression:
285
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
286
- ExpressionAttributeValues: {
287
- ':version': version,
288
- ':updatedAt': now,
289
- ':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
+ },
290
293
  },
291
294
  },
292
- },
293
- ],
294
- })
295
- );
296
- } else {
297
- // Create new migration record
298
- await this.client.send(
299
- new TransactWriteCommand({
300
- TransactItems: [
301
- this.lockOwnershipCheck(),
302
- {
303
- Put: {
304
- TableName: this.tableName,
305
- Item: {
306
- PK: this.trackingPrefix,
307
- SK: version,
308
- version,
309
- name,
310
- timestamp: now,
311
- appliedAt: now,
312
- status: 'applied',
313
- schemaDefinition,
314
- schemaChanges,
315
- checksum,
316
- } as MigrationRecord,
317
- // Ensure migration wasn't already applied (uniqueness on
318
- // SK; PK is shared across all migration rows).
319
- ConditionExpression: 'attribute_not_exists(SK)',
320
- },
321
- },
322
- {
323
- Update: {
324
- TableName: this.tableName,
325
- Key: {
326
- PK: `${this.trackingPrefix}#CURRENT`,
327
- 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)',
328
322
  },
329
- UpdateExpression:
330
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
331
- ExpressionAttributeValues: {
332
- ':version': version,
333
- ':updatedAt': now,
334
- ':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
+ },
335
338
  },
336
339
  },
337
- },
338
- ],
339
- })
340
- );
340
+ ],
341
+ })
342
+ );
343
+ }
344
+ } catch (err: unknown) {
345
+ await this.handleMarkAsAppliedCancellation(err, version);
346
+ }
347
+ }
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);
341
385
  }
386
+ throw err;
342
387
  }
343
388
 
344
389
  /**
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';