@ftschopp/dynatable-migrations 1.2.3 → 1.2.5
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/errors.d.ts +25 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/errors.js.map +1 -0
- 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 +11 -1
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tracker.d.ts +30 -0
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +262 -148
- package/dist/core/tracker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/core/errors.ts +55 -0
- package/src/core/lock-heartbeat.test.ts +78 -0
- package/src/core/lock-heartbeat.ts +48 -0
- package/src/core/runner.ts +9 -1
- package/src/core/tracker.test.ts +281 -0
- package/src/core/tracker.ts +286 -158
- package/src/index.ts +1 -0
- package/src/types/index.ts +12 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ftschopp/dynatable-migrations",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "DynamoDB migration tool for single table design with schema versioning",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@repo/typescript-config": "*",
|
|
29
29
|
"@types/jest": "^30.0.0",
|
|
30
30
|
"@types/node": "^25.0.3",
|
|
31
|
+
"aws-sdk-client-mock": "^4.1.0",
|
|
31
32
|
"jest": "^30.2.0",
|
|
32
33
|
"ts-jest": "^29.4.6",
|
|
33
34
|
"typescript": "5.9.3"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by `markAsApplied` when the version already has a tracking record
|
|
3
|
+
* in a non-applied state (e.g. `failed`, `rolled_back`) and therefore
|
|
4
|
+
* cannot be silently treated as an idempotent re-apply. Surfaces the
|
|
5
|
+
* existing state so the caller can decide whether to retry, recover, or
|
|
6
|
+
* roll back.
|
|
7
|
+
*/
|
|
8
|
+
export class MigrationAlreadyAppliedError extends Error {
|
|
9
|
+
public readonly version: string;
|
|
10
|
+
public readonly currentStatus: string | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(version: string, currentStatus: string | undefined) {
|
|
13
|
+
super(
|
|
14
|
+
`Migration "${version}" already has a tracking record in state ` +
|
|
15
|
+
`"${currentStatus ?? 'unknown'}" — cannot mark as applied. ` +
|
|
16
|
+
`If you want to re-apply this version, roll it back first ` +
|
|
17
|
+
`(or recover the failed run) and try again.`
|
|
18
|
+
);
|
|
19
|
+
this.name = 'MigrationAlreadyAppliedError';
|
|
20
|
+
this.version = version;
|
|
21
|
+
this.currentStatus = currentStatus;
|
|
22
|
+
if (typeof (Error as unknown as { captureStackTrace?: unknown }).captureStackTrace === 'function') {
|
|
23
|
+
(
|
|
24
|
+
Error as unknown as { captureStackTrace: (target: object, ctor: unknown) => void }
|
|
25
|
+
).captureStackTrace(this, MigrationAlreadyAppliedError);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thrown when a tracker write is cancelled because the migration lock was
|
|
32
|
+
* taken by another process between `acquireLock()` and the write itself
|
|
33
|
+
* (e.g. the original lock TTL expired and a second worker took over).
|
|
34
|
+
*
|
|
35
|
+
* The transaction was rolled back atomically — no partial state was
|
|
36
|
+
* written. The safe action is to stop the current `up()` / `down()` run.
|
|
37
|
+
*/
|
|
38
|
+
export class MigrationLockLostError extends Error {
|
|
39
|
+
public readonly version: string;
|
|
40
|
+
|
|
41
|
+
constructor(version: string) {
|
|
42
|
+
super(
|
|
43
|
+
`Migration lock was lost during markAsApplied("${version}"). ` +
|
|
44
|
+
`Another worker may have taken over. The transaction was rolled ` +
|
|
45
|
+
`back atomically; no partial state was written.`
|
|
46
|
+
);
|
|
47
|
+
this.name = 'MigrationLockLostError';
|
|
48
|
+
this.version = version;
|
|
49
|
+
if (typeof (Error as unknown as { captureStackTrace?: unknown }).captureStackTrace === 'function') {
|
|
50
|
+
(
|
|
51
|
+
Error as unknown as { captureStackTrace: (target: object, ctor: unknown) => void }
|
|
52
|
+
).captureStackTrace(this, MigrationLockLostError);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { startLockHeartbeat } from './lock-heartbeat';
|
|
2
|
+
import type { MigrationTracker } from '../types';
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.useFakeTimers();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.useRealTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function makeTracker(refresh: jest.Mock): MigrationTracker {
|
|
13
|
+
// Only the methods the heartbeat actually touches need to be present.
|
|
14
|
+
return {
|
|
15
|
+
refreshLock: refresh,
|
|
16
|
+
} as unknown as MigrationTracker;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('startLockHeartbeat', () => {
|
|
20
|
+
test('calls refreshLock every ~ttl/3 seconds', () => {
|
|
21
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
22
|
+
const tracker = makeTracker(refresh);
|
|
23
|
+
const stop = startLockHeartbeat(tracker, 30); // every 10s
|
|
24
|
+
|
|
25
|
+
jest.advanceTimersByTime(10_000);
|
|
26
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
27
|
+
|
|
28
|
+
jest.advanceTimersByTime(10_000);
|
|
29
|
+
expect(refresh).toHaveBeenCalledTimes(2);
|
|
30
|
+
|
|
31
|
+
stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('stop() clears the interval and prevents further refreshes', () => {
|
|
35
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
36
|
+
const tracker = makeTracker(refresh);
|
|
37
|
+
const stop = startLockHeartbeat(tracker, 30);
|
|
38
|
+
|
|
39
|
+
jest.advanceTimersByTime(10_000);
|
|
40
|
+
stop();
|
|
41
|
+
jest.advanceTimersByTime(60_000);
|
|
42
|
+
|
|
43
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('clamps the interval to a 1s minimum even with absurdly small TTLs', () => {
|
|
47
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
48
|
+
const tracker = makeTracker(refresh);
|
|
49
|
+
const stop = startLockHeartbeat(tracker, 0.5); // would compute < 1s
|
|
50
|
+
|
|
51
|
+
jest.advanceTimersByTime(999);
|
|
52
|
+
expect(refresh).toHaveBeenCalledTimes(0);
|
|
53
|
+
jest.advanceTimersByTime(1);
|
|
54
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('swallows refresh errors so transient failures do not crash the runner', async () => {
|
|
60
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
61
|
+
const refresh = jest
|
|
62
|
+
.fn()
|
|
63
|
+
.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'NetworkError' }));
|
|
64
|
+
const tracker = makeTracker(refresh);
|
|
65
|
+
const stop = startLockHeartbeat(tracker, 30);
|
|
66
|
+
|
|
67
|
+
jest.advanceTimersByTime(10_000);
|
|
68
|
+
// Let the rejected promise settle
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
|
|
72
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(warn).toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
stop();
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MigrationTracker } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Periodically refreshes the migration lock so that migrations longer than
|
|
5
|
+
* the lock TTL don't have their lock taken by another worker.
|
|
6
|
+
*
|
|
7
|
+
* Returns a stop function. Call it from the same `finally` that releases
|
|
8
|
+
* the lock to make sure the interval doesn't leak.
|
|
9
|
+
*
|
|
10
|
+
* The heartbeat fires every `ttlSeconds / 3` seconds. Errors from the
|
|
11
|
+
* underlying refresh are logged and swallowed: if the lock is genuinely
|
|
12
|
+
* lost, the next tracker mutation will fail its ConditionCheck and the
|
|
13
|
+
* runner will surface a clear `TransactionCanceledException` to the user.
|
|
14
|
+
*/
|
|
15
|
+
export function startLockHeartbeat(
|
|
16
|
+
tracker: MigrationTracker,
|
|
17
|
+
ttlSeconds: number
|
|
18
|
+
): () => void {
|
|
19
|
+
// Fire well before expiry so a single missed beat doesn't drop the lock.
|
|
20
|
+
const intervalMs = Math.max(1_000, Math.floor((ttlSeconds * 1000) / 3));
|
|
21
|
+
let active = true;
|
|
22
|
+
|
|
23
|
+
const handle = setInterval(() => {
|
|
24
|
+
if (!active) return;
|
|
25
|
+
void tracker.refreshLock().catch((err: unknown) => {
|
|
26
|
+
const name = (err as { name?: string } | null)?.name;
|
|
27
|
+
const message = (err as { message?: string } | null)?.message ?? String(err);
|
|
28
|
+
if (name === 'ConditionalCheckFailedException') {
|
|
29
|
+
console.warn(
|
|
30
|
+
'⚠️ Migration lock was taken by another process. ' +
|
|
31
|
+
'Subsequent tracker writes will fail.'
|
|
32
|
+
);
|
|
33
|
+
} else {
|
|
34
|
+
console.warn(`⚠️ Failed to refresh migration lock: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}, intervalMs);
|
|
38
|
+
|
|
39
|
+
// Don't keep the Node event loop alive just for the heartbeat.
|
|
40
|
+
if (typeof handle.unref === 'function') {
|
|
41
|
+
handle.unref();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
active = false;
|
|
46
|
+
clearInterval(handle);
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { MigrationConfig, MigrationContext, MigrationFile, MigrationStatus } fro
|
|
|
15
15
|
import { DynamoDBMigrationTracker } from './tracker';
|
|
16
16
|
import { MigrationLoader } from './loader';
|
|
17
17
|
import { compareSemver } from './semver';
|
|
18
|
+
import { startLockHeartbeat } from './lock-heartbeat';
|
|
18
19
|
|
|
19
20
|
export interface RunOptions {
|
|
20
21
|
limit?: number;
|
|
@@ -50,6 +51,7 @@ export class MigrationRunner {
|
|
|
50
51
|
await this.initialize();
|
|
51
52
|
|
|
52
53
|
// Acquire lock unless dry run
|
|
54
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
53
55
|
if (!dryRun) {
|
|
54
56
|
const lockAcquired = await this.tracker.acquireLock();
|
|
55
57
|
if (!lockAcquired) {
|
|
@@ -58,6 +60,7 @@ export class MigrationRunner {
|
|
|
58
60
|
'If you believe this is an error, wait a few minutes and try again.'
|
|
59
61
|
);
|
|
60
62
|
}
|
|
63
|
+
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
try {
|
|
@@ -133,7 +136,9 @@ export class MigrationRunner {
|
|
|
133
136
|
|
|
134
137
|
return executed;
|
|
135
138
|
} finally {
|
|
136
|
-
// Always release lock
|
|
139
|
+
// Always stop the heartbeat and release the lock — in that order, so
|
|
140
|
+
// we don't refresh a lock we're about to delete.
|
|
141
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
137
142
|
if (!dryRun) {
|
|
138
143
|
await this.tracker.releaseLock();
|
|
139
144
|
}
|
|
@@ -147,11 +152,13 @@ export class MigrationRunner {
|
|
|
147
152
|
await this.initialize();
|
|
148
153
|
|
|
149
154
|
// Acquire lock unless dry run
|
|
155
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
150
156
|
if (!dryRun) {
|
|
151
157
|
const lockAcquired = await this.tracker.acquireLock();
|
|
152
158
|
if (!lockAcquired) {
|
|
153
159
|
throw new Error('Could not acquire migration lock. Another migration may be in progress.');
|
|
154
160
|
}
|
|
161
|
+
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
try {
|
|
@@ -207,6 +214,7 @@ export class MigrationRunner {
|
|
|
207
214
|
|
|
208
215
|
return rolledBack;
|
|
209
216
|
} finally {
|
|
217
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
210
218
|
if (!dryRun) {
|
|
211
219
|
await this.tracker.releaseLock();
|
|
212
220
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
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
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('DynamoDBMigrationTracker - markAsApplied idempotency (#10)', () => {
|
|
153
|
+
function cancelledTransactError(reasons: Array<{ Code?: string }>) {
|
|
154
|
+
return Object.assign(new Error('Transaction cancelled'), {
|
|
155
|
+
name: 'TransactionCanceledException',
|
|
156
|
+
CancellationReasons: reasons,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test('returns silently when the cancellation is because the row is already in status=applied', async () => {
|
|
161
|
+
ddbMock.on(PutCommand).resolves({}); // acquireLock
|
|
162
|
+
// First getMigration → no record (so we take the Put branch)
|
|
163
|
+
// Second getMigration (after cancellation) → already applied
|
|
164
|
+
ddbMock
|
|
165
|
+
.on(GetCommand)
|
|
166
|
+
.resolvesOnce({ Item: undefined })
|
|
167
|
+
.resolvesOnce({
|
|
168
|
+
Item: {
|
|
169
|
+
PK: '_SCHEMA#VERSION',
|
|
170
|
+
SK: '0.1.0',
|
|
171
|
+
version: '0.1.0',
|
|
172
|
+
status: 'applied',
|
|
173
|
+
name: 'init',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Migration row condition fails (reasons[1]); lock check passed (reasons[0] = None)
|
|
178
|
+
ddbMock.on(TransactWriteCommand).rejects(
|
|
179
|
+
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const tracker = makeTracker();
|
|
183
|
+
await tracker.acquireLock();
|
|
184
|
+
|
|
185
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).resolves.toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('throws MigrationAlreadyAppliedError when the row exists in a non-applied state', async () => {
|
|
189
|
+
ddbMock.on(PutCommand).resolves({});
|
|
190
|
+
ddbMock
|
|
191
|
+
.on(GetCommand)
|
|
192
|
+
.resolvesOnce({ Item: undefined })
|
|
193
|
+
.resolvesOnce({
|
|
194
|
+
Item: {
|
|
195
|
+
PK: '_SCHEMA#VERSION',
|
|
196
|
+
SK: '0.1.0',
|
|
197
|
+
version: '0.1.0',
|
|
198
|
+
status: 'failed',
|
|
199
|
+
name: 'init',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
ddbMock.on(TransactWriteCommand).rejects(
|
|
204
|
+
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const tracker = makeTracker();
|
|
208
|
+
await tracker.acquireLock();
|
|
209
|
+
|
|
210
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toMatchObject({
|
|
211
|
+
name: 'MigrationAlreadyAppliedError',
|
|
212
|
+
version: '0.1.0',
|
|
213
|
+
currentStatus: 'failed',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('throws MigrationLockLostError when the lock-row ConditionCheck is what failed', async () => {
|
|
218
|
+
ddbMock.on(PutCommand).resolves({});
|
|
219
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
220
|
+
|
|
221
|
+
ddbMock.on(TransactWriteCommand).rejects(
|
|
222
|
+
cancelledTransactError([{ Code: 'ConditionalCheckFailed' }, { Code: 'None' }])
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const tracker = makeTracker();
|
|
226
|
+
await tracker.acquireLock();
|
|
227
|
+
|
|
228
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toMatchObject({
|
|
229
|
+
name: 'MigrationLockLostError',
|
|
230
|
+
version: '0.1.0',
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('re-throws unexpected errors unchanged', async () => {
|
|
235
|
+
ddbMock.on(PutCommand).resolves({});
|
|
236
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
237
|
+
|
|
238
|
+
const boom = Object.assign(new Error('upstream blew up'), { name: 'InternalServerError' });
|
|
239
|
+
ddbMock.on(TransactWriteCommand).rejects(boom);
|
|
240
|
+
|
|
241
|
+
const tracker = makeTracker();
|
|
242
|
+
await tracker.acquireLock();
|
|
243
|
+
|
|
244
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toBe(boom);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('idempotent path also covers the Update branch (existing record was rolled_back, applied by a racing worker)', async () => {
|
|
248
|
+
ddbMock.on(PutCommand).resolves({});
|
|
249
|
+
// First getMigration → existing rolled_back record (so we take the Update branch)
|
|
250
|
+
// Second getMigration (after cancellation) → already applied (race)
|
|
251
|
+
ddbMock
|
|
252
|
+
.on(GetCommand)
|
|
253
|
+
.resolvesOnce({
|
|
254
|
+
Item: {
|
|
255
|
+
PK: '_SCHEMA#VERSION',
|
|
256
|
+
SK: '0.1.0',
|
|
257
|
+
version: '0.1.0',
|
|
258
|
+
status: 'rolled_back',
|
|
259
|
+
name: 'init',
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
.resolvesOnce({
|
|
263
|
+
Item: {
|
|
264
|
+
PK: '_SCHEMA#VERSION',
|
|
265
|
+
SK: '0.1.0',
|
|
266
|
+
version: '0.1.0',
|
|
267
|
+
status: 'applied',
|
|
268
|
+
name: 'init',
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
ddbMock.on(TransactWriteCommand).rejects(
|
|
273
|
+
cancelledTransactError([{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }])
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const tracker = makeTracker();
|
|
277
|
+
await tracker.acquireLock();
|
|
278
|
+
|
|
279
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).resolves.toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
});
|