@ftschopp/dynatable-migrations 1.2.6 → 1.3.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 +7 -0
- package/README.md +12 -0
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/down.d.ts +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +61 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/unlock.d.ts +8 -0
- package/dist/commands/unlock.d.ts.map +1 -0
- package/dist/commands/unlock.js +87 -0
- package/dist/commands/unlock.js.map +1 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +14 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/loader.d.ts +7 -1
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +35 -6
- package/dist/core/loader.js.map +1 -1
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +23 -12
- package/dist/core/tracker.js.map +1 -1
- package/package.json +35 -4
- package/.gitkeep +0 -0
- package/jest.config.js +0 -8
- package/migrations/.gitkeep +0 -0
- package/src/cli-utils.test.ts +0 -45
- package/src/cli-utils.ts +0 -18
- package/src/cli.ts +0 -110
- package/src/commands/create.ts +0 -111
- package/src/commands/down.ts +0 -26
- package/src/commands/init.ts +0 -37
- package/src/commands/status.ts +0 -75
- package/src/commands/up.ts +0 -26
- package/src/core/client.ts +0 -24
- package/src/core/config.ts +0 -140
- package/src/core/errors.ts +0 -55
- package/src/core/loader.ts +0 -256
- package/src/core/lock-heartbeat.test.ts +0 -78
- package/src/core/lock-heartbeat.ts +0 -48
- package/src/core/runner.test.ts +0 -51
- package/src/core/runner.ts +0 -315
- package/src/core/semver.test.ts +0 -33
- package/src/core/semver.ts +0 -26
- package/src/core/tracker.test.ts +0 -281
- package/src/core/tracker.ts +0 -559
- package/src/index.ts +0 -25
- package/src/templates/migration.ts +0 -92
- package/src/types/index.ts +0 -220
- package/tsconfig.json +0 -19
package/src/core/tracker.ts
DELETED
|
@@ -1,559 +0,0 @@
|
|
|
1
|
-
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
2
|
-
import {
|
|
3
|
-
PutCommand,
|
|
4
|
-
GetCommand,
|
|
5
|
-
QueryCommand,
|
|
6
|
-
UpdateCommand,
|
|
7
|
-
DeleteCommand,
|
|
8
|
-
TransactWriteCommand,
|
|
9
|
-
} from '@aws-sdk/lib-dynamodb';
|
|
10
|
-
import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
|
|
11
|
-
import { compareSemver } from './semver';
|
|
12
|
-
import { MigrationAlreadyAppliedError, MigrationLockLostError } from './errors';
|
|
13
|
-
|
|
14
|
-
/** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
|
|
15
|
-
export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
|
|
16
|
-
|
|
17
|
-
export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
18
|
-
private client: DynamoDBDocumentClient;
|
|
19
|
-
private tableName: string;
|
|
20
|
-
private trackingPrefix: string;
|
|
21
|
-
private gsi1Name: string;
|
|
22
|
-
private lockId: string | null = null;
|
|
23
|
-
/** TTL for newly-acquired or refreshed locks. */
|
|
24
|
-
public readonly lockTtlSeconds: number;
|
|
25
|
-
|
|
26
|
-
constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
|
|
27
|
-
this.client = client;
|
|
28
|
-
this.tableName = config.tableName;
|
|
29
|
-
this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
|
|
30
|
-
this.gsi1Name = config.gsi1Name || 'GSI1';
|
|
31
|
-
this.lockTtlSeconds = config.lockTtlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private get lockKey() {
|
|
35
|
-
return {
|
|
36
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
37
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Throws if there is no active lock. Call before any tracker write. */
|
|
42
|
-
private requireLock(): void {
|
|
43
|
-
if (!this.lockId) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
'Tracker has no active lock; refusing to issue tracker mutations. ' +
|
|
46
|
-
'Call acquireLock() first.'
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
|
|
52
|
-
private lockOwnershipCheck() {
|
|
53
|
-
this.requireLock();
|
|
54
|
-
return {
|
|
55
|
-
ConditionCheck: {
|
|
56
|
-
TableName: this.tableName,
|
|
57
|
-
Key: this.lockKey,
|
|
58
|
-
ConditionExpression: 'lockId = :lockId',
|
|
59
|
-
ExpressionAttributeValues: {
|
|
60
|
-
':lockId': this.lockId,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async initialize(): Promise<void> {
|
|
67
|
-
// Check if current version pointer exists, if not create it
|
|
68
|
-
const current = await this.getCurrentVersion();
|
|
69
|
-
if (!current) {
|
|
70
|
-
// Create initial version pointer
|
|
71
|
-
await this.client.send(
|
|
72
|
-
new PutCommand({
|
|
73
|
-
TableName: this.tableName,
|
|
74
|
-
Item: {
|
|
75
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
76
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
77
|
-
GSI1PK: 'SCHEMA#CURRENT',
|
|
78
|
-
GSI1SK: 'v0000',
|
|
79
|
-
currentVersion: 'v0000',
|
|
80
|
-
updatedAt: new Date().toISOString(),
|
|
81
|
-
},
|
|
82
|
-
})
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Acquire a distributed lock to prevent concurrent migrations
|
|
89
|
-
*/
|
|
90
|
-
async acquireLock(): Promise<boolean> {
|
|
91
|
-
const now = Date.now();
|
|
92
|
-
const expiresAt = now + this.lockTtlSeconds * 1000;
|
|
93
|
-
this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
await this.client.send(
|
|
97
|
-
new PutCommand({
|
|
98
|
-
TableName: this.tableName,
|
|
99
|
-
Item: {
|
|
100
|
-
...this.lockKey,
|
|
101
|
-
lockId: this.lockId,
|
|
102
|
-
acquiredAt: new Date().toISOString(),
|
|
103
|
-
expiresAt,
|
|
104
|
-
},
|
|
105
|
-
// Only succeed if lock doesn't exist or has expired. The `<=`
|
|
106
|
-
// covers the boundary where two clocks read the exact ms.
|
|
107
|
-
ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
|
|
108
|
-
ExpressionAttributeValues: {
|
|
109
|
-
':now': now,
|
|
110
|
-
},
|
|
111
|
-
})
|
|
112
|
-
);
|
|
113
|
-
return true;
|
|
114
|
-
} catch (error: any) {
|
|
115
|
-
if (error.name === 'ConditionalCheckFailedException') {
|
|
116
|
-
this.lockId = null;
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
throw error;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Extend the lock's expiration by another `lockTtlSeconds`. Throws
|
|
125
|
-
* `ConditionalCheckFailedException` if another worker has already taken
|
|
126
|
-
* the lock — callers should treat that as "we lost the race; stop
|
|
127
|
-
* making writes". Silent no-op if no lock is currently held.
|
|
128
|
-
*/
|
|
129
|
-
async refreshLock(): Promise<void> {
|
|
130
|
-
if (!this.lockId) return;
|
|
131
|
-
|
|
132
|
-
const newExpiresAt = Date.now() + this.lockTtlSeconds * 1000;
|
|
133
|
-
|
|
134
|
-
await this.client.send(
|
|
135
|
-
new UpdateCommand({
|
|
136
|
-
TableName: this.tableName,
|
|
137
|
-
Key: this.lockKey,
|
|
138
|
-
UpdateExpression: 'SET expiresAt = :exp',
|
|
139
|
-
ConditionExpression: 'lockId = :lockId',
|
|
140
|
-
ExpressionAttributeValues: {
|
|
141
|
-
':exp': newExpiresAt,
|
|
142
|
-
':lockId': this.lockId,
|
|
143
|
-
},
|
|
144
|
-
})
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Release the distributed lock
|
|
150
|
-
*/
|
|
151
|
-
async releaseLock(): Promise<void> {
|
|
152
|
-
if (!this.lockId) return;
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
await this.client.send(
|
|
156
|
-
new DeleteCommand({
|
|
157
|
-
TableName: this.tableName,
|
|
158
|
-
Key: this.lockKey,
|
|
159
|
-
// Only delete if we own the lock
|
|
160
|
-
ConditionExpression: 'lockId = :lockId',
|
|
161
|
-
ExpressionAttributeValues: {
|
|
162
|
-
':lockId': this.lockId,
|
|
163
|
-
},
|
|
164
|
-
})
|
|
165
|
-
);
|
|
166
|
-
} catch (error: any) {
|
|
167
|
-
// Lock may have expired or been taken by someone else
|
|
168
|
-
if (error.name !== 'ConditionalCheckFailedException') {
|
|
169
|
-
throw error;
|
|
170
|
-
}
|
|
171
|
-
} finally {
|
|
172
|
-
this.lockId = null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Get all applied migrations with pagination support
|
|
178
|
-
*/
|
|
179
|
-
async getAppliedMigrations(): Promise<MigrationRecord[]> {
|
|
180
|
-
const items: MigrationRecord[] = [];
|
|
181
|
-
let lastKey: Record<string, any> | undefined;
|
|
182
|
-
|
|
183
|
-
do {
|
|
184
|
-
const result = await this.client.send(
|
|
185
|
-
new QueryCommand({
|
|
186
|
-
TableName: this.tableName,
|
|
187
|
-
KeyConditionExpression: 'PK = :pk',
|
|
188
|
-
ExpressionAttributeValues: {
|
|
189
|
-
':pk': this.trackingPrefix,
|
|
190
|
-
},
|
|
191
|
-
ExclusiveStartKey: lastKey,
|
|
192
|
-
})
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
items.push(...((result.Items || []) as MigrationRecord[]));
|
|
196
|
-
lastKey = result.LastEvaluatedKey;
|
|
197
|
-
} while (lastKey);
|
|
198
|
-
|
|
199
|
-
return items;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async getCurrentVersion(): Promise<string | null> {
|
|
203
|
-
const result = await this.client.send(
|
|
204
|
-
new GetCommand({
|
|
205
|
-
TableName: this.tableName,
|
|
206
|
-
Key: {
|
|
207
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
208
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
209
|
-
},
|
|
210
|
-
})
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
return result.Item?.currentVersion || null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Mark migration as applied using TransactWrite for atomicity
|
|
218
|
-
* Handles both new migrations and re-applying rolled back migrations
|
|
219
|
-
*/
|
|
220
|
-
async markAsApplied(
|
|
221
|
-
version: string,
|
|
222
|
-
name: string,
|
|
223
|
-
schemaDefinition?: Record<string, any>,
|
|
224
|
-
schemaChanges?: SchemaChange[],
|
|
225
|
-
checksum?: string
|
|
226
|
-
): Promise<void> {
|
|
227
|
-
this.requireLock();
|
|
228
|
-
const now = new Date().toISOString();
|
|
229
|
-
|
|
230
|
-
// Check if migration record already exists (e.g., rolled back or failed)
|
|
231
|
-
const existing = await this.getMigration(version);
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
if (existing) {
|
|
235
|
-
// Update existing record (re-applying a rolled back or failed migration)
|
|
236
|
-
// Build dynamic update expression - DynamoDB doesn't accept undefined values
|
|
237
|
-
const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
|
|
238
|
-
const expressionValues: Record<string, any> = {
|
|
239
|
-
':status': 'applied',
|
|
240
|
-
':appliedAt': now,
|
|
241
|
-
':name': name,
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
if (schemaDefinition !== undefined) {
|
|
245
|
-
setParts.push('schemaDefinition = :schemaDef');
|
|
246
|
-
expressionValues[':schemaDef'] = schemaDefinition;
|
|
247
|
-
}
|
|
248
|
-
if (schemaChanges !== undefined) {
|
|
249
|
-
setParts.push('schemaChanges = :schemaChanges');
|
|
250
|
-
expressionValues[':schemaChanges'] = schemaChanges;
|
|
251
|
-
}
|
|
252
|
-
if (checksum !== undefined) {
|
|
253
|
-
setParts.push('checksum = :checksum');
|
|
254
|
-
expressionValues[':checksum'] = checksum;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
await this.client.send(
|
|
258
|
-
new TransactWriteCommand({
|
|
259
|
-
TransactItems: [
|
|
260
|
-
this.lockOwnershipCheck(),
|
|
261
|
-
{
|
|
262
|
-
Update: {
|
|
263
|
-
TableName: this.tableName,
|
|
264
|
-
Key: {
|
|
265
|
-
PK: this.trackingPrefix,
|
|
266
|
-
SK: version,
|
|
267
|
-
},
|
|
268
|
-
UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
|
|
269
|
-
ExpressionAttributeNames: {
|
|
270
|
-
'#status': 'status',
|
|
271
|
-
'#name': 'name',
|
|
272
|
-
'#error': 'error',
|
|
273
|
-
},
|
|
274
|
-
ExpressionAttributeValues: expressionValues,
|
|
275
|
-
// Only allow re-applying if not currently applied
|
|
276
|
-
ConditionExpression: '#status <> :status',
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
Update: {
|
|
281
|
-
TableName: this.tableName,
|
|
282
|
-
Key: {
|
|
283
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
284
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
285
|
-
},
|
|
286
|
-
UpdateExpression:
|
|
287
|
-
'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
|
|
288
|
-
ExpressionAttributeValues: {
|
|
289
|
-
':version': version,
|
|
290
|
-
':updatedAt': now,
|
|
291
|
-
':gsi1sk': version,
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
],
|
|
296
|
-
})
|
|
297
|
-
);
|
|
298
|
-
} else {
|
|
299
|
-
// Create new migration record
|
|
300
|
-
await this.client.send(
|
|
301
|
-
new TransactWriteCommand({
|
|
302
|
-
TransactItems: [
|
|
303
|
-
this.lockOwnershipCheck(),
|
|
304
|
-
{
|
|
305
|
-
Put: {
|
|
306
|
-
TableName: this.tableName,
|
|
307
|
-
Item: {
|
|
308
|
-
PK: this.trackingPrefix,
|
|
309
|
-
SK: version,
|
|
310
|
-
version,
|
|
311
|
-
name,
|
|
312
|
-
timestamp: now,
|
|
313
|
-
appliedAt: now,
|
|
314
|
-
status: 'applied',
|
|
315
|
-
schemaDefinition,
|
|
316
|
-
schemaChanges,
|
|
317
|
-
checksum,
|
|
318
|
-
} as MigrationRecord,
|
|
319
|
-
// Ensure migration wasn't already applied (uniqueness on
|
|
320
|
-
// SK; PK is shared across all migration rows).
|
|
321
|
-
ConditionExpression: 'attribute_not_exists(SK)',
|
|
322
|
-
},
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
Update: {
|
|
326
|
-
TableName: this.tableName,
|
|
327
|
-
Key: {
|
|
328
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
329
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
330
|
-
},
|
|
331
|
-
UpdateExpression:
|
|
332
|
-
'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
|
|
333
|
-
ExpressionAttributeValues: {
|
|
334
|
-
':version': version,
|
|
335
|
-
':updatedAt': now,
|
|
336
|
-
':gsi1sk': version,
|
|
337
|
-
},
|
|
338
|
-
},
|
|
339
|
-
},
|
|
340
|
-
],
|
|
341
|
-
})
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
} catch (err: unknown) {
|
|
345
|
-
await this.handleMarkAsAppliedCancellation(err, version);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Inspect a `TransactionCanceledException` raised by `markAsApplied` and
|
|
351
|
-
* either swallow it (idempotent re-apply) or re-throw a typed error.
|
|
352
|
-
*
|
|
353
|
-
* The TransactWrite items are: [lockOwnershipCheck, migrationRow, currentPointer].
|
|
354
|
-
* Each item produces an entry in `CancellationReasons`.
|
|
355
|
-
* - reasons[0] failing → the lock was lost → MigrationLockLostError.
|
|
356
|
-
* - reasons[1] failing → the migration row already exists in a state the
|
|
357
|
-
* write refused. Re-fetch the record:
|
|
358
|
-
* * status === 'applied' → idempotent re-apply, return silently.
|
|
359
|
-
* * other states → MigrationAlreadyAppliedError with the
|
|
360
|
-
* current state, so the caller can decide what to do.
|
|
361
|
-
*/
|
|
362
|
-
private async handleMarkAsAppliedCancellation(
|
|
363
|
-
err: unknown,
|
|
364
|
-
version: string
|
|
365
|
-
): Promise<void> {
|
|
366
|
-
const error = err as { name?: string; CancellationReasons?: Array<{ Code?: string }> } | null;
|
|
367
|
-
if (error?.name !== 'TransactionCanceledException') {
|
|
368
|
-
throw err;
|
|
369
|
-
}
|
|
370
|
-
const reasons = error.CancellationReasons ?? [];
|
|
371
|
-
const lockReason = reasons[0];
|
|
372
|
-
const migrationReason = reasons[1];
|
|
373
|
-
|
|
374
|
-
if (lockReason?.Code === 'ConditionalCheckFailed') {
|
|
375
|
-
throw new MigrationLockLostError(version);
|
|
376
|
-
}
|
|
377
|
-
if (migrationReason?.Code === 'ConditionalCheckFailed') {
|
|
378
|
-
const current = await this.getMigration(version);
|
|
379
|
-
if (current?.status === 'applied') {
|
|
380
|
-
// Either we re-tried our own write or another worker applied the same
|
|
381
|
-
// version. Either way, the desired post-state is already satisfied.
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
throw new MigrationAlreadyAppliedError(version, current?.status);
|
|
385
|
-
}
|
|
386
|
-
throw err;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Mark migration as rolled back using TransactWrite for atomicity
|
|
391
|
-
*/
|
|
392
|
-
async markAsRolledBack(version: string): Promise<void> {
|
|
393
|
-
this.requireLock();
|
|
394
|
-
const now = new Date().toISOString();
|
|
395
|
-
|
|
396
|
-
// Find previous version
|
|
397
|
-
const migrations = await this.getAppliedMigrations();
|
|
398
|
-
const appliedMigrations = migrations
|
|
399
|
-
.filter((m) => m.status === 'applied' && m.version !== version)
|
|
400
|
-
.sort((a, b) => compareSemver(b.version, a.version));
|
|
401
|
-
|
|
402
|
-
const previousVersion = appliedMigrations[0]?.version || 'v0000';
|
|
403
|
-
|
|
404
|
-
// Use TransactWrite to atomically update record and pointer
|
|
405
|
-
await this.client.send(
|
|
406
|
-
new TransactWriteCommand({
|
|
407
|
-
TransactItems: [
|
|
408
|
-
this.lockOwnershipCheck(),
|
|
409
|
-
{
|
|
410
|
-
Update: {
|
|
411
|
-
TableName: this.tableName,
|
|
412
|
-
Key: {
|
|
413
|
-
PK: this.trackingPrefix,
|
|
414
|
-
SK: version,
|
|
415
|
-
},
|
|
416
|
-
UpdateExpression: 'SET #status = :status, rolledBackAt = :timestamp',
|
|
417
|
-
ExpressionAttributeNames: {
|
|
418
|
-
'#status': 'status',
|
|
419
|
-
},
|
|
420
|
-
ExpressionAttributeValues: {
|
|
421
|
-
':status': 'rolled_back',
|
|
422
|
-
':timestamp': now,
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
},
|
|
426
|
-
{
|
|
427
|
-
Update: {
|
|
428
|
-
TableName: this.tableName,
|
|
429
|
-
Key: {
|
|
430
|
-
PK: `${this.trackingPrefix}#CURRENT`,
|
|
431
|
-
SK: `${this.trackingPrefix}#CURRENT`,
|
|
432
|
-
},
|
|
433
|
-
UpdateExpression:
|
|
434
|
-
'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
|
|
435
|
-
ExpressionAttributeValues: {
|
|
436
|
-
':version': previousVersion,
|
|
437
|
-
':updatedAt': now,
|
|
438
|
-
':gsi1sk': previousVersion,
|
|
439
|
-
},
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
],
|
|
443
|
-
})
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
async markAsFailed(version: string, error: string): Promise<void> {
|
|
448
|
-
this.requireLock();
|
|
449
|
-
// Check if the migration record exists first
|
|
450
|
-
const existing = await this.getMigration(version);
|
|
451
|
-
const now = new Date().toISOString();
|
|
452
|
-
|
|
453
|
-
if (existing) {
|
|
454
|
-
// Update existing record, gated by lock ownership
|
|
455
|
-
await this.client.send(
|
|
456
|
-
new TransactWriteCommand({
|
|
457
|
-
TransactItems: [
|
|
458
|
-
this.lockOwnershipCheck(),
|
|
459
|
-
{
|
|
460
|
-
Update: {
|
|
461
|
-
TableName: this.tableName,
|
|
462
|
-
Key: {
|
|
463
|
-
PK: this.trackingPrefix,
|
|
464
|
-
SK: version,
|
|
465
|
-
},
|
|
466
|
-
UpdateExpression:
|
|
467
|
-
'SET #status = :status, #error = :error, failedAt = :timestamp',
|
|
468
|
-
ExpressionAttributeNames: {
|
|
469
|
-
'#status': 'status',
|
|
470
|
-
'#error': 'error',
|
|
471
|
-
},
|
|
472
|
-
ExpressionAttributeValues: {
|
|
473
|
-
':status': 'failed',
|
|
474
|
-
':error': error,
|
|
475
|
-
':timestamp': now,
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
},
|
|
479
|
-
],
|
|
480
|
-
})
|
|
481
|
-
);
|
|
482
|
-
} else {
|
|
483
|
-
// Create new record for failed migration, gated by lock ownership
|
|
484
|
-
await this.client.send(
|
|
485
|
-
new TransactWriteCommand({
|
|
486
|
-
TransactItems: [
|
|
487
|
-
this.lockOwnershipCheck(),
|
|
488
|
-
{
|
|
489
|
-
Put: {
|
|
490
|
-
TableName: this.tableName,
|
|
491
|
-
Item: {
|
|
492
|
-
PK: this.trackingPrefix,
|
|
493
|
-
SK: version,
|
|
494
|
-
version,
|
|
495
|
-
name: 'unknown',
|
|
496
|
-
status: 'failed',
|
|
497
|
-
error,
|
|
498
|
-
failedAt: now,
|
|
499
|
-
timestamp: now,
|
|
500
|
-
},
|
|
501
|
-
},
|
|
502
|
-
},
|
|
503
|
-
],
|
|
504
|
-
})
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
async recordSchemaChange(change: SchemaChange): Promise<void> {
|
|
510
|
-
this.requireLock();
|
|
511
|
-
const currentVersion = await this.getCurrentVersion();
|
|
512
|
-
if (!currentVersion || currentVersion === 'v0000') {
|
|
513
|
-
throw new Error('Cannot record schema change: No migration has been applied yet');
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Append to schemaChanges array, gated by lock ownership
|
|
517
|
-
await this.client.send(
|
|
518
|
-
new TransactWriteCommand({
|
|
519
|
-
TransactItems: [
|
|
520
|
-
this.lockOwnershipCheck(),
|
|
521
|
-
{
|
|
522
|
-
Update: {
|
|
523
|
-
TableName: this.tableName,
|
|
524
|
-
Key: {
|
|
525
|
-
PK: this.trackingPrefix,
|
|
526
|
-
SK: currentVersion,
|
|
527
|
-
},
|
|
528
|
-
UpdateExpression:
|
|
529
|
-
'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
|
|
530
|
-
ExpressionAttributeValues: {
|
|
531
|
-
':empty_list': [],
|
|
532
|
-
':change': [change],
|
|
533
|
-
},
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
],
|
|
537
|
-
})
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async getMigration(version: string): Promise<MigrationRecord | null> {
|
|
542
|
-
const result = await this.client.send(
|
|
543
|
-
new GetCommand({
|
|
544
|
-
TableName: this.tableName,
|
|
545
|
-
Key: {
|
|
546
|
-
PK: this.trackingPrefix,
|
|
547
|
-
SK: version,
|
|
548
|
-
},
|
|
549
|
-
})
|
|
550
|
-
);
|
|
551
|
-
|
|
552
|
-
return (result.Item as MigrationRecord) || null;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
async isApplied(version: string): Promise<boolean> {
|
|
556
|
-
const migration = await this.getMigration(version);
|
|
557
|
-
return migration?.status === 'applied';
|
|
558
|
-
}
|
|
559
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @ftschopp/dynatable-migrations
|
|
3
|
-
* DynamoDB migration tool for single table design with schema versioning
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// Types
|
|
7
|
-
export * from './types';
|
|
8
|
-
|
|
9
|
-
// Core
|
|
10
|
-
export { DynamoDBMigrationTracker } from './core/tracker';
|
|
11
|
-
export { MigrationLoader } from './core/loader';
|
|
12
|
-
export { MigrationRunner, type RunOptions } from './core/runner';
|
|
13
|
-
export { ConfigLoader, loadConfig } from './core/config';
|
|
14
|
-
export { createDynamoDBClient } from './core/client';
|
|
15
|
-
export { MigrationAlreadyAppliedError, MigrationLockLostError } from './core/errors';
|
|
16
|
-
|
|
17
|
-
// Template
|
|
18
|
-
export { generateMigrationTemplate } from './templates/migration';
|
|
19
|
-
|
|
20
|
-
// Commands (for programmatic use)
|
|
21
|
-
export { createMigration } from './commands/create';
|
|
22
|
-
export { runMigrations } from './commands/up';
|
|
23
|
-
export { rollbackMigrations } from './commands/down';
|
|
24
|
-
export { showStatus } from './commands/status';
|
|
25
|
-
export { initProject } from './commands/init';
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration template generator
|
|
3
|
-
*/
|
|
4
|
-
export function generateMigrationTemplate(version: string, name: string): string {
|
|
5
|
-
return `import { Migration } from "@ftschopp/dynatable-migrations";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Migration: ${name}
|
|
9
|
-
* Version: ${version}
|
|
10
|
-
*
|
|
11
|
-
* Description:
|
|
12
|
-
* Add description of what this migration does here.
|
|
13
|
-
*
|
|
14
|
-
* Version type:
|
|
15
|
-
* - MAJOR (X.0.0): Breaking changes, incompatible schema changes
|
|
16
|
-
* - MINOR (0.X.0): New features, backwards-compatible changes
|
|
17
|
-
* - PATCH (0.0.X): Bug fixes, data corrections
|
|
18
|
-
*/
|
|
19
|
-
export const migration: Migration = {
|
|
20
|
-
version: "${version}",
|
|
21
|
-
name: "${name}",
|
|
22
|
-
description: "Add description here",
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Schema snapshot (optional)
|
|
26
|
-
* Document your schema changes here for reference
|
|
27
|
-
*/
|
|
28
|
-
schema: {
|
|
29
|
-
// Example:
|
|
30
|
-
// User: {
|
|
31
|
-
// attributes: {
|
|
32
|
-
// username: { type: String, required: true },
|
|
33
|
-
// email: { type: String, required: false },
|
|
34
|
-
// }
|
|
35
|
-
// }
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Apply migration
|
|
40
|
-
*/
|
|
41
|
-
async up({ client, tableName, dynamodb }) {
|
|
42
|
-
console.log("Running migration: ${name}");
|
|
43
|
-
|
|
44
|
-
// Example: Add a new attribute to existing items
|
|
45
|
-
// const { ScanCommand, UpdateCommand } = dynamodb;
|
|
46
|
-
//
|
|
47
|
-
// const result = await client.send(new ScanCommand({
|
|
48
|
-
// TableName: tableName,
|
|
49
|
-
// FilterExpression: "begins_with(PK, :pk)",
|
|
50
|
-
// ExpressionAttributeValues: { ":pk": "USER#" }
|
|
51
|
-
// }));
|
|
52
|
-
//
|
|
53
|
-
// for (const item of result.Items || []) {
|
|
54
|
-
// await client.send(new UpdateCommand({
|
|
55
|
-
// TableName: tableName,
|
|
56
|
-
// Key: { PK: item.PK, SK: item.SK },
|
|
57
|
-
// UpdateExpression: "SET emailVerified = :value",
|
|
58
|
-
// ExpressionAttributeValues: { ":value": false }
|
|
59
|
-
// }));
|
|
60
|
-
// }
|
|
61
|
-
|
|
62
|
-
console.log("✅ Migration completed");
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Rollback migration
|
|
67
|
-
*/
|
|
68
|
-
async down({ client, tableName, dynamodb }) {
|
|
69
|
-
console.log("Rolling back migration: ${name}");
|
|
70
|
-
|
|
71
|
-
// Example: Remove the attribute added in up()
|
|
72
|
-
// const { ScanCommand, UpdateCommand } = dynamodb;
|
|
73
|
-
//
|
|
74
|
-
// const result = await client.send(new ScanCommand({
|
|
75
|
-
// TableName: tableName,
|
|
76
|
-
// FilterExpression: "begins_with(PK, :pk)",
|
|
77
|
-
// ExpressionAttributeValues: { ":pk": "USER#" }
|
|
78
|
-
// }));
|
|
79
|
-
//
|
|
80
|
-
// for (const item of result.Items || []) {
|
|
81
|
-
// await client.send(new UpdateCommand({
|
|
82
|
-
// TableName: tableName,
|
|
83
|
-
// Key: { PK: item.PK, SK: item.SK },
|
|
84
|
-
// UpdateExpression: "REMOVE email, emailVerified"
|
|
85
|
-
// }));
|
|
86
|
-
// }
|
|
87
|
-
|
|
88
|
-
console.log("✅ Rollback completed");
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
`;
|
|
92
|
-
}
|