@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,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
- }
@@ -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
- });