@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -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 +7 -1
  19. package/dist/core/loader.d.ts.map +1 -1
  20. package/dist/core/loader.js +35 -6
  21. package/dist/core/loader.js.map +1 -1
  22. package/dist/core/tracker.d.ts.map +1 -1
  23. package/dist/core/tracker.js +23 -12
  24. package/dist/core/tracker.js.map +1 -1
  25. package/package.json +35 -4
  26. package/.gitkeep +0 -0
  27. package/jest.config.js +0 -8
  28. package/migrations/.gitkeep +0 -0
  29. package/src/cli-utils.test.ts +0 -45
  30. package/src/cli-utils.ts +0 -18
  31. package/src/cli.ts +0 -110
  32. package/src/commands/create.ts +0 -111
  33. package/src/commands/down.ts +0 -26
  34. package/src/commands/init.ts +0 -37
  35. package/src/commands/status.ts +0 -75
  36. package/src/commands/up.ts +0 -26
  37. package/src/core/client.ts +0 -24
  38. package/src/core/config.ts +0 -140
  39. package/src/core/errors.ts +0 -55
  40. package/src/core/loader.ts +0 -256
  41. package/src/core/lock-heartbeat.test.ts +0 -78
  42. package/src/core/lock-heartbeat.ts +0 -48
  43. package/src/core/runner.test.ts +0 -51
  44. package/src/core/runner.ts +0 -315
  45. package/src/core/semver.test.ts +0 -33
  46. package/src/core/semver.ts +0 -26
  47. package/src/core/tracker.test.ts +0 -281
  48. package/src/core/tracker.ts +0 -559
  49. package/src/index.ts +0 -25
  50. package/src/templates/migration.ts +0 -92
  51. package/src/types/index.ts +0 -220
  52. package/tsconfig.json +0 -19
@@ -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
- }
@@ -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
- });