@ftschopp/dynatable-migrations 1.2.2 → 1.2.4
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 +14 -0
- package/dist/core/loader.d.ts +0 -5
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +2 -18
- package/dist/core/loader.js.map +1 -1
- package/dist/core/lock-heartbeat.d.ts +15 -0
- package/dist/core/lock-heartbeat.d.ts.map +1 -0
- package/dist/core/lock-heartbeat.js +44 -0
- package/dist/core/lock-heartbeat.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +13 -2
- package/dist/core/runner.js.map +1 -1
- package/dist/core/semver.d.ts +14 -0
- package/dist/core/semver.d.ts.map +1 -0
- package/dist/core/semver.js +29 -0
- package/dist/core/semver.js.map +1 -0
- package/dist/core/tracker.d.ts +16 -0
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +134 -60
- package/dist/core/tracker.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/jest.config.js +8 -0
- package/package.json +3 -2
- package/src/core/loader.ts +2 -19
- package/src/core/lock-heartbeat.test.ts +78 -0
- package/src/core/lock-heartbeat.ts +48 -0
- package/src/core/runner.ts +11 -2
- package/src/core/semver.test.ts +33 -0
- package/src/core/semver.ts +26 -0
- package/src/core/tracker.test.ts +150 -0
- package/src/core/tracker.ts +146 -62
- package/src/types/index.ts +12 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { compareSemver } from './semver';
|
|
2
|
+
|
|
3
|
+
describe('compareSemver', () => {
|
|
4
|
+
test('returns -1 when a < b across the minor digit boundary (the original bug)', () => {
|
|
5
|
+
expect(compareSemver('0.9.0', '0.10.0')).toBe(-1);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
test('returns 1 when a > b across the minor digit boundary', () => {
|
|
9
|
+
expect(compareSemver('0.10.0', '0.9.0')).toBe(1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('returns -1 when a < b across the patch digit boundary', () => {
|
|
13
|
+
expect(compareSemver('0.1.0', '0.1.10')).toBe(-1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('handles major beats minor', () => {
|
|
17
|
+
expect(compareSemver('1.0.0', '0.99.99')).toBe(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('returns 0 for equal versions', () => {
|
|
21
|
+
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('sorts a list of versions ascending in the correct order', () => {
|
|
25
|
+
const sorted = ['0.10.0', '1.0.0', '0.1.0', '0.9.0'].sort(compareSemver);
|
|
26
|
+
expect(sorted).toEqual(['0.1.0', '0.9.0', '0.10.0', '1.0.0']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('sorts a list of versions descending when the comparator is inverted', () => {
|
|
30
|
+
const sorted = ['0.10.0', '1.0.0', '0.1.0', '0.9.0'].sort((a, b) => compareSemver(b, a));
|
|
31
|
+
expect(sorted).toEqual(['1.0.0', '0.10.0', '0.9.0', '0.1.0']);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two semver versions of the form `MAJOR.MINOR.PATCH`.
|
|
3
|
+
*
|
|
4
|
+
* Returns:
|
|
5
|
+
* -1 if `a < b`
|
|
6
|
+
* 0 if `a === b`
|
|
7
|
+
* 1 if `a > b`
|
|
8
|
+
*
|
|
9
|
+
* Designed for migration filenames (`0.1.0_name.ts`) which the loader
|
|
10
|
+
* already validates as strict three-segment semver — prerelease and build
|
|
11
|
+
* metadata are not handled.
|
|
12
|
+
*/
|
|
13
|
+
export function compareSemver(a: string, b: string): number {
|
|
14
|
+
const partsA = a.split('.').map((n) => parseInt(n, 10));
|
|
15
|
+
const partsB = b.split('.').map((n) => parseInt(n, 10));
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
const numA = partsA[i] || 0;
|
|
19
|
+
const numB = partsB[i] || 0;
|
|
20
|
+
|
|
21
|
+
if (numA > numB) return 1;
|
|
22
|
+
if (numA < numB) return -1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
import {
|
|
4
|
+
DynamoDBDocumentClient,
|
|
5
|
+
GetCommand,
|
|
6
|
+
PutCommand,
|
|
7
|
+
UpdateCommand,
|
|
8
|
+
TransactWriteCommand,
|
|
9
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
10
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
11
|
+
import { DynamoDBMigrationTracker, DEFAULT_LOCK_TTL_SECONDS } from './tracker';
|
|
12
|
+
import type { MigrationConfig } from '../types';
|
|
13
|
+
|
|
14
|
+
const ddbMock = mockClient(DynamoDBDocumentClient);
|
|
15
|
+
|
|
16
|
+
const baseConfig: MigrationConfig = {
|
|
17
|
+
tableName: 'TestTable',
|
|
18
|
+
client: { region: 'us-east-1' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeTracker(config: Partial<MigrationConfig> = {}) {
|
|
22
|
+
const client = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'us-east-1' }));
|
|
23
|
+
return new DynamoDBMigrationTracker(client, { ...baseConfig, ...config });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
ddbMock.reset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('DynamoDBMigrationTracker - lock TTL', () => {
|
|
31
|
+
test('uses the default 5-minute TTL when none is configured', () => {
|
|
32
|
+
const tracker = makeTracker();
|
|
33
|
+
expect(tracker.lockTtlSeconds).toBe(DEFAULT_LOCK_TTL_SECONDS);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('honors a custom lockTtlSeconds from config', () => {
|
|
37
|
+
const tracker = makeTracker({ lockTtlSeconds: 30 });
|
|
38
|
+
expect(tracker.lockTtlSeconds).toBe(30);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('DynamoDBMigrationTracker - acquireLock', () => {
|
|
43
|
+
test('uses <= for the expiry boundary so simultaneous-ms expiries are takeable', async () => {
|
|
44
|
+
ddbMock.on(PutCommand).resolves({});
|
|
45
|
+
const tracker = makeTracker();
|
|
46
|
+
|
|
47
|
+
await tracker.acquireLock();
|
|
48
|
+
|
|
49
|
+
const calls = ddbMock.commandCalls(PutCommand);
|
|
50
|
+
expect(calls).toHaveLength(1);
|
|
51
|
+
const expr = (calls[0]!.args[0].input as any).ConditionExpression as string;
|
|
52
|
+
expect(expr).toContain('expiresAt <= :now');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns false when the lock row exists and has not expired', async () => {
|
|
56
|
+
const err = Object.assign(new Error('cond failed'), {
|
|
57
|
+
name: 'ConditionalCheckFailedException',
|
|
58
|
+
});
|
|
59
|
+
ddbMock.on(PutCommand).rejects(err);
|
|
60
|
+
const tracker = makeTracker();
|
|
61
|
+
|
|
62
|
+
await expect(tracker.acquireLock()).resolves.toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('DynamoDBMigrationTracker - refreshLock', () => {
|
|
67
|
+
test('extends the lock with a ConditionExpression on the current lockId', async () => {
|
|
68
|
+
ddbMock.on(PutCommand).resolves({});
|
|
69
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
70
|
+
|
|
71
|
+
const tracker = makeTracker({ lockTtlSeconds: 60 });
|
|
72
|
+
await tracker.acquireLock();
|
|
73
|
+
await tracker.refreshLock();
|
|
74
|
+
|
|
75
|
+
const updates = ddbMock.commandCalls(UpdateCommand);
|
|
76
|
+
expect(updates).toHaveLength(1);
|
|
77
|
+
const input = updates[0]!.args[0].input as any;
|
|
78
|
+
expect(input.ConditionExpression).toBe('lockId = :lockId');
|
|
79
|
+
expect(typeof input.ExpressionAttributeValues[':lockId']).toBe('string');
|
|
80
|
+
expect(typeof input.ExpressionAttributeValues[':exp']).toBe('number');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('throws when the lockId no longer matches (lost race)', async () => {
|
|
84
|
+
ddbMock.on(PutCommand).resolves({});
|
|
85
|
+
const err = Object.assign(new Error('cond failed'), {
|
|
86
|
+
name: 'ConditionalCheckFailedException',
|
|
87
|
+
});
|
|
88
|
+
ddbMock.on(UpdateCommand).rejects(err);
|
|
89
|
+
|
|
90
|
+
const tracker = makeTracker();
|
|
91
|
+
await tracker.acquireLock();
|
|
92
|
+
|
|
93
|
+
await expect(tracker.refreshLock()).rejects.toMatchObject({
|
|
94
|
+
name: 'ConditionalCheckFailedException',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('is a silent no-op when no lock has been acquired yet', async () => {
|
|
99
|
+
const tracker = makeTracker();
|
|
100
|
+
await expect(tracker.refreshLock()).resolves.toBeUndefined();
|
|
101
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('DynamoDBMigrationTracker - tracker writes are gated by lock ownership', () => {
|
|
106
|
+
test('markAsApplied (new record path) emits a ConditionCheck on the lock row', async () => {
|
|
107
|
+
ddbMock.on(PutCommand).resolves({}); // acquireLock
|
|
108
|
+
ddbMock.on(TransactWriteCommand).resolves({});
|
|
109
|
+
// getMigration returns no existing record → take the Put branch
|
|
110
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
111
|
+
|
|
112
|
+
const tracker = makeTracker();
|
|
113
|
+
await tracker.acquireLock();
|
|
114
|
+
await tracker.markAsApplied('0.1.0', 'init');
|
|
115
|
+
|
|
116
|
+
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
117
|
+
expect(tx).toHaveLength(1);
|
|
118
|
+
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
119
|
+
// First item should be the lock-row ConditionCheck
|
|
120
|
+
expect(items[0].ConditionCheck).toBeDefined();
|
|
121
|
+
expect(items[0].ConditionCheck.ConditionExpression).toBe('lockId = :lockId');
|
|
122
|
+
expect(items[0].ConditionCheck.Key).toEqual({
|
|
123
|
+
PK: '_SCHEMA#VERSION#LOCK',
|
|
124
|
+
SK: '_SCHEMA#VERSION#LOCK',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('markAsApplied throws when no lock has been acquired', async () => {
|
|
129
|
+
const tracker = makeTracker();
|
|
130
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toThrow(
|
|
131
|
+
/Tracker has no active lock/i
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('markAsApplied (new record path) uses attribute_not_exists(SK), not (PK)', async () => {
|
|
136
|
+
ddbMock.on(PutCommand).resolves({});
|
|
137
|
+
ddbMock.on(TransactWriteCommand).resolves({});
|
|
138
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
139
|
+
|
|
140
|
+
const tracker = makeTracker();
|
|
141
|
+
await tracker.acquireLock();
|
|
142
|
+
await tracker.markAsApplied('0.1.0', 'init');
|
|
143
|
+
|
|
144
|
+
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
145
|
+
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
146
|
+
// The Put is the second item (index 1) after the ConditionCheck
|
|
147
|
+
const put = items.find((i) => i.Put);
|
|
148
|
+
expect(put.Put.ConditionExpression).toBe('attribute_not_exists(SK)');
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/core/tracker.ts
CHANGED
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
TransactWriteCommand,
|
|
9
9
|
} from '@aws-sdk/lib-dynamodb';
|
|
10
10
|
import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
|
|
11
|
+
import { compareSemver } from './semver';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
|
|
14
|
+
export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
|
|
13
15
|
|
|
14
16
|
export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
15
17
|
private client: DynamoDBDocumentClient;
|
|
@@ -17,12 +19,47 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
17
19
|
private trackingPrefix: string;
|
|
18
20
|
private gsi1Name: string;
|
|
19
21
|
private lockId: string | null = null;
|
|
22
|
+
/** TTL for newly-acquired or refreshed locks. */
|
|
23
|
+
public readonly lockTtlSeconds: number;
|
|
20
24
|
|
|
21
25
|
constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
|
|
22
26
|
this.client = client;
|
|
23
27
|
this.tableName = config.tableName;
|
|
24
28
|
this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
|
|
25
29
|
this.gsi1Name = config.gsi1Name || 'GSI1';
|
|
30
|
+
this.lockTtlSeconds = config.lockTtlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private get lockKey() {
|
|
34
|
+
return {
|
|
35
|
+
PK: `${this.trackingPrefix}#LOCK`,
|
|
36
|
+
SK: `${this.trackingPrefix}#LOCK`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Throws if there is no active lock. Call before any tracker write. */
|
|
41
|
+
private requireLock(): void {
|
|
42
|
+
if (!this.lockId) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Tracker has no active lock; refusing to issue tracker mutations. ' +
|
|
45
|
+
'Call acquireLock() first.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
|
|
51
|
+
private lockOwnershipCheck() {
|
|
52
|
+
this.requireLock();
|
|
53
|
+
return {
|
|
54
|
+
ConditionCheck: {
|
|
55
|
+
TableName: this.tableName,
|
|
56
|
+
Key: this.lockKey,
|
|
57
|
+
ConditionExpression: 'lockId = :lockId',
|
|
58
|
+
ExpressionAttributeValues: {
|
|
59
|
+
':lockId': this.lockId,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
async initialize(): Promise<void> {
|
|
@@ -50,13 +87,8 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
50
87
|
* Acquire a distributed lock to prevent concurrent migrations
|
|
51
88
|
*/
|
|
52
89
|
async acquireLock(): Promise<boolean> {
|
|
53
|
-
const lockKey = {
|
|
54
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
55
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
90
|
const now = Date.now();
|
|
59
|
-
const expiresAt = now +
|
|
91
|
+
const expiresAt = now + this.lockTtlSeconds * 1000;
|
|
60
92
|
this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
|
|
61
93
|
|
|
62
94
|
try {
|
|
@@ -64,13 +96,14 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
64
96
|
new PutCommand({
|
|
65
97
|
TableName: this.tableName,
|
|
66
98
|
Item: {
|
|
67
|
-
...lockKey,
|
|
99
|
+
...this.lockKey,
|
|
68
100
|
lockId: this.lockId,
|
|
69
101
|
acquiredAt: new Date().toISOString(),
|
|
70
102
|
expiresAt,
|
|
71
103
|
},
|
|
72
|
-
// Only succeed if lock doesn't exist or has expired
|
|
73
|
-
|
|
104
|
+
// Only succeed if lock doesn't exist or has expired. The `<=`
|
|
105
|
+
// covers the boundary where two clocks read the exact ms.
|
|
106
|
+
ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
|
|
74
107
|
ExpressionAttributeValues: {
|
|
75
108
|
':now': now,
|
|
76
109
|
},
|
|
@@ -86,22 +119,42 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
86
119
|
}
|
|
87
120
|
}
|
|
88
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Extend the lock's expiration by another `lockTtlSeconds`. Throws
|
|
124
|
+
* `ConditionalCheckFailedException` if another worker has already taken
|
|
125
|
+
* the lock — callers should treat that as "we lost the race; stop
|
|
126
|
+
* making writes". Silent no-op if no lock is currently held.
|
|
127
|
+
*/
|
|
128
|
+
async refreshLock(): Promise<void> {
|
|
129
|
+
if (!this.lockId) return;
|
|
130
|
+
|
|
131
|
+
const newExpiresAt = Date.now() + this.lockTtlSeconds * 1000;
|
|
132
|
+
|
|
133
|
+
await this.client.send(
|
|
134
|
+
new UpdateCommand({
|
|
135
|
+
TableName: this.tableName,
|
|
136
|
+
Key: this.lockKey,
|
|
137
|
+
UpdateExpression: 'SET expiresAt = :exp',
|
|
138
|
+
ConditionExpression: 'lockId = :lockId',
|
|
139
|
+
ExpressionAttributeValues: {
|
|
140
|
+
':exp': newExpiresAt,
|
|
141
|
+
':lockId': this.lockId,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
89
147
|
/**
|
|
90
148
|
* Release the distributed lock
|
|
91
149
|
*/
|
|
92
150
|
async releaseLock(): Promise<void> {
|
|
93
151
|
if (!this.lockId) return;
|
|
94
152
|
|
|
95
|
-
const lockKey = {
|
|
96
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
97
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
98
|
-
};
|
|
99
|
-
|
|
100
153
|
try {
|
|
101
154
|
await this.client.send(
|
|
102
155
|
new DeleteCommand({
|
|
103
156
|
TableName: this.tableName,
|
|
104
|
-
Key: lockKey,
|
|
157
|
+
Key: this.lockKey,
|
|
105
158
|
// Only delete if we own the lock
|
|
106
159
|
ConditionExpression: 'lockId = :lockId',
|
|
107
160
|
ExpressionAttributeValues: {
|
|
@@ -170,6 +223,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
170
223
|
schemaChanges?: SchemaChange[],
|
|
171
224
|
checksum?: string
|
|
172
225
|
): Promise<void> {
|
|
226
|
+
this.requireLock();
|
|
173
227
|
const now = new Date().toISOString();
|
|
174
228
|
|
|
175
229
|
// Check if migration record already exists (e.g., rolled back or failed)
|
|
@@ -201,6 +255,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
201
255
|
await this.client.send(
|
|
202
256
|
new TransactWriteCommand({
|
|
203
257
|
TransactItems: [
|
|
258
|
+
this.lockOwnershipCheck(),
|
|
204
259
|
{
|
|
205
260
|
Update: {
|
|
206
261
|
TableName: this.tableName,
|
|
@@ -243,6 +298,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
243
298
|
await this.client.send(
|
|
244
299
|
new TransactWriteCommand({
|
|
245
300
|
TransactItems: [
|
|
301
|
+
this.lockOwnershipCheck(),
|
|
246
302
|
{
|
|
247
303
|
Put: {
|
|
248
304
|
TableName: this.tableName,
|
|
@@ -258,8 +314,9 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
258
314
|
schemaChanges,
|
|
259
315
|
checksum,
|
|
260
316
|
} as MigrationRecord,
|
|
261
|
-
// Ensure migration wasn't already applied
|
|
262
|
-
|
|
317
|
+
// Ensure migration wasn't already applied (uniqueness on
|
|
318
|
+
// SK; PK is shared across all migration rows).
|
|
319
|
+
ConditionExpression: 'attribute_not_exists(SK)',
|
|
263
320
|
},
|
|
264
321
|
},
|
|
265
322
|
{
|
|
@@ -288,13 +345,14 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
288
345
|
* Mark migration as rolled back using TransactWrite for atomicity
|
|
289
346
|
*/
|
|
290
347
|
async markAsRolledBack(version: string): Promise<void> {
|
|
348
|
+
this.requireLock();
|
|
291
349
|
const now = new Date().toISOString();
|
|
292
350
|
|
|
293
351
|
// Find previous version
|
|
294
352
|
const migrations = await this.getAppliedMigrations();
|
|
295
353
|
const appliedMigrations = migrations
|
|
296
354
|
.filter((m) => m.status === 'applied' && m.version !== version)
|
|
297
|
-
.sort((a, b) => b.version
|
|
355
|
+
.sort((a, b) => compareSemver(b.version, a.version));
|
|
298
356
|
|
|
299
357
|
const previousVersion = appliedMigrations[0]?.version || 'v0000';
|
|
300
358
|
|
|
@@ -302,6 +360,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
302
360
|
await this.client.send(
|
|
303
361
|
new TransactWriteCommand({
|
|
304
362
|
TransactItems: [
|
|
363
|
+
this.lockOwnershipCheck(),
|
|
305
364
|
{
|
|
306
365
|
Update: {
|
|
307
366
|
TableName: this.tableName,
|
|
@@ -341,70 +400,95 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
341
400
|
}
|
|
342
401
|
|
|
343
402
|
async markAsFailed(version: string, error: string): Promise<void> {
|
|
403
|
+
this.requireLock();
|
|
344
404
|
// Check if the migration record exists first
|
|
345
405
|
const existing = await this.getMigration(version);
|
|
406
|
+
const now = new Date().toISOString();
|
|
346
407
|
|
|
347
408
|
if (existing) {
|
|
348
|
-
// Update existing record
|
|
409
|
+
// Update existing record, gated by lock ownership
|
|
349
410
|
await this.client.send(
|
|
350
|
-
new
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
411
|
+
new TransactWriteCommand({
|
|
412
|
+
TransactItems: [
|
|
413
|
+
this.lockOwnershipCheck(),
|
|
414
|
+
{
|
|
415
|
+
Update: {
|
|
416
|
+
TableName: this.tableName,
|
|
417
|
+
Key: {
|
|
418
|
+
PK: this.trackingPrefix,
|
|
419
|
+
SK: version,
|
|
420
|
+
},
|
|
421
|
+
UpdateExpression:
|
|
422
|
+
'SET #status = :status, #error = :error, failedAt = :timestamp',
|
|
423
|
+
ExpressionAttributeNames: {
|
|
424
|
+
'#status': 'status',
|
|
425
|
+
'#error': 'error',
|
|
426
|
+
},
|
|
427
|
+
ExpressionAttributeValues: {
|
|
428
|
+
':status': 'failed',
|
|
429
|
+
':error': error,
|
|
430
|
+
':timestamp': now,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
],
|
|
366
435
|
})
|
|
367
436
|
);
|
|
368
437
|
} else {
|
|
369
|
-
// Create new record for failed migration
|
|
438
|
+
// Create new record for failed migration, gated by lock ownership
|
|
370
439
|
await this.client.send(
|
|
371
|
-
new
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
440
|
+
new TransactWriteCommand({
|
|
441
|
+
TransactItems: [
|
|
442
|
+
this.lockOwnershipCheck(),
|
|
443
|
+
{
|
|
444
|
+
Put: {
|
|
445
|
+
TableName: this.tableName,
|
|
446
|
+
Item: {
|
|
447
|
+
PK: this.trackingPrefix,
|
|
448
|
+
SK: version,
|
|
449
|
+
version,
|
|
450
|
+
name: 'unknown',
|
|
451
|
+
status: 'failed',
|
|
452
|
+
error,
|
|
453
|
+
failedAt: now,
|
|
454
|
+
timestamp: now,
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
],
|
|
383
459
|
})
|
|
384
460
|
);
|
|
385
461
|
}
|
|
386
462
|
}
|
|
387
463
|
|
|
388
464
|
async recordSchemaChange(change: SchemaChange): Promise<void> {
|
|
465
|
+
this.requireLock();
|
|
389
466
|
const currentVersion = await this.getCurrentVersion();
|
|
390
467
|
if (!currentVersion || currentVersion === 'v0000') {
|
|
391
468
|
throw new Error('Cannot record schema change: No migration has been applied yet');
|
|
392
469
|
}
|
|
393
470
|
|
|
394
|
-
// Append to schemaChanges array
|
|
471
|
+
// Append to schemaChanges array, gated by lock ownership
|
|
395
472
|
await this.client.send(
|
|
396
|
-
new
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
473
|
+
new TransactWriteCommand({
|
|
474
|
+
TransactItems: [
|
|
475
|
+
this.lockOwnershipCheck(),
|
|
476
|
+
{
|
|
477
|
+
Update: {
|
|
478
|
+
TableName: this.tableName,
|
|
479
|
+
Key: {
|
|
480
|
+
PK: this.trackingPrefix,
|
|
481
|
+
SK: currentVersion,
|
|
482
|
+
},
|
|
483
|
+
UpdateExpression:
|
|
484
|
+
'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
|
|
485
|
+
ExpressionAttributeValues: {
|
|
486
|
+
':empty_list': [],
|
|
487
|
+
':change': [change],
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
],
|
|
408
492
|
})
|
|
409
493
|
);
|
|
410
494
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -117,6 +117,12 @@ export interface MigrationConfig {
|
|
|
117
117
|
migrationsDir?: string;
|
|
118
118
|
trackingPrefix?: string; // Default: "_SCHEMA#VERSION"
|
|
119
119
|
gsi1Name?: string; // Default: "GSI1"
|
|
120
|
+
/**
|
|
121
|
+
* How long an acquired migration lock stays valid before another worker
|
|
122
|
+
* can take it over, in seconds. The runner sends a heartbeat every
|
|
123
|
+
* `lockTtlSeconds / 3` to extend it. Default: 300 (5 minutes).
|
|
124
|
+
*/
|
|
125
|
+
lockTtlSeconds?: number;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/**
|
|
@@ -148,6 +154,12 @@ export interface MigrationTracker {
|
|
|
148
154
|
*/
|
|
149
155
|
releaseLock(): Promise<void>;
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Extend the lock's expiration. Throws `ConditionalCheckFailedException`
|
|
159
|
+
* if the lock has already been taken by someone else.
|
|
160
|
+
*/
|
|
161
|
+
refreshLock(): Promise<void>;
|
|
162
|
+
|
|
151
163
|
/**
|
|
152
164
|
* Mark migration as applied
|
|
153
165
|
*/
|