@ftschopp/dynatable-migrations 1.3.2 → 2.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.
@@ -1,76 +1,84 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DynamoDBMigrationTracker = exports.DEFAULT_LOCK_TTL_SECONDS = void 0;
3
+ exports.createMigrationTracker = exports.DEFAULT_LOCK_TTL_SECONDS = void 0;
4
4
  const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
5
5
  const semver_1 = require("./semver");
6
6
  const errors_1 = require("./errors");
7
7
  /** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
8
8
  exports.DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
9
- class DynamoDBMigrationTracker {
10
- constructor(client, config) {
11
- this.lockId = null;
12
- this.client = client;
13
- this.tableName = config.tableName;
14
- this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
15
- this.gsi1Name = config.gsi1Name || 'GSI1';
16
- this.lockTtlSeconds = config.lockTtlSeconds ?? exports.DEFAULT_LOCK_TTL_SECONDS;
17
- }
18
- get lockKey() {
19
- return {
20
- PK: `${this.trackingPrefix}#LOCK`,
21
- SK: `${this.trackingPrefix}#LOCK`,
22
- };
23
- }
9
+ /**
10
+ * Create a DynamoDB-backed migration tracker.
11
+ *
12
+ * The tracker carries one piece of genuinely mutable state: the current
13
+ * lock id, set by `acquireLock()` and cleared by `releaseLock()`. Everything
14
+ * else is derived from the config or read from DynamoDB on demand.
15
+ */
16
+ const createMigrationTracker = (client, config) => {
17
+ const tableName = config.tableName;
18
+ const trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
19
+ const lockTtlSeconds = config.lockTtlSeconds ?? exports.DEFAULT_LOCK_TTL_SECONDS;
20
+ let lockId = null;
21
+ const lockKey = {
22
+ PK: `${trackingPrefix}#LOCK`,
23
+ SK: `${trackingPrefix}#LOCK`,
24
+ };
24
25
  /** Throws if there is no active lock. Call before any tracker write. */
25
- requireLock() {
26
- if (!this.lockId) {
26
+ const requireLock = () => {
27
+ if (!lockId) {
27
28
  throw new Error('Tracker has no active lock; refusing to issue tracker mutations. ' +
28
29
  'Call acquireLock() first.');
29
30
  }
30
- }
31
+ };
31
32
  /** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
32
- lockOwnershipCheck() {
33
- this.requireLock();
33
+ const lockOwnershipCheck = () => {
34
+ requireLock();
34
35
  return {
35
36
  ConditionCheck: {
36
- TableName: this.tableName,
37
- Key: this.lockKey,
37
+ TableName: tableName,
38
+ Key: lockKey,
38
39
  ConditionExpression: 'lockId = :lockId',
39
- ExpressionAttributeValues: {
40
- ':lockId': this.lockId,
41
- },
40
+ ExpressionAttributeValues: { ':lockId': lockId },
42
41
  },
43
42
  };
44
- }
45
- async initialize() {
46
- // Check if current version pointer exists, if not create it
47
- const current = await this.getCurrentVersion();
48
- if (!current) {
49
- // Create initial version pointer. The condition prevents two
50
- // concurrent first-run inits from each writing the row — without
51
- // it, both reads return null, both Puts succeed, and the second
52
- // silently overwrites the first.
53
- try {
54
- await this.client.send(new lib_dynamodb_1.PutCommand({
55
- TableName: this.tableName,
56
- Item: {
57
- PK: `${this.trackingPrefix}#CURRENT`,
58
- SK: `${this.trackingPrefix}#CURRENT`,
59
- GSI1PK: 'SCHEMA#CURRENT',
60
- GSI1SK: 'v0000',
61
- currentVersion: 'v0000',
62
- updatedAt: new Date().toISOString(),
63
- },
64
- ConditionExpression: 'attribute_not_exists(PK)',
65
- }));
66
- }
67
- catch (err) {
68
- // Another worker initialized first — that's the desired post-state.
69
- if (err?.name !== 'ConditionalCheckFailedException')
70
- throw err;
71
- }
43
+ };
44
+ const getCurrentVersion = async () => {
45
+ const result = await client.send(new lib_dynamodb_1.GetCommand({
46
+ TableName: tableName,
47
+ Key: {
48
+ PK: `${trackingPrefix}#CURRENT`,
49
+ SK: `${trackingPrefix}#CURRENT`,
50
+ },
51
+ }));
52
+ return result.Item?.currentVersion || null;
53
+ };
54
+ const initialize = async () => {
55
+ const current = await getCurrentVersion();
56
+ if (current)
57
+ return;
58
+ // Create initial version pointer. The condition prevents two
59
+ // concurrent first-run inits from each writing the row — without
60
+ // it, both reads return null, both Puts succeed, and the second
61
+ // silently overwrites the first.
62
+ try {
63
+ await client.send(new lib_dynamodb_1.PutCommand({
64
+ TableName: tableName,
65
+ Item: {
66
+ PK: `${trackingPrefix}#CURRENT`,
67
+ SK: `${trackingPrefix}#CURRENT`,
68
+ GSI1PK: 'SCHEMA#CURRENT',
69
+ GSI1SK: 'v0000',
70
+ currentVersion: 'v0000',
71
+ updatedAt: new Date().toISOString(),
72
+ },
73
+ ConditionExpression: 'attribute_not_exists(PK)',
74
+ }));
75
+ }
76
+ catch (err) {
77
+ // Another worker initialized first — that's the desired post-state.
78
+ if (err?.name !== 'ConditionalCheckFailedException')
79
+ throw err;
72
80
  }
73
- }
81
+ };
74
82
  /**
75
83
  * Acquire a distributed lock to prevent concurrent migrations.
76
84
  *
@@ -79,92 +87,68 @@ class DynamoDBMigrationTracker {
79
87
  * `rolledBackAt`, `acquiredAt`). ISO 8601 is lexicographically ordered the
80
88
  * same way it's chronologically ordered, so the `<=` condition still
81
89
  * compares correctly.
82
- *
83
- * **Upgrade note:** earlier versions stored `expiresAt` as a number
84
- * (millis). A stale numeric lock written by an older client cannot be
85
- * compared against the new ISO `:now` value (DynamoDB rejects mixed-type
86
- * comparisons), so an in-flight numeric lock will block new acquisitions
87
- * until cleared via `dynatable-migrate unlock --force`.
88
90
  */
89
- async acquireLock() {
91
+ const acquireLock = async () => {
90
92
  const now = Date.now();
91
93
  const nowIso = new Date(now).toISOString();
92
- const expiresAtIso = new Date(now + this.lockTtlSeconds * 1000).toISOString();
93
- this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
94
+ const expiresAtIso = new Date(now + lockTtlSeconds * 1000).toISOString();
95
+ const newLockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
96
+ lockId = newLockId;
94
97
  try {
95
- await this.client.send(new lib_dynamodb_1.PutCommand({
96
- TableName: this.tableName,
98
+ await client.send(new lib_dynamodb_1.PutCommand({
99
+ TableName: tableName,
97
100
  Item: {
98
- ...this.lockKey,
99
- lockId: this.lockId,
101
+ ...lockKey,
102
+ lockId: newLockId,
100
103
  acquiredAt: nowIso,
101
104
  expiresAt: expiresAtIso,
102
105
  },
103
106
  // Only succeed if lock doesn't exist or has expired. The `<=`
104
107
  // covers the boundary where two clocks read the exact ms.
105
108
  ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
106
- ExpressionAttributeValues: {
107
- ':now': nowIso,
108
- },
109
+ ExpressionAttributeValues: { ':now': nowIso },
109
110
  }));
110
111
  return true;
111
112
  }
112
113
  catch (error) {
113
114
  if (error.name === 'ConditionalCheckFailedException') {
114
- this.lockId = null;
115
+ lockId = null;
115
116
  return false;
116
117
  }
117
118
  throw error;
118
119
  }
119
- }
120
- /**
121
- * Extend the lock's expiration by another `lockTtlSeconds`. Throws
122
- * `ConditionalCheckFailedException` if another worker has already taken
123
- * the lock — callers should treat that as "we lost the race; stop
124
- * making writes". Silent no-op if no lock is currently held.
125
- */
126
- async refreshLock() {
127
- if (!this.lockId)
120
+ };
121
+ const refreshLock = async () => {
122
+ if (!lockId)
128
123
  return;
129
- const newExpiresAtIso = new Date(Date.now() + this.lockTtlSeconds * 1000).toISOString();
130
- await this.client.send(new lib_dynamodb_1.UpdateCommand({
131
- TableName: this.tableName,
132
- Key: this.lockKey,
124
+ const newExpiresAtIso = new Date(Date.now() + lockTtlSeconds * 1000).toISOString();
125
+ await client.send(new lib_dynamodb_1.UpdateCommand({
126
+ TableName: tableName,
127
+ Key: lockKey,
133
128
  UpdateExpression: 'SET expiresAt = :exp',
134
129
  ConditionExpression: 'lockId = :lockId',
135
- ExpressionAttributeValues: {
136
- ':exp': newExpiresAtIso,
137
- ':lockId': this.lockId,
138
- },
130
+ ExpressionAttributeValues: { ':exp': newExpiresAtIso, ':lockId': lockId },
139
131
  }));
140
- }
141
- /**
142
- * Release the distributed lock
143
- */
144
- async releaseLock() {
145
- if (!this.lockId)
132
+ };
133
+ const releaseLock = async () => {
134
+ if (!lockId)
146
135
  return;
147
136
  try {
148
- await this.client.send(new lib_dynamodb_1.DeleteCommand({
149
- TableName: this.tableName,
150
- Key: this.lockKey,
151
- // Only delete if we own the lock
137
+ await client.send(new lib_dynamodb_1.DeleteCommand({
138
+ TableName: tableName,
139
+ Key: lockKey,
152
140
  ConditionExpression: 'lockId = :lockId',
153
- ExpressionAttributeValues: {
154
- ':lockId': this.lockId,
155
- },
141
+ ExpressionAttributeValues: { ':lockId': lockId },
156
142
  }));
157
143
  }
158
144
  catch (error) {
159
- // Lock may have expired or been taken by someone else
160
- if (error.name !== 'ConditionalCheckFailedException') {
145
+ if (error.name !== 'ConditionalCheckFailedException')
161
146
  throw error;
162
- }
163
147
  }
164
148
  finally {
165
- this.lockId = null;
149
+ lockId = null;
166
150
  }
167
- }
151
+ };
168
152
  /**
169
153
  * Get all applied migrations with pagination support.
170
154
  *
@@ -172,15 +156,13 @@ class DynamoDBMigrationTracker {
172
156
  * `schemaDefinition` and `schemaChanges` blobs are intentionally omitted,
173
157
  * because no internal caller reads them and including them blows up the
174
158
  * 1MB-per-page budget (and consumed RCU) on tables with many migrations.
175
- * If a future caller needs the full record, fetch by version with
176
- * `getMigration(version)`.
177
159
  */
178
- async getAppliedMigrations() {
179
- const items = [];
160
+ const getAppliedMigrations = async () => {
161
+ const pages = [];
180
162
  let lastKey;
181
163
  do {
182
- const result = await this.client.send(new lib_dynamodb_1.QueryCommand({
183
- TableName: this.tableName,
164
+ const result = await client.send(new lib_dynamodb_1.QueryCommand({
165
+ TableName: tableName,
184
166
  KeyConditionExpression: 'PK = :pk',
185
167
  // `name` and `status` are DynamoDB reserved words; alias them.
186
168
  ProjectionExpression: 'PK, SK, version, #name, #status, appliedAt, #ts, #err, checksum, failedAt, rolledBackAt, description',
@@ -190,84 +172,97 @@ class DynamoDBMigrationTracker {
190
172
  '#ts': 'timestamp',
191
173
  '#err': 'error',
192
174
  },
193
- ExpressionAttributeValues: {
194
- ':pk': this.trackingPrefix,
195
- },
175
+ ExpressionAttributeValues: { ':pk': trackingPrefix },
196
176
  ExclusiveStartKey: lastKey,
197
177
  }));
198
- items.push(...(result.Items || []));
178
+ pages.push((result.Items ?? []));
199
179
  lastKey = result.LastEvaluatedKey;
200
180
  } while (lastKey);
201
- return items;
202
- }
203
- async getCurrentVersion() {
204
- const result = await this.client.send(new lib_dynamodb_1.GetCommand({
205
- TableName: this.tableName,
206
- Key: {
207
- PK: `${this.trackingPrefix}#CURRENT`,
208
- SK: `${this.trackingPrefix}#CURRENT`,
209
- },
181
+ return pages.flat();
182
+ };
183
+ const getMigration = async (version) => {
184
+ const result = await client.send(new lib_dynamodb_1.GetCommand({
185
+ TableName: tableName,
186
+ Key: { PK: trackingPrefix, SK: version },
210
187
  }));
211
- return result.Item?.currentVersion || null;
212
- }
188
+ return result.Item || null;
189
+ };
190
+ /**
191
+ * Inspect a `TransactionCanceledException` raised by `markAsApplied` and
192
+ * either swallow it (idempotent re-apply) or re-throw a typed error.
193
+ */
194
+ const handleMarkAsAppliedCancellation = async (err, version) => {
195
+ const error = err;
196
+ if (error?.name !== 'TransactionCanceledException')
197
+ throw err;
198
+ const reasons = error.CancellationReasons ?? [];
199
+ const lockReason = reasons[0];
200
+ const migrationReason = reasons[1];
201
+ if (lockReason?.Code === 'ConditionalCheckFailed') {
202
+ throw new errors_1.MigrationLockLostError(version);
203
+ }
204
+ if (migrationReason?.Code === 'ConditionalCheckFailed') {
205
+ const current = await getMigration(version);
206
+ if (current?.status === 'applied') {
207
+ return;
208
+ }
209
+ throw new errors_1.MigrationAlreadyAppliedError(version, current?.status);
210
+ }
211
+ throw err;
212
+ };
213
213
  /**
214
214
  * Mark migration as applied using TransactWrite for atomicity
215
215
  * Handles both new migrations and re-applying rolled back migrations
216
216
  */
217
- async markAsApplied(version, name, schemaDefinition, schemaChanges, checksum) {
218
- this.requireLock();
217
+ const markAsApplied = async (version, name, schemaDefinition, schemaChanges, checksum) => {
218
+ requireLock();
219
219
  const now = new Date().toISOString();
220
- // Check if migration record already exists (e.g., rolled back or failed)
221
- const existing = await this.getMigration(version);
220
+ const existing = await getMigration(version);
222
221
  try {
223
222
  if (existing) {
224
- // Update existing record (re-applying a rolled back or failed migration)
225
- // Build dynamic update expression - DynamoDB doesn't accept undefined values
226
- const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
223
+ // Update existing record (re-applying a rolled back or failed migration).
224
+ // Build expression and values purely from the optional fields.
225
+ const optionalFields = [
226
+ ['schemaDefinition', schemaDefinition, ':schemaDef'],
227
+ ['schemaChanges', schemaChanges, ':schemaChanges'],
228
+ ['checksum', checksum, ':checksum'],
229
+ ];
230
+ const presentFields = optionalFields.filter(([, value]) => value !== undefined);
231
+ const setExpr = [
232
+ '#status = :status',
233
+ 'appliedAt = :appliedAt',
234
+ '#name = :name',
235
+ ...presentFields.map(([field, , placeholder]) => `${field} = ${placeholder}`),
236
+ ].join(', ');
227
237
  const expressionValues = {
228
238
  ':status': 'applied',
229
239
  ':appliedAt': now,
230
240
  ':name': name,
241
+ ...Object.fromEntries(presentFields.map(([, value, placeholder]) => [placeholder, value])),
231
242
  };
232
- if (schemaDefinition !== undefined) {
233
- setParts.push('schemaDefinition = :schemaDef');
234
- expressionValues[':schemaDef'] = schemaDefinition;
235
- }
236
- if (schemaChanges !== undefined) {
237
- setParts.push('schemaChanges = :schemaChanges');
238
- expressionValues[':schemaChanges'] = schemaChanges;
239
- }
240
- if (checksum !== undefined) {
241
- setParts.push('checksum = :checksum');
242
- expressionValues[':checksum'] = checksum;
243
- }
244
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
243
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
245
244
  TransactItems: [
246
- this.lockOwnershipCheck(),
245
+ lockOwnershipCheck(),
247
246
  {
248
247
  Update: {
249
- TableName: this.tableName,
250
- Key: {
251
- PK: this.trackingPrefix,
252
- SK: version,
253
- },
254
- UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
248
+ TableName: tableName,
249
+ Key: { PK: trackingPrefix, SK: version },
250
+ UpdateExpression: `SET ${setExpr} REMOVE #error, failedAt, rolledBackAt`,
255
251
  ExpressionAttributeNames: {
256
252
  '#status': 'status',
257
253
  '#name': 'name',
258
254
  '#error': 'error',
259
255
  },
260
256
  ExpressionAttributeValues: expressionValues,
261
- // Only allow re-applying if not currently applied
262
257
  ConditionExpression: '#status <> :status',
263
258
  },
264
259
  },
265
260
  {
266
261
  Update: {
267
- TableName: this.tableName,
262
+ TableName: tableName,
268
263
  Key: {
269
- PK: `${this.trackingPrefix}#CURRENT`,
270
- SK: `${this.trackingPrefix}#CURRENT`,
264
+ PK: `${trackingPrefix}#CURRENT`,
265
+ SK: `${trackingPrefix}#CURRENT`,
271
266
  },
272
267
  UpdateExpression: 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
273
268
  ExpressionAttributeValues: {
@@ -281,15 +276,14 @@ class DynamoDBMigrationTracker {
281
276
  }));
282
277
  }
283
278
  else {
284
- // Create new migration record
285
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
279
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
286
280
  TransactItems: [
287
- this.lockOwnershipCheck(),
281
+ lockOwnershipCheck(),
288
282
  {
289
283
  Put: {
290
- TableName: this.tableName,
284
+ TableName: tableName,
291
285
  Item: {
292
- PK: this.trackingPrefix,
286
+ PK: trackingPrefix,
293
287
  SK: version,
294
288
  version,
295
289
  name,
@@ -307,10 +301,10 @@ class DynamoDBMigrationTracker {
307
301
  },
308
302
  {
309
303
  Update: {
310
- TableName: this.tableName,
304
+ TableName: tableName,
311
305
  Key: {
312
- PK: `${this.trackingPrefix}#CURRENT`,
313
- SK: `${this.trackingPrefix}#CURRENT`,
306
+ PK: `${trackingPrefix}#CURRENT`,
307
+ SK: `${trackingPrefix}#CURRENT`,
314
308
  },
315
309
  UpdateExpression: 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
316
310
  ExpressionAttributeValues: {
@@ -325,82 +319,27 @@ class DynamoDBMigrationTracker {
325
319
  }
326
320
  }
327
321
  catch (err) {
328
- await this.handleMarkAsAppliedCancellation(err, version);
329
- }
330
- }
331
- /**
332
- * Inspect a `TransactionCanceledException` raised by `markAsApplied` and
333
- * either swallow it (idempotent re-apply) or re-throw a typed error.
334
- *
335
- * The TransactWrite items are: [lockOwnershipCheck, migrationRow, currentPointer].
336
- * Each item produces an entry in `CancellationReasons`.
337
- * - reasons[0] failing → the lock was lost → MigrationLockLostError.
338
- * - reasons[1] failing → the migration row already exists in a state the
339
- * write refused. Re-fetch the record:
340
- * * status === 'applied' → idempotent re-apply, return silently.
341
- * * other states → MigrationAlreadyAppliedError with the
342
- * current state, so the caller can decide what to do.
343
- */
344
- async handleMarkAsAppliedCancellation(err, version) {
345
- const error = err;
346
- if (error?.name !== 'TransactionCanceledException') {
347
- throw err;
348
- }
349
- const reasons = error.CancellationReasons ?? [];
350
- const lockReason = reasons[0];
351
- const migrationReason = reasons[1];
352
- if (lockReason?.Code === 'ConditionalCheckFailed') {
353
- throw new errors_1.MigrationLockLostError(version);
322
+ await handleMarkAsAppliedCancellation(err, version);
354
323
  }
355
- if (migrationReason?.Code === 'ConditionalCheckFailed') {
356
- const current = await this.getMigration(version);
357
- if (current?.status === 'applied') {
358
- // Either we re-tried our own write or another worker applied the same
359
- // version. Either way, the desired post-state is already satisfied.
360
- return;
361
- }
362
- throw new errors_1.MigrationAlreadyAppliedError(version, current?.status);
363
- }
364
- throw err;
365
- }
366
- /**
367
- * Mark migration as rolled back using TransactWrite for atomicity.
368
- *
369
- * @param previousVersion Optional. The version the CURRENT pointer should
370
- * move to after this rollback. When the caller already knows it (the
371
- * runner does — it loaded the applied list once at the top of `down()`),
372
- * passing it here skips a `getAppliedMigrations()` Query per rollback.
373
- * Without this hint, a multi-step rollback was N×(paginated Query of
374
- * the entire migration history). When omitted, the tracker falls back
375
- * to looking it up.
376
- */
377
- async markAsRolledBack(version, previousVersion) {
378
- this.requireLock();
324
+ };
325
+ const markAsRolledBack = async (version, previousVersion) => {
326
+ requireLock();
379
327
  const now = new Date().toISOString();
380
- let resolvedPrevious = previousVersion;
381
- if (resolvedPrevious === undefined) {
382
- // Find previous version
383
- const migrations = await this.getAppliedMigrations();
384
- const appliedMigrations = migrations
328
+ const resolvedPrevious = previousVersion ??
329
+ (await getAppliedMigrations()
330
+ .then((migrations) => migrations
385
331
  .filter((m) => m.status === 'applied' && m.version !== version)
386
- .sort((a, b) => (0, semver_1.compareSemver)(b.version, a.version));
387
- resolvedPrevious = appliedMigrations[0]?.version || 'v0000';
388
- }
389
- // Use TransactWrite to atomically update record and pointer
390
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
332
+ .sort((a, b) => (0, semver_1.compareSemver)(b.version, a.version)))
333
+ .then((sorted) => sorted[0]?.version || 'v0000'));
334
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
391
335
  TransactItems: [
392
- this.lockOwnershipCheck(),
336
+ lockOwnershipCheck(),
393
337
  {
394
338
  Update: {
395
- TableName: this.tableName,
396
- Key: {
397
- PK: this.trackingPrefix,
398
- SK: version,
399
- },
339
+ TableName: tableName,
340
+ Key: { PK: trackingPrefix, SK: version },
400
341
  UpdateExpression: 'SET #status = :status, rolledBackAt = :timestamp',
401
- ExpressionAttributeNames: {
402
- '#status': 'status',
403
- },
342
+ ExpressionAttributeNames: { '#status': 'status' },
404
343
  ExpressionAttributeValues: {
405
344
  ':status': 'rolled_back',
406
345
  ':timestamp': now,
@@ -409,10 +348,10 @@ class DynamoDBMigrationTracker {
409
348
  },
410
349
  {
411
350
  Update: {
412
- TableName: this.tableName,
351
+ TableName: tableName,
413
352
  Key: {
414
- PK: `${this.trackingPrefix}#CURRENT`,
415
- SK: `${this.trackingPrefix}#CURRENT`,
353
+ PK: `${trackingPrefix}#CURRENT`,
354
+ SK: `${trackingPrefix}#CURRENT`,
416
355
  },
417
356
  UpdateExpression: 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
418
357
  ExpressionAttributeValues: {
@@ -424,24 +363,19 @@ class DynamoDBMigrationTracker {
424
363
  },
425
364
  ],
426
365
  }));
427
- }
428
- async markAsFailed(version, error) {
429
- this.requireLock();
430
- // Check if the migration record exists first
431
- const existing = await this.getMigration(version);
366
+ };
367
+ const markAsFailed = async (version, error) => {
368
+ requireLock();
369
+ const existing = await getMigration(version);
432
370
  const now = new Date().toISOString();
433
371
  if (existing) {
434
- // Update existing record, gated by lock ownership
435
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
372
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
436
373
  TransactItems: [
437
- this.lockOwnershipCheck(),
374
+ lockOwnershipCheck(),
438
375
  {
439
376
  Update: {
440
- TableName: this.tableName,
441
- Key: {
442
- PK: this.trackingPrefix,
443
- SK: version,
444
- },
377
+ TableName: tableName,
378
+ Key: { PK: trackingPrefix, SK: version },
445
379
  UpdateExpression: 'SET #status = :status, #error = :error, failedAt = :timestamp',
446
380
  ExpressionAttributeNames: {
447
381
  '#status': 'status',
@@ -458,15 +392,14 @@ class DynamoDBMigrationTracker {
458
392
  }));
459
393
  }
460
394
  else {
461
- // Create new record for failed migration, gated by lock ownership
462
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
395
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
463
396
  TransactItems: [
464
- this.lockOwnershipCheck(),
397
+ lockOwnershipCheck(),
465
398
  {
466
399
  Put: {
467
- TableName: this.tableName,
400
+ TableName: tableName,
468
401
  Item: {
469
- PK: this.trackingPrefix,
402
+ PK: trackingPrefix,
470
403
  SK: version,
471
404
  version,
472
405
  name: 'unknown',
@@ -480,24 +413,20 @@ class DynamoDBMigrationTracker {
480
413
  ],
481
414
  }));
482
415
  }
483
- }
484
- async recordSchemaChange(change) {
485
- this.requireLock();
486
- const currentVersion = await this.getCurrentVersion();
416
+ };
417
+ const recordSchemaChange = async (change) => {
418
+ requireLock();
419
+ const currentVersion = await getCurrentVersion();
487
420
  if (!currentVersion || currentVersion === 'v0000') {
488
421
  throw new Error('Cannot record schema change: No migration has been applied yet');
489
422
  }
490
- // Append to schemaChanges array, gated by lock ownership
491
- await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
423
+ await client.send(new lib_dynamodb_1.TransactWriteCommand({
492
424
  TransactItems: [
493
- this.lockOwnershipCheck(),
425
+ lockOwnershipCheck(),
494
426
  {
495
427
  Update: {
496
- TableName: this.tableName,
497
- Key: {
498
- PK: this.trackingPrefix,
499
- SK: currentVersion,
500
- },
428
+ TableName: tableName,
429
+ Key: { PK: trackingPrefix, SK: currentVersion },
501
430
  UpdateExpression: 'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
502
431
  ExpressionAttributeValues: {
503
432
  ':empty_list': [],
@@ -507,21 +436,26 @@ class DynamoDBMigrationTracker {
507
436
  },
508
437
  ],
509
438
  }));
510
- }
511
- async getMigration(version) {
512
- const result = await this.client.send(new lib_dynamodb_1.GetCommand({
513
- TableName: this.tableName,
514
- Key: {
515
- PK: this.trackingPrefix,
516
- SK: version,
517
- },
518
- }));
519
- return result.Item || null;
520
- }
521
- async isApplied(version) {
522
- const migration = await this.getMigration(version);
439
+ };
440
+ const isApplied = async (version) => {
441
+ const migration = await getMigration(version);
523
442
  return migration?.status === 'applied';
524
- }
525
- }
526
- exports.DynamoDBMigrationTracker = DynamoDBMigrationTracker;
443
+ };
444
+ return {
445
+ lockTtlSeconds,
446
+ initialize,
447
+ acquireLock,
448
+ refreshLock,
449
+ releaseLock,
450
+ getAppliedMigrations,
451
+ getCurrentVersion,
452
+ markAsApplied,
453
+ markAsRolledBack,
454
+ markAsFailed,
455
+ recordSchemaChange,
456
+ getMigration,
457
+ isApplied,
458
+ };
459
+ };
460
+ exports.createMigrationTracker = createMigrationTracker;
527
461
  //# sourceMappingURL=tracker.js.map