@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.
- package/CHANGELOG.md +43 -0
- package/README.md +2 -2
- package/USAGE.md +2 -2
- package/dist/cli.js +48 -80
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +3 -2
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +12 -7
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +3 -2
- package/dist/commands/up.js.map +1 -1
- package/dist/core/config.d.ts +4 -23
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +72 -102
- package/dist/core/config.js.map +1 -1
- package/dist/core/loader.d.ts +16 -62
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +165 -216
- package/dist/core/loader.js.map +1 -1
- package/dist/core/runner.d.ts +2 -28
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +181 -218
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tracker.d.ts +10 -77
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +219 -285
- package/dist/core/tracker.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/core/tracker.js
CHANGED
|
@@ -1,76 +1,84 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (!
|
|
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
|
-
|
|
33
|
+
const lockOwnershipCheck = () => {
|
|
34
|
+
requireLock();
|
|
34
35
|
return {
|
|
35
36
|
ConditionCheck: {
|
|
36
|
-
TableName:
|
|
37
|
-
Key:
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
91
|
+
const acquireLock = async () => {
|
|
90
92
|
const now = Date.now();
|
|
91
93
|
const nowIso = new Date(now).toISOString();
|
|
92
|
-
const expiresAtIso = new Date(now +
|
|
93
|
-
|
|
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
|
|
96
|
-
TableName:
|
|
98
|
+
await client.send(new lib_dynamodb_1.PutCommand({
|
|
99
|
+
TableName: tableName,
|
|
97
100
|
Item: {
|
|
98
|
-
...
|
|
99
|
-
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
|
-
|
|
115
|
+
lockId = null;
|
|
115
116
|
return false;
|
|
116
117
|
}
|
|
117
118
|
throw error;
|
|
118
119
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
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() +
|
|
130
|
-
await
|
|
131
|
-
TableName:
|
|
132
|
-
Key:
|
|
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
|
-
|
|
143
|
-
*/
|
|
144
|
-
async releaseLock() {
|
|
145
|
-
if (!this.lockId)
|
|
132
|
+
};
|
|
133
|
+
const releaseLock = async () => {
|
|
134
|
+
if (!lockId)
|
|
146
135
|
return;
|
|
147
136
|
try {
|
|
148
|
-
await
|
|
149
|
-
TableName:
|
|
150
|
-
Key:
|
|
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
|
-
|
|
160
|
-
if (error.name !== 'ConditionalCheckFailedException') {
|
|
145
|
+
if (error.name !== 'ConditionalCheckFailedException')
|
|
161
146
|
throw error;
|
|
162
|
-
}
|
|
163
147
|
}
|
|
164
148
|
finally {
|
|
165
|
-
|
|
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
|
|
179
|
-
const
|
|
160
|
+
const getAppliedMigrations = async () => {
|
|
161
|
+
const pages = [];
|
|
180
162
|
let lastKey;
|
|
181
163
|
do {
|
|
182
|
-
const result = await
|
|
183
|
-
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
|
-
|
|
178
|
+
pages.push((result.Items ?? []));
|
|
199
179
|
lastKey = result.LastEvaluatedKey;
|
|
200
180
|
} while (lastKey);
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
async
|
|
204
|
-
const result = await
|
|
205
|
-
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
|
|
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
|
|
218
|
-
|
|
217
|
+
const markAsApplied = async (version, name, schemaDefinition, schemaChanges, checksum) => {
|
|
218
|
+
requireLock();
|
|
219
219
|
const now = new Date().toISOString();
|
|
220
|
-
|
|
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
|
|
226
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
lockOwnershipCheck(),
|
|
247
246
|
{
|
|
248
247
|
Update: {
|
|
249
|
-
TableName:
|
|
250
|
-
Key: {
|
|
251
|
-
|
|
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:
|
|
262
|
+
TableName: tableName,
|
|
268
263
|
Key: {
|
|
269
|
-
PK: `${
|
|
270
|
-
SK: `${
|
|
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
|
-
|
|
285
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
279
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
286
280
|
TransactItems: [
|
|
287
|
-
|
|
281
|
+
lockOwnershipCheck(),
|
|
288
282
|
{
|
|
289
283
|
Put: {
|
|
290
|
-
TableName:
|
|
284
|
+
TableName: tableName,
|
|
291
285
|
Item: {
|
|
292
|
-
PK:
|
|
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:
|
|
304
|
+
TableName: tableName,
|
|
311
305
|
Key: {
|
|
312
|
-
PK: `${
|
|
313
|
-
SK: `${
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
lockOwnershipCheck(),
|
|
393
337
|
{
|
|
394
338
|
Update: {
|
|
395
|
-
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:
|
|
351
|
+
TableName: tableName,
|
|
413
352
|
Key: {
|
|
414
|
-
PK: `${
|
|
415
|
-
SK: `${
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
435
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
372
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
436
373
|
TransactItems: [
|
|
437
|
-
|
|
374
|
+
lockOwnershipCheck(),
|
|
438
375
|
{
|
|
439
376
|
Update: {
|
|
440
|
-
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
|
-
|
|
462
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
395
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
463
396
|
TransactItems: [
|
|
464
|
-
|
|
397
|
+
lockOwnershipCheck(),
|
|
465
398
|
{
|
|
466
399
|
Put: {
|
|
467
|
-
TableName:
|
|
400
|
+
TableName: tableName,
|
|
468
401
|
Item: {
|
|
469
|
-
PK:
|
|
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
|
|
485
|
-
|
|
486
|
-
const currentVersion = await
|
|
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
|
-
|
|
491
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
423
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
492
424
|
TransactItems: [
|
|
493
|
-
|
|
425
|
+
lockOwnershipCheck(),
|
|
494
426
|
{
|
|
495
427
|
Update: {
|
|
496
|
-
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
|
|
512
|
-
const
|
|
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
|
-
|
|
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
|