@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/config.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import { MigrationConfig } from '../types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Load migration configuration
|
|
7
|
-
*/
|
|
8
|
-
export class ConfigLoader {
|
|
9
|
-
private configPath: string;
|
|
10
|
-
|
|
11
|
-
constructor(configPath?: string) {
|
|
12
|
-
this.configPath = configPath || this.findConfigFile() || 'dynatable.config.js';
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Find config file in current directory or parent directories
|
|
17
|
-
*/
|
|
18
|
-
private findConfigFile(): string | null {
|
|
19
|
-
const configFileNames = [
|
|
20
|
-
'dynatable.config.js',
|
|
21
|
-
'dynatable.config.json',
|
|
22
|
-
'.dynatablerc.js',
|
|
23
|
-
'.dynatablerc.json',
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
let currentDir = process.cwd();
|
|
27
|
-
const root = path.parse(currentDir).root;
|
|
28
|
-
|
|
29
|
-
while (currentDir !== root) {
|
|
30
|
-
for (const fileName of configFileNames) {
|
|
31
|
-
const filePath = path.join(currentDir, fileName);
|
|
32
|
-
if (fs.existsSync(filePath)) {
|
|
33
|
-
return filePath;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
currentDir = path.dirname(currentDir);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Load configuration from file
|
|
45
|
-
*/
|
|
46
|
-
async load(): Promise<MigrationConfig> {
|
|
47
|
-
if (!fs.existsSync(this.configPath)) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`Configuration file not found: ${this.configPath}\n\nPlease create a dynatable.config.js file in your project root.`
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
let config: MigrationConfig;
|
|
55
|
-
|
|
56
|
-
if (this.configPath.endsWith('.json')) {
|
|
57
|
-
const content = fs.readFileSync(this.configPath, 'utf-8');
|
|
58
|
-
config = JSON.parse(content);
|
|
59
|
-
} else {
|
|
60
|
-
// JavaScript config file
|
|
61
|
-
const module = await import(this.configPath);
|
|
62
|
-
config = module.default || module;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return this.validateAndNormalizeConfig(config);
|
|
66
|
-
} catch (error: any) {
|
|
67
|
-
throw new Error(`Failed to load configuration: ${error.message}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Validate and normalize configuration
|
|
73
|
-
*/
|
|
74
|
-
private validateAndNormalizeConfig(config: Partial<MigrationConfig>): MigrationConfig {
|
|
75
|
-
if (!config.tableName) {
|
|
76
|
-
throw new Error("Configuration error: 'tableName' is required");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!config.client?.region) {
|
|
80
|
-
throw new Error("Configuration error: 'client.region' is required");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
tableName: config.tableName,
|
|
85
|
-
client: {
|
|
86
|
-
region: config.client.region,
|
|
87
|
-
endpoint: config.client.endpoint,
|
|
88
|
-
credentials: config.client.credentials,
|
|
89
|
-
},
|
|
90
|
-
migrationsDir: config.migrationsDir || './migrations',
|
|
91
|
-
trackingPrefix: config.trackingPrefix || '_SCHEMA#VERSION',
|
|
92
|
-
gsi1Name: config.gsi1Name || 'GSI1',
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Create default config file
|
|
98
|
-
*/
|
|
99
|
-
static createDefaultConfig(targetPath: string): void {
|
|
100
|
-
const defaultConfig = `module.exports = {
|
|
101
|
-
// DynamoDB table name
|
|
102
|
-
tableName: "MyTable",
|
|
103
|
-
|
|
104
|
-
// DynamoDB client configuration
|
|
105
|
-
client: {
|
|
106
|
-
region: "us-east-1",
|
|
107
|
-
|
|
108
|
-
// Optional: For local DynamoDB
|
|
109
|
-
endpoint: "http://localhost:8000",
|
|
110
|
-
|
|
111
|
-
// Optional: Credentials (use AWS_PROFILE or IAM role in production)
|
|
112
|
-
credentials: {
|
|
113
|
-
accessKeyId: "local",
|
|
114
|
-
secretAccessKey: "local",
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
// Optional: Migrations directory (default: ./migrations)
|
|
119
|
-
migrationsDir: "./migrations",
|
|
120
|
-
|
|
121
|
-
// Optional: Tracking prefix in single table (default: _SCHEMA#VERSION)
|
|
122
|
-
trackingPrefix: "_SCHEMA#VERSION",
|
|
123
|
-
|
|
124
|
-
// Optional: GSI name for tracking (default: GSI1)
|
|
125
|
-
gsi1Name: "GSI1",
|
|
126
|
-
};
|
|
127
|
-
`;
|
|
128
|
-
|
|
129
|
-
fs.writeFileSync(targetPath, defaultConfig, 'utf-8');
|
|
130
|
-
console.log(`✅ Created config file: ${targetPath}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Get config from environment or file
|
|
136
|
-
*/
|
|
137
|
-
export async function loadConfig(configPath?: string): Promise<MigrationConfig> {
|
|
138
|
-
const loader = new ConfigLoader(configPath);
|
|
139
|
-
return loader.load();
|
|
140
|
-
}
|
package/src/core/errors.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/loader.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import * as crypto from 'crypto';
|
|
4
|
-
import { Migration, MigrationFile } from '../types';
|
|
5
|
-
import { compareSemver } from './semver';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Load all migration files from directory
|
|
9
|
-
*/
|
|
10
|
-
export class MigrationLoader {
|
|
11
|
-
private migrationsDir: string;
|
|
12
|
-
|
|
13
|
-
constructor(migrationsDir: string) {
|
|
14
|
-
this.migrationsDir = migrationsDir;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Calculate checksum of a file
|
|
19
|
-
*/
|
|
20
|
-
calculateChecksum(filePath: string): string {
|
|
21
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
22
|
-
return crypto.createHash('md5').update(content).digest('hex');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Load all migration files
|
|
27
|
-
*/
|
|
28
|
-
async loadMigrations(): Promise<MigrationFile[]> {
|
|
29
|
-
if (!fs.existsSync(this.migrationsDir)) {
|
|
30
|
-
throw new Error(`Migrations directory not found: ${this.migrationsDir}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const files = fs
|
|
34
|
-
.readdirSync(this.migrationsDir)
|
|
35
|
-
.filter(
|
|
36
|
-
(f) =>
|
|
37
|
-
(f.endsWith('.ts') || f.endsWith('.js')) &&
|
|
38
|
-
!f.endsWith('.d.ts') &&
|
|
39
|
-
this.isValidMigrationFileName(f)
|
|
40
|
-
)
|
|
41
|
-
.sort((a, b) => {
|
|
42
|
-
// Sort by semver
|
|
43
|
-
const versionA = a.match(/^(\d+\.\d+\.\d+)/)?.[1] || '0.0.0';
|
|
44
|
-
const versionB = b.match(/^(\d+\.\d+\.\d+)/)?.[1] || '0.0.0';
|
|
45
|
-
return compareSemver(versionA, versionB);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const migrations: MigrationFile[] = [];
|
|
49
|
-
|
|
50
|
-
for (const file of files) {
|
|
51
|
-
const filePath = path.join(this.migrationsDir, file);
|
|
52
|
-
const migration = await this.loadMigration(filePath);
|
|
53
|
-
|
|
54
|
-
if (migration) {
|
|
55
|
-
migrations.push(migration);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return migrations;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Load single migration file
|
|
64
|
-
*/
|
|
65
|
-
private async loadMigration(filePath: string): Promise<MigrationFile | null> {
|
|
66
|
-
// Convert to absolute path
|
|
67
|
-
const absolutePath = path.isAbsolute(filePath)
|
|
68
|
-
? filePath
|
|
69
|
-
: path.resolve(process.cwd(), filePath);
|
|
70
|
-
|
|
71
|
-
let module: any;
|
|
72
|
-
|
|
73
|
-
// For TypeScript files, use require (ts-node must be registered)
|
|
74
|
-
if (absolutePath.endsWith('.ts')) {
|
|
75
|
-
// Register ts-node if not already registered
|
|
76
|
-
this.registerTsNode();
|
|
77
|
-
|
|
78
|
-
// Clear require cache to reload file
|
|
79
|
-
delete require.cache[absolutePath];
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
module = require(absolutePath);
|
|
83
|
-
} catch (error: any) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`Failed to load TypeScript migration ${filePath}: ${error.message}. ` +
|
|
86
|
-
`Ensure ts-node is installed and the file has valid TypeScript syntax.`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
// For JavaScript files, use dynamic import
|
|
91
|
-
try {
|
|
92
|
-
const fileUrl = `file://${absolutePath}`;
|
|
93
|
-
module = await import(fileUrl);
|
|
94
|
-
} catch (error: any) {
|
|
95
|
-
throw new Error(`Failed to load JavaScript migration ${filePath}: ${error.message}`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const migration: Migration = module.migration || module.default;
|
|
100
|
-
|
|
101
|
-
if (!migration) {
|
|
102
|
-
console.warn(`Warning: No migration export found in ${filePath}`);
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Validate migration structure
|
|
107
|
-
this.validateMigration(migration, filePath);
|
|
108
|
-
|
|
109
|
-
const fileName = path.basename(filePath);
|
|
110
|
-
const { version, name } = this.parseMigrationFileName(fileName);
|
|
111
|
-
|
|
112
|
-
// Calculate checksum
|
|
113
|
-
const checksum = this.calculateChecksum(absolutePath);
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
version,
|
|
117
|
-
name,
|
|
118
|
-
filePath,
|
|
119
|
-
migration,
|
|
120
|
-
checksum,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Register ts-node for TypeScript file loading
|
|
126
|
-
*/
|
|
127
|
-
private registerTsNode(): void {
|
|
128
|
-
try {
|
|
129
|
-
// Check if ts-node is already registered
|
|
130
|
-
const tsNodeSymbol = Symbol.for('ts-node.register.instance');
|
|
131
|
-
if ((process as any)[tsNodeSymbol]) {
|
|
132
|
-
return; // Already registered
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
require('ts-node').register({
|
|
136
|
-
transpileOnly: true,
|
|
137
|
-
skipProject: true, // Don't use project tsconfig
|
|
138
|
-
compilerOptions: {
|
|
139
|
-
module: 'commonjs',
|
|
140
|
-
target: 'ES2020',
|
|
141
|
-
esModuleInterop: true,
|
|
142
|
-
moduleResolution: 'node',
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
} catch (error: any) {
|
|
146
|
-
if (error.code === 'MODULE_NOT_FOUND') {
|
|
147
|
-
throw new Error(
|
|
148
|
-
'ts-node is required to load TypeScript migrations. ' +
|
|
149
|
-
'Install it with: npm install ts-node'
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
// Other errors might mean ts-node is already registered differently
|
|
153
|
-
console.warn(`Warning: Could not register ts-node: ${error.message}`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Validate migration file name format
|
|
159
|
-
* Expected: 1.0.0_migration_name.ts (semver format)
|
|
160
|
-
*/
|
|
161
|
-
private isValidMigrationFileName(fileName: string): boolean {
|
|
162
|
-
// Match semver: 1.0.0_name.ts or 1.2.3_name.ts
|
|
163
|
-
return /^\d+\.\d+\.\d+_[\w-]+\.(ts|js)$/.test(fileName);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Parse version and name from file name
|
|
168
|
-
*/
|
|
169
|
-
private parseMigrationFileName(fileName: string): {
|
|
170
|
-
version: string;
|
|
171
|
-
name: string;
|
|
172
|
-
} {
|
|
173
|
-
const match = fileName.match(/^(\d+\.\d+\.\d+)_([\w-]+)\.(ts|js)$/);
|
|
174
|
-
|
|
175
|
-
if (!match || !match[1] || !match[2]) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
`Invalid migration file name format: ${fileName}. Expected: X.Y.Z_name.ts (semver)`
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const version = match[1];
|
|
182
|
-
const name = match[2];
|
|
183
|
-
|
|
184
|
-
return { version, name };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Validate migration structure
|
|
189
|
-
*/
|
|
190
|
-
private validateMigration(migration: Migration, filePath: string): void {
|
|
191
|
-
if (!migration.version) {
|
|
192
|
-
throw new Error(`Migration ${filePath} is missing 'version' property`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (!migration.name) {
|
|
196
|
-
throw new Error(`Migration ${filePath} is missing 'name' property`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (typeof migration.up !== 'function') {
|
|
200
|
-
throw new Error(`Migration ${filePath} is missing 'up' function or it's not a function`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (typeof migration.down !== 'function') {
|
|
204
|
-
throw new Error(`Migration ${filePath} is missing 'down' function or it's not a function`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Get pending migrations (not yet applied)
|
|
210
|
-
*/
|
|
211
|
-
async getPendingMigrations(appliedVersions: string[]): Promise<MigrationFile[]> {
|
|
212
|
-
const allMigrations = await this.loadMigrations();
|
|
213
|
-
return allMigrations.filter((m) => !appliedVersions.includes(m.version));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Get migration by version
|
|
218
|
-
*/
|
|
219
|
-
async getMigration(version: string): Promise<MigrationFile | null> {
|
|
220
|
-
const migrations = await this.loadMigrations();
|
|
221
|
-
return migrations.find((m) => m.version === version) || null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Generate next version number (increments patch by default)
|
|
226
|
-
*/
|
|
227
|
-
async getNextVersion(): Promise<string> {
|
|
228
|
-
const migrations = await this.loadMigrations();
|
|
229
|
-
|
|
230
|
-
if (migrations.length === 0) {
|
|
231
|
-
return '0.1.0';
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Get last version
|
|
235
|
-
const lastMigration = migrations[migrations.length - 1];
|
|
236
|
-
if (!lastMigration) {
|
|
237
|
-
return '0.1.0';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const lastVersion = lastMigration.version;
|
|
241
|
-
|
|
242
|
-
// Parse semver and increment patch
|
|
243
|
-
const parts = lastVersion.split('.');
|
|
244
|
-
if (parts.length === 3 && parts[0] && parts[1] && parts[2]) {
|
|
245
|
-
const major = parseInt(parts[0], 10);
|
|
246
|
-
const minor = parseInt(parts[1], 10);
|
|
247
|
-
const patch = parseInt(parts[2], 10);
|
|
248
|
-
|
|
249
|
-
// Increment patch version
|
|
250
|
-
return `${major}.${minor}.${patch + 1}`;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return '0.1.0';
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
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.test.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
-
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
4
|
-
import { MigrationRunner } from './runner';
|
|
5
|
-
import type { MigrationConfig } from '../types';
|
|
6
|
-
|
|
7
|
-
const baseConfig: MigrationConfig = {
|
|
8
|
-
tableName: 'TestTable',
|
|
9
|
-
client: { region: 'us-east-1' },
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
function makeRunner() {
|
|
13
|
-
const client = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'us-east-1' }));
|
|
14
|
-
return new MigrationRunner(client, baseConfig);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('MigrationRunner.up - input validation', () => {
|
|
18
|
-
test('throws when limit is 0', async () => {
|
|
19
|
-
await expect(makeRunner().up({ limit: 0 })).rejects.toThrow(/positive integer/);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('throws when limit is negative', async () => {
|
|
23
|
-
await expect(makeRunner().up({ limit: -3 })).rejects.toThrow(/positive integer/);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('throws when limit is NaN', async () => {
|
|
27
|
-
await expect(makeRunner().up({ limit: NaN })).rejects.toThrow(/positive integer/);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('throws when limit is a non-integer (1.5)', async () => {
|
|
31
|
-
await expect(makeRunner().up({ limit: 1.5 })).rejects.toThrow(/positive integer/);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('MigrationRunner.down - input validation', () => {
|
|
36
|
-
test('throws when steps is 0', async () => {
|
|
37
|
-
await expect(makeRunner().down(0)).rejects.toThrow(/positive integer/);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('throws when steps is negative', async () => {
|
|
41
|
-
await expect(makeRunner().down(-5)).rejects.toThrow(/positive integer/);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('throws when steps is NaN', async () => {
|
|
45
|
-
await expect(makeRunner().down(Number.NaN)).rejects.toThrow(/positive integer/);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('throws when steps is a non-integer', async () => {
|
|
49
|
-
await expect(makeRunner().down(2.5)).rejects.toThrow(/positive integer/);
|
|
50
|
-
});
|
|
51
|
-
});
|