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