@ftschopp/dynatable-migrations 1.2.6 → 1.3.1

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -0
  3. package/dist/cli.js +21 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/create.js +1 -1
  6. package/dist/commands/create.js.map +1 -1
  7. package/dist/commands/down.d.ts +1 -1
  8. package/dist/commands/down.d.ts.map +1 -1
  9. package/dist/commands/down.js +61 -1
  10. package/dist/commands/down.js.map +1 -1
  11. package/dist/commands/unlock.d.ts +8 -0
  12. package/dist/commands/unlock.d.ts.map +1 -0
  13. package/dist/commands/unlock.js +87 -0
  14. package/dist/commands/unlock.js.map +1 -0
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +14 -1
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/loader.d.ts +22 -1
  19. package/dist/core/loader.d.ts.map +1 -1
  20. package/dist/core/loader.js +56 -6
  21. package/dist/core/loader.js.map +1 -1
  22. package/dist/core/runner.d.ts.map +1 -1
  23. package/dist/core/runner.js +29 -8
  24. package/dist/core/runner.js.map +1 -1
  25. package/dist/core/tracker.d.ts +18 -3
  26. package/dist/core/tracker.d.ts.map +1 -1
  27. package/dist/core/tracker.js +60 -23
  28. package/dist/core/tracker.js.map +1 -1
  29. package/dist/types/index.d.ts +7 -2
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/package.json +35 -4
  32. package/.gitkeep +0 -0
  33. package/jest.config.js +0 -8
  34. package/migrations/.gitkeep +0 -0
  35. package/src/cli-utils.test.ts +0 -45
  36. package/src/cli-utils.ts +0 -18
  37. package/src/cli.ts +0 -110
  38. package/src/commands/create.ts +0 -111
  39. package/src/commands/down.ts +0 -26
  40. package/src/commands/init.ts +0 -37
  41. package/src/commands/status.ts +0 -75
  42. package/src/commands/up.ts +0 -26
  43. package/src/core/client.ts +0 -24
  44. package/src/core/config.ts +0 -140
  45. package/src/core/errors.ts +0 -55
  46. package/src/core/loader.ts +0 -256
  47. package/src/core/lock-heartbeat.test.ts +0 -78
  48. package/src/core/lock-heartbeat.ts +0 -48
  49. package/src/core/runner.test.ts +0 -51
  50. package/src/core/runner.ts +0 -315
  51. package/src/core/semver.test.ts +0 -33
  52. package/src/core/semver.ts +0 -26
  53. package/src/core/tracker.test.ts +0 -281
  54. package/src/core/tracker.ts +0 -559
  55. package/src/index.ts +0 -25
  56. package/src/templates/migration.ts +0 -92
  57. package/src/types/index.ts +0 -220
  58. package/tsconfig.json +0 -19
@@ -1,111 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import { MigrationLoader } from '../core/loader';
4
- import { generateMigrationTemplate } from '../templates/migration';
5
-
6
- type VersionBumpType = 'major' | 'minor' | 'patch';
7
-
8
- /**
9
- * Get next version based on bump type
10
- */
11
- async function getNextVersion(loader: MigrationLoader, bumpType: VersionBumpType): Promise<string> {
12
- const migrations = await loader['loadMigrations']();
13
-
14
- const lastMigration = migrations.length > 0 ? migrations[migrations.length - 1] : null;
15
- const lastVersion = lastMigration?.version;
16
- const parts = lastVersion?.split('.');
17
-
18
- if (!parts || parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
19
- switch (bumpType) {
20
- case 'major':
21
- return '1.0.0';
22
- case 'minor':
23
- return '0.1.0';
24
- case 'patch':
25
- return '0.0.1';
26
- default:
27
- return '0.1.0';
28
- }
29
- }
30
-
31
- const major = parseInt(parts[0], 10);
32
- const minor = parseInt(parts[1], 10);
33
- const patch = parseInt(parts[2], 10);
34
-
35
- switch (bumpType) {
36
- case 'major':
37
- return `${major + 1}.0.0`;
38
- case 'minor':
39
- return `${major}.${minor + 1}.0`;
40
- case 'patch':
41
- return `${major}.${minor}.${patch + 1}`;
42
- default:
43
- return `${major}.${minor}.${patch + 1}`;
44
- }
45
- }
46
-
47
- export async function createMigration(
48
- name: string,
49
- migrationsDir: string = './migrations',
50
- bumpType?: string,
51
- explicitVersion?: string
52
- ): Promise<void> {
53
- // Ensure migrations directory exists
54
- if (!fs.existsSync(migrationsDir)) {
55
- fs.mkdirSync(migrationsDir, { recursive: true });
56
- console.log(`📁 Created migrations directory: ${migrationsDir}`);
57
- }
58
-
59
- // Generate version
60
- const loader = new MigrationLoader(migrationsDir);
61
- let version: string;
62
-
63
- if (explicitVersion) {
64
- // Validate explicit version is semver
65
- if (!/^\d+\.\d+\.\d+$/.test(explicitVersion)) {
66
- throw new Error(
67
- `Invalid version format: ${explicitVersion}. Expected semver format (e.g., 1.0.0)`
68
- );
69
- }
70
- version = explicitVersion;
71
- } else if (bumpType) {
72
- // Validate bump type
73
- const validTypes: VersionBumpType[] = ['major', 'minor', 'patch'];
74
- if (!validTypes.includes(bumpType as VersionBumpType)) {
75
- throw new Error(`Invalid bump type: ${bumpType}. Expected: major, minor, or patch`);
76
- }
77
- version = await getNextVersion(loader, bumpType as VersionBumpType);
78
- } else {
79
- // Default: increment patch
80
- version = await getNextVersion(loader, 'patch');
81
- }
82
-
83
- // Sanitize name (replace spaces and special chars with underscores)
84
- const sanitizedName = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
85
-
86
- // Generate file name
87
- const fileName = `${version}_${sanitizedName}.ts`;
88
- const filePath = path.join(migrationsDir, fileName);
89
-
90
- // Check if file already exists
91
- if (fs.existsSync(filePath)) {
92
- throw new Error(`Migration file already exists: ${filePath}`);
93
- }
94
-
95
- // Generate migration content
96
- const content = generateMigrationTemplate(version, sanitizedName);
97
-
98
- // Write file
99
- fs.writeFileSync(filePath, content, 'utf-8');
100
-
101
- // Determine version type for informational message
102
- const versionType = bumpType ? bumpType.toUpperCase() : explicitVersion ? 'EXPLICIT' : 'PATCH';
103
-
104
- console.log(`\n✅ Created migration: ${fileName}`);
105
- console.log(` Path: ${filePath}`);
106
- console.log(` Version: ${version} (${versionType})\n`);
107
- console.log(`Next steps:`);
108
- console.log(` 1. Edit ${fileName}`);
109
- console.log(` 2. Implement up() and down() functions`);
110
- console.log(` 3. Run: dynatable-migrate up\n`);
111
- }
@@ -1,26 +0,0 @@
1
- import { MigrationRunner } from '../core/runner';
2
- import { createDynamoDBClient } from '../core/client';
3
- import { MigrationConfig } from '../types';
4
-
5
- export async function rollbackMigrations(
6
- config: MigrationConfig,
7
- steps: number = 1,
8
- dryRun: boolean = false
9
- ): Promise<void> {
10
- const client = createDynamoDBClient(config);
11
- const runner = new MigrationRunner(client, config);
12
-
13
- try {
14
- const rolledBack = await runner.down(steps, dryRun);
15
-
16
- if (!dryRun && rolledBack.length > 0) {
17
- console.log(`\n🎉 Successfully rolled back ${rolledBack.length} migration(s)`);
18
-
19
- const currentVersion = await runner.getCurrentVersion();
20
- console.log(`📌 Current version: ${currentVersion}\n`);
21
- }
22
- } catch (error: any) {
23
- console.error(`\n❌ Rollback failed: ${error.message}\n`);
24
- process.exit(1);
25
- }
26
- }
@@ -1,37 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import { ConfigLoader } from '../core/config';
4
-
5
- export async function initProject(): Promise<void> {
6
- const cwd = process.cwd();
7
-
8
- console.log('\n🚀 Initializing DynamoDB migrations\n');
9
-
10
- // Create migrations directory
11
- const migrationsDir = path.join(cwd, 'migrations');
12
- if (!fs.existsSync(migrationsDir)) {
13
- fs.mkdirSync(migrationsDir, { recursive: true });
14
- console.log(`✅ Created: ${migrationsDir}/`);
15
-
16
- // Create .gitkeep
17
- fs.writeFileSync(path.join(migrationsDir, '.gitkeep'), '');
18
- } else {
19
- console.log(`⚠️ Directory already exists: ${migrationsDir}/`);
20
- }
21
-
22
- // Create config file
23
- const configPath = path.join(cwd, 'dynatable.config.js');
24
- if (!fs.existsSync(configPath)) {
25
- ConfigLoader.createDefaultConfig(configPath);
26
- } else {
27
- console.log(`⚠️ Config already exists: ${configPath}`);
28
- }
29
-
30
- console.log('\n✅ Initialization complete!\n');
31
- console.log('Next steps:');
32
- console.log(' 1. Edit dynatable.config.js with your DynamoDB settings');
33
- console.log(' 2. Create your first migration:');
34
- console.log(' dynatable-migrate create add_user_email');
35
- console.log(' 3. Run migrations:');
36
- console.log(' dynatable-migrate up\n');
37
- }
@@ -1,75 +0,0 @@
1
- import { MigrationRunner } from '../core/runner';
2
- import { createDynamoDBClient } from '../core/client';
3
- import { MigrationConfig } from '../types';
4
-
5
- export async function showStatus(config: MigrationConfig): Promise<void> {
6
- const client = createDynamoDBClient(config);
7
- const runner = new MigrationRunner(client, config);
8
-
9
- try {
10
- const statuses = await runner.status();
11
- const currentVersion = await runner.getCurrentVersion();
12
-
13
- console.log(`\n📊 Migration Status\n`);
14
- console.log(`Table: ${config.tableName}`);
15
- console.log(`Current version: ${currentVersion || 'v0000 (no migrations)'}`);
16
- console.log(`Migrations directory: ${config.migrationsDir || './migrations'}\n`);
17
-
18
- if (statuses.length === 0) {
19
- console.log('No migrations found.\n');
20
- return;
21
- }
22
-
23
- // Group by status
24
- const pending = statuses.filter((s) => s.status === 'pending');
25
- const applied = statuses.filter((s) => s.status === 'applied');
26
- const failed = statuses.filter((s) => s.status === 'failed');
27
- const rolledBack = statuses.filter((s) => s.status === 'rolled_back');
28
-
29
- // Show applied migrations
30
- if (applied.length > 0) {
31
- console.log(`✅ Applied (${applied.length}):`);
32
- for (const status of applied) {
33
- const date = status.appliedAt ? new Date(status.appliedAt).toLocaleString() : '';
34
- console.log(` ${status.version} - ${status.name} (${date})`);
35
- }
36
- console.log();
37
- }
38
-
39
- // Show pending migrations
40
- if (pending.length > 0) {
41
- console.log(`⏳ Pending (${pending.length}):`);
42
- for (const status of pending) {
43
- console.log(` ${status.version} - ${status.name}`);
44
- }
45
- console.log();
46
- }
47
-
48
- // Show rolled back migrations
49
- if (rolledBack.length > 0) {
50
- console.log(`⬅️ Rolled Back (${rolledBack.length}):`);
51
- for (const status of rolledBack) {
52
- console.log(` ${status.version} - ${status.name}`);
53
- }
54
- console.log();
55
- }
56
-
57
- // Show failed migrations
58
- if (failed.length > 0) {
59
- console.log(`❌ Failed (${failed.length}):`);
60
- for (const status of failed) {
61
- console.log(` ${status.version} - ${status.name}`);
62
- if (status.error) {
63
- console.log(` Error: ${status.error}`);
64
- }
65
- }
66
- console.log();
67
- }
68
-
69
- // Summary
70
- console.log(`Total: ${statuses.length} migration(s)\n`);
71
- } catch (error: any) {
72
- console.error(`\n❌ Failed to get status: ${error.message}\n`);
73
- process.exit(1);
74
- }
75
- }
@@ -1,26 +0,0 @@
1
- import { MigrationRunner } from '../core/runner';
2
- import { createDynamoDBClient } from '../core/client';
3
- import { MigrationConfig } from '../types';
4
-
5
- export async function runMigrations(
6
- config: MigrationConfig,
7
- limit?: number,
8
- dryRun: boolean = false
9
- ): Promise<void> {
10
- const client = createDynamoDBClient(config);
11
- const runner = new MigrationRunner(client, config);
12
-
13
- try {
14
- const executed = await runner.up({ limit, dryRun });
15
-
16
- if (!dryRun && executed.length > 0) {
17
- console.log(`\n🎉 Successfully applied ${executed.length} migration(s)`);
18
-
19
- const currentVersion = await runner.getCurrentVersion();
20
- console.log(`📌 Current version: ${currentVersion}\n`);
21
- }
22
- } catch (error: any) {
23
- console.error(`\n❌ Migration failed: ${error.message}\n`);
24
- process.exit(1);
25
- }
26
- }
@@ -1,24 +0,0 @@
1
- import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
- import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
- import { MigrationConfig } from '../types';
4
-
5
- /**
6
- * Create a DynamoDB Document Client from config
7
- */
8
- export function createDynamoDBClient(config: MigrationConfig): DynamoDBDocumentClient {
9
- const ddbClient = new DynamoDBClient({
10
- region: config.client.region,
11
- endpoint: config.client.endpoint,
12
- credentials: config.client.credentials,
13
- });
14
-
15
- return DynamoDBDocumentClient.from(ddbClient, {
16
- marshallOptions: {
17
- removeUndefinedValues: true,
18
- convertClassInstanceToMap: true,
19
- },
20
- unmarshallOptions: {
21
- wrapNumbers: false,
22
- },
23
- });
24
- }
@@ -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
- }
@@ -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
- }