Skip to content

Commit bd49ad9

Browse files
authored
feat: support index based filename naming strategy (#1443)
1 parent a3731f8 commit bd49ad9

File tree

5 files changed

+191
-41
lines changed

5 files changed

+191
-41
lines changed

bin/node-pg-migrate.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const parser = yargs(process.argv.slice(2))
173173
},
174174
[migrationFilenameFormatArg]: {
175175
defaultDescription: '"timestamp"',
176-
choices: ['timestamp', 'utc'],
176+
choices: ['timestamp', 'utc', 'index'],
177177
describe:
178178
'Prefix type of migration filename (Only valid with the create action)',
179179
type: 'string',
@@ -425,7 +425,8 @@ function readJson(json: unknown): void {
425425
MIGRATIONS_FILENAME_FORMAT,
426426
migrationFilenameFormatArg,
427427
json,
428-
(val): val is FilenameFormat => val === 'timestamp' || val === 'utc'
428+
(val): val is FilenameFormat =>
429+
val === 'timestamp' || val === 'utc' || val === 'index'
429430
);
430431
TEMPLATE_FILE_NAME = applyIf(
431432
TEMPLATE_FILE_NAME,

docs/src/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ You can adjust defaults by passing arguments to `node-pg-migrate`:
9393
| `create-migrations-schema` | | `false` | Create the configured migrations schema if it doesn't exist |
9494
| `migrations-table` | `t` | `pgmigrations` | The table storing which migrations have been run |
9595
| `ignore-pattern` | | `undefined` | Regex pattern for file names to ignore (ignores files starting with `.` by default). Alternatively, provide a [glob](https://www.npmjs.com/package/glob) pattern and set `--use-glob`. Note: enabling glob will read both, `--migrations-dir` _and_ `--ignore-pattern` as glob patterns |
96-
| `migration-filename-format` | | `timestamp` | Choose prefix of file, `utc` (`20200605075829074`) or `timestamp` (`1591343909074`) |
96+
| `migration-filename-format` | | `timestamp` | Choose prefix of file, `utc` (`20200605075829074`), `timestamp` (`1591343909074`), or `index` (`0012`) |
9797
| `migration-file-language` | `j` | `js` | Language of the migration file to create (`js`, `ts` or `sql`) |
9898
| `template-file-name` | | `undefined` | Utilize a custom migration template file with language inferred from its extension. The file should export the up method, accepting a MigrationBuilder instance. |
9999
| `tsconfig` | | `undefined` | Path to tsconfig.json. Used to setup transpiling of TS migration files. (Also sets `migration-file-language` to typescript, if not overridden) |

src/migration.ts

Lines changed: 102 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface RunMigration {
3333
export const FilenameFormat = Object.freeze({
3434
timestamp: 'timestamp',
3535
utc: 'utc',
36+
index: 'index',
3637
});
3738

3839
export type FilenameFormat =
@@ -49,6 +50,7 @@ export interface CreateOptionsDefault {
4950

5051
export type CreateOptions = {
5152
filenameFormat?: FilenameFormat;
53+
ignorePattern?: string;
5254
} & (CreateOptionsTemplate | CreateOptionsDefault);
5355

5456
const SEPARATOR = '_';
@@ -187,8 +189,11 @@ async function getLastSuffix(
187189
}
188190

189191
/**
190-
* extracts numeric value from everything in `filename` before `SEPARATOR`.
191-
* 17 digit numbers are interpreted as utc date and converted to the number representation of that date.
192+
* Extracts numeric value from everything in `filename` before `SEPARATOR`.
193+
* 17 digit numbers are interpreted as UTC date and converted to the number
194+
* representation of that date. 1...4 digit numbers are interpreted as index
195+
* based naming scheme.
196+
*
192197
* @param filename filename to extract the prefix from
193198
* @param logger Redirect messages to this logger object, rather than `console`.
194199
* @returns numeric value of the filename prefix (everything before `SEPARATOR`).
@@ -197,30 +202,30 @@ export function getNumericPrefix(
197202
filename: string,
198203
logger: Logger = console
199204
): number {
200-
const prefix = filename.split(SEPARATOR)[0];
201-
if (prefix && /^\d+$/.test(prefix)) {
202-
if (prefix.length === 13) {
203-
// timestamp: 1391877300255
204-
return Number(prefix);
205-
}
205+
const prefix = (/^(\d+)/.exec(filename) || '')[0];
206+
const value = Number(prefix);
206207

207-
if (prefix && prefix.length === 17) {
208-
// utc: 20200513070724505
209-
const year = prefix.slice(0, 4);
210-
const month = prefix.slice(4, 6);
211-
const date = prefix.slice(6, 8);
212-
const hours = prefix.slice(8, 10);
213-
const minutes = prefix.slice(10, 12);
214-
const seconds = prefix.slice(12, 14);
215-
const ms = prefix.slice(14, 17);
216-
return new Date(
217-
`${year}-${month}-${date}T${hours}:${minutes}:${seconds}.${ms}Z`
218-
).valueOf();
219-
}
208+
if (!/^\d+$/.test(prefix) || Number.isNaN(value)) {
209+
logger.error(`Cannot determine numeric prefix for "${filename}"`);
210+
throw new Error(`Cannot determine numeric prefix for "${filename}"`);
220211
}
221212

222-
logger.error(`Can't determine timestamp for ${prefix}`);
223-
return Number(prefix) || 0;
213+
// Special case for UTC timestamp
214+
if (prefix.length === 17) {
215+
// utc: 20200513070724505
216+
const year = prefix.slice(0, 4);
217+
const month = prefix.slice(4, 6);
218+
const date = prefix.slice(6, 8);
219+
const hours = prefix.slice(8, 10);
220+
const minutes = prefix.slice(10, 12);
221+
const seconds = prefix.slice(12, 14);
222+
const ms = prefix.slice(14, 17);
223+
return new Date(
224+
`${year}-${month}-${date}T${hours}:${minutes}:${seconds}.${ms}Z`
225+
).valueOf();
226+
}
227+
228+
return value;
224229
}
225230

226231
async function resolveSuffix(
@@ -232,22 +237,78 @@ async function resolveSuffix(
232237
}
233238

234239
export class Migration implements RunMigration {
235-
// class method that creates a new migration file by cloning the migration template
240+
/**
241+
* Get file prefix for a new migrations file
242+
*
243+
* @method Migration.getFilePrefix
244+
* @param filenameFormat Filename format
245+
* @param directory Migrations directory
246+
* @param [ignorePattern] Glob ignore pattern
247+
* @returns string New file prefix
248+
*/
249+
static async getFilePrefix(
250+
filenameFormat: string,
251+
directory: string,
252+
ignorePattern?: string
253+
): Promise<string> {
254+
if (filenameFormat === FilenameFormat.index) {
255+
const filePaths = await getMigrationFilePaths(directory, {
256+
ignorePattern,
257+
useGlob: /\*/.test(directory) || /\*/.test(ignorePattern || ''),
258+
});
259+
260+
// Get the minimum last found prefix as the total number of matching files
261+
let lastPrefix = 0;
262+
let prefixLength = 0;
263+
264+
// Increment can be used only when there are no mismatching filenames, so all
265+
// the filenames have to be verified first against "index" naming pattern
266+
for (const filenamePath of filePaths) {
267+
const filename = basename(filenamePath);
268+
269+
if (!/^\d+\D/.test(filename)) {
270+
throw new Error(
271+
`Cannot deduce index for previously created file "${filenamePath}"`
272+
);
273+
}
274+
275+
const prefix = filename.split(/\D/)[0] ?? '';
276+
prefixLength = Math.max(prefixLength, prefix.length);
277+
lastPrefix = Math.max(lastPrefix, getNumericPrefix(filename));
278+
}
279+
280+
// Next prefix is one more than the last found prefix
281+
return `${lastPrefix + 1}`.padStart(
282+
// Use default padding of 4 characters, but continue the previous
283+
// naming scheme when applicable
284+
prefixLength || 4,
285+
'0'
286+
);
287+
}
288+
289+
return filenameFormat === FilenameFormat.utc
290+
? new Date().toISOString().replace(/\D/g, '')
291+
: Date.now().toString();
292+
}
293+
294+
// Class method that creates a new migration file by cloning the migration template
236295
static async create(
237296
name: string,
238297
directory: string,
239298
options: CreateOptions = {}
240299
): Promise<string> {
241-
const { filenameFormat = FilenameFormat.timestamp } = options;
300+
const { filenameFormat = FilenameFormat.timestamp, ignorePattern } =
301+
options;
242302

243-
// ensure the migrations directory exists
303+
// Ensure the migrations directory exists
244304
await mkdir(directory, { recursive: true });
245305

246-
const now = new Date();
247-
const time =
248-
filenameFormat === FilenameFormat.utc
249-
? now.toISOString().replace(/\D/g, '')
250-
: now.valueOf();
306+
// Get prefix based on the configured naming scheme
307+
const prefix = await Migration.getFilePrefix(
308+
filenameFormat,
309+
directory,
310+
ignorePattern
311+
);
251312

252313
const templateFileName =
253314
'templateFileName' in options
@@ -262,7 +323,7 @@ export class Migration implements RunMigration {
262323
const suffix = getSuffixFromFileName(templateFileName);
263324

264325
// file name looks like migrations/1391877300255_migration-title.js
265-
const newFile = join(directory, `${time}${SEPARATOR}${name}.${suffix}`);
326+
const newFile = join(directory, `${prefix}${SEPARATOR}${name}.${suffix}`);
266327

267328
// copy the default migration template to the new file location
268329
await new Promise<void>((resolve, reject) => {
@@ -304,12 +365,20 @@ export class Migration implements RunMigration {
304365
this.db = db;
305366
this.path = migrationPath;
306367
this.name = basename(migrationPath, extname(migrationPath));
307-
this.timestamp = getNumericPrefix(this.name, logger);
308368
this.up = up;
309369
this.down = down;
310370
this.options = options;
311371
this.typeShorthands = typeShorthands;
312372
this.logger = logger;
373+
374+
// Backwards compatibility patch: getNumericPrefix failed silently earlier
375+
// so this try-catch block is to simulate it. The constructor should fail
376+
// with invalid prefix rather than allow to continue.
377+
try {
378+
this.timestamp = getNumericPrefix(this.name, logger);
379+
} catch {
380+
this.timestamp = 0;
381+
}
313382
}
314383

315384
_getMarkAsRun(action: MigrationAction): string {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const up = (pgm) => {
2+
pgm.createExtension('uuid-ossp', { ifNotExists: true });
3+
pgm.dropExtension('uuid-ossp');
4+
pgm.createExtension('uuid-ossp');
5+
pgm.dropExtension('uuid-ossp');
6+
};
7+
8+
export const down = () => null;

test/migration.spec.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RunnerOption } from '../src';
55
import type { DBConnection } from '../src/db';
66
import type { Logger } from '../src/logger';
77
import {
8+
FilenameFormat,
89
getMigrationFilePaths,
910
getNumericPrefix,
1011
Migration,
@@ -40,9 +41,21 @@ describe('migration', () => {
4041
});
4142

4243
describe('getNumericPrefix', () => {
44+
it('should allow any non-numeric character as a separator', () => {
45+
expect(getNumericPrefix('1-line-as-separator.js')).toBe(1);
46+
expect(getNumericPrefix('2_underscore-as-separator.ts')).toBe(2);
47+
expect(getNumericPrefix('3 space-as-separator.sql')).toBe(3);
48+
});
49+
50+
it('should fail with a non-numeric value', () => {
51+
const prefix = 'invalid-prefix';
52+
expect(() => getNumericPrefix(prefix, logger)).toThrow(
53+
new Error(`Cannot determine numeric prefix for "${prefix}"`)
54+
);
55+
});
56+
4357
it('should get timestamp for normal timestamp', () => {
4458
const now = Date.now();
45-
4659
expect(getNumericPrefix(String(now), logger)).toBe(now);
4760
});
4861

@@ -53,6 +66,11 @@ describe('migration', () => {
5366
getNumericPrefix(now.toISOString().replace(/\D/g, ''), logger)
5467
).toBe(now.valueOf());
5568
});
69+
70+
it('should get prefix for index strings', () => {
71+
expect(getNumericPrefix('0001', logger)).toBe(1);
72+
expect(getNumericPrefix('1234', logger)).toBe(1234);
73+
});
5674
});
5775

5876
describe('getMigrationFilePaths', () => {
@@ -126,6 +144,60 @@ describe('migration', () => {
126144
expect(isAbsolute(filePath)).toBeTruthy();
127145
}
128146
});
147+
148+
it('should resolve a sane starting point when migration files do not exist', async () => {
149+
const dir = 'test/directory-does-not-exist/**';
150+
151+
const nextPrefix = await Migration.getFilePrefix(
152+
FilenameFormat.index,
153+
dir
154+
);
155+
156+
expect(nextPrefix).toEqual('0001');
157+
});
158+
159+
it('should resolve the next index for file paths', async () => {
160+
const dir = 'test/{cockroach,migrations}/**';
161+
// ignores those files that have `test` in their name (not in the path, just filename)
162+
const ignorePattern = '*/cockroach/*test*';
163+
164+
const nextPrefix = await Migration.getFilePrefix(
165+
FilenameFormat.index,
166+
dir,
167+
ignorePattern
168+
);
169+
170+
expect(nextPrefix).toEqual('095');
171+
});
172+
173+
it('should fail to get the next index with invalid filenames', async () => {
174+
const prefix = Migration.getFilePrefix(
175+
FilenameFormat.index,
176+
'test/invalid-migrations/invalid-prefix.*'
177+
);
178+
179+
await expect(prefix).rejects.toThrow();
180+
});
181+
182+
it('should get an epoch timestamp as a prefix', async () => {
183+
const dir = 'test/migrations/**';
184+
const prefix = await Migration.getFilePrefix('timestamp', dir);
185+
186+
// Checking against asynchronous code: prefix should be very close as now
187+
// but is not exactly the same millisecond
188+
expect(Number.parseInt(prefix) - Date.now() < 100).toEqual(true);
189+
});
190+
191+
it('should get a normalized UTC as a prefix', async () => {
192+
const now = Number.parseInt(new Date().toISOString().replace(/\D/g, ''));
193+
194+
const dir = 'test/migrations/**';
195+
const prefix = await Migration.getFilePrefix('utc', dir);
196+
197+
// Checking against asynchronous code: prefix should be very close as now
198+
// but is not exactly the same millisecond
199+
expect(Number.parseInt(prefix) - now < 100).toEqual(true);
200+
});
129201
});
130202

131203
describe('self.applyUp', () => {
@@ -220,8 +292,8 @@ describe('migration', () => {
220292
});
221293

222294
it('should fail with an error message if the migration is invalid', () => {
295+
const direction = 'up';
223296
const invalidMigrationName = 'invalid-migration';
224-
225297
const migration = new Migration(
226298
dbMock,
227299
invalidMigrationName,
@@ -231,9 +303,9 @@ describe('migration', () => {
231303
logger
232304
);
233305

234-
const direction = 'up';
235-
236-
expect(() => migration.apply(direction)).toThrow(
306+
expect(() => {
307+
migration.apply(direction);
308+
}).toThrow(
237309
new Error(
238310
`Unknown value for direction: ${direction}. Is the migration ${invalidMigrationName} exporting a '${direction}' function?`
239311
)

0 commit comments

Comments
 (0)