@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.
- package/CHANGELOG.md +50 -0
- package/README.md +2 -2
- package/USAGE.md +2 -2
- package/dist/cli.js +51 -68
- 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/unlock.js +1 -1
- package/dist/commands/unlock.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 -65
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +229 -282
- 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,157 +1,154 @@
|
|
|
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
|
-
* 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
|
|
91
|
+
const acquireLock = async () => {
|
|
78
92
|
const now = Date.now();
|
|
79
|
-
const
|
|
80
|
-
|
|
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
|
|
83
|
-
TableName:
|
|
98
|
+
await client.send(new lib_dynamodb_1.PutCommand({
|
|
99
|
+
TableName: tableName,
|
|
84
100
|
Item: {
|
|
85
|
-
...
|
|
86
|
-
lockId:
|
|
87
|
-
acquiredAt:
|
|
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
|
-
|
|
115
|
+
lockId = null;
|
|
102
116
|
return false;
|
|
103
117
|
}
|
|
104
118
|
throw error;
|
|
105
119
|
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
117
|
-
await
|
|
118
|
-
TableName:
|
|
119
|
-
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,
|
|
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
|
-
|
|
130
|
-
*/
|
|
131
|
-
async releaseLock() {
|
|
132
|
-
if (!this.lockId)
|
|
132
|
+
};
|
|
133
|
+
const releaseLock = async () => {
|
|
134
|
+
if (!lockId)
|
|
133
135
|
return;
|
|
134
136
|
try {
|
|
135
|
-
await
|
|
136
|
-
TableName:
|
|
137
|
-
Key:
|
|
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
|
-
|
|
147
|
-
if (error.name !== 'ConditionalCheckFailedException') {
|
|
145
|
+
if (error.name !== 'ConditionalCheckFailedException')
|
|
148
146
|
throw error;
|
|
149
|
-
}
|
|
150
147
|
}
|
|
151
148
|
finally {
|
|
152
|
-
|
|
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
|
|
166
|
-
const
|
|
160
|
+
const getAppliedMigrations = async () => {
|
|
161
|
+
const pages = [];
|
|
167
162
|
let lastKey;
|
|
168
163
|
do {
|
|
169
|
-
const result = await
|
|
170
|
-
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
|
-
|
|
178
|
+
pages.push((result.Items ?? []));
|
|
186
179
|
lastKey = result.LastEvaluatedKey;
|
|
187
180
|
} while (lastKey);
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
async
|
|
191
|
-
const result = await
|
|
192
|
-
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
|
|
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
|
|
205
|
-
|
|
217
|
+
const markAsApplied = async (version, name, schemaDefinition, schemaChanges, checksum) => {
|
|
218
|
+
requireLock();
|
|
206
219
|
const now = new Date().toISOString();
|
|
207
|
-
|
|
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
|
|
213
|
-
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(', ');
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
lockOwnershipCheck(),
|
|
234
246
|
{
|
|
235
247
|
Update: {
|
|
236
|
-
TableName:
|
|
237
|
-
Key: {
|
|
238
|
-
|
|
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:
|
|
262
|
+
TableName: tableName,
|
|
255
263
|
Key: {
|
|
256
|
-
PK: `${
|
|
257
|
-
SK: `${
|
|
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
|
-
|
|
272
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
279
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
273
280
|
TransactItems: [
|
|
274
|
-
|
|
281
|
+
lockOwnershipCheck(),
|
|
275
282
|
{
|
|
276
283
|
Put: {
|
|
277
|
-
TableName:
|
|
284
|
+
TableName: tableName,
|
|
278
285
|
Item: {
|
|
279
|
-
PK:
|
|
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:
|
|
304
|
+
TableName: tableName,
|
|
298
305
|
Key: {
|
|
299
|
-
PK: `${
|
|
300
|
-
SK: `${
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
lockOwnershipCheck(),
|
|
380
337
|
{
|
|
381
338
|
Update: {
|
|
382
|
-
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:
|
|
351
|
+
TableName: tableName,
|
|
400
352
|
Key: {
|
|
401
|
-
PK: `${
|
|
402
|
-
SK: `${
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
422
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
372
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
423
373
|
TransactItems: [
|
|
424
|
-
|
|
374
|
+
lockOwnershipCheck(),
|
|
425
375
|
{
|
|
426
376
|
Update: {
|
|
427
|
-
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
|
-
|
|
449
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
395
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
450
396
|
TransactItems: [
|
|
451
|
-
|
|
397
|
+
lockOwnershipCheck(),
|
|
452
398
|
{
|
|
453
399
|
Put: {
|
|
454
|
-
TableName:
|
|
400
|
+
TableName: tableName,
|
|
455
401
|
Item: {
|
|
456
|
-
PK:
|
|
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
|
|
472
|
-
|
|
473
|
-
const currentVersion = await
|
|
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
|
-
|
|
478
|
-
await this.client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
423
|
+
await client.send(new lib_dynamodb_1.TransactWriteCommand({
|
|
479
424
|
TransactItems: [
|
|
480
|
-
|
|
425
|
+
lockOwnershipCheck(),
|
|
481
426
|
{
|
|
482
427
|
Update: {
|
|
483
|
-
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
|
|
499
|
-
const
|
|
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
|
-
|
|
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
|