@sqb/migrator 5.0.7 → 6.0.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/README.md CHANGED
@@ -1,23 +1,49 @@
1
- <center>
2
- <p>
1
+ <p style="text-align:center">
3
2
  <img src="https://user-images.githubusercontent.com/3836517/32965280-1a2b63ce-cbe7-11e7-8ee1-ba47313503c5.png" width="500px" alt="SQB Logo"/>
4
3
  </p>
5
- </center>
6
4
 
7
5
  <br>
8
6
 
9
7
  [![NPM Version][npm-image]][npm-url]
10
8
  [![NPM Downloads][downloads-image]][downloads-url]
11
- [![Build Status][travis-image]][travis-url]
9
+ [![CI Tests][ci-test-image]][ci-test-url]
12
10
  [![Test Coverage][coveralls-image]][coveralls-url]
13
- [![Dependencies][dependencies-image]][dependencies-url]
14
- [![DevDependencies][devdependencies-image]][devdependencies-url]
15
- [![Package Quality][quality-image]][quality-url]
16
11
 
17
12
  ## About SQB
18
13
 
19
14
  SQB is an extensible, multi-dialect SQL query builder and Database connection wrapper for NodeJS.
20
15
 
16
+ ## About @sqb/migrator
17
+
18
+ `@sqb/migrator` is a schema/data migration runner for SQB. A `MigrationPackage` describes an
19
+ ordered set of versioned migrations, each made up of one or more tasks:
20
+
21
+ - a raw SQL script (a `.sql` file, or an inline string/function),
22
+ - a data-insert task (`{ tableName, rows }`), or
23
+ - a custom function that runs arbitrary code against the connection.
24
+
25
+ Migrations and tasks can be declared inline or discovered from disk via glob patterns (e.g.
26
+ `v*/migration.json` + `*.task.sql`). `DbMigrator.execute()` loads the package, compares its
27
+ migrations against the target database's tracked version, and applies everything up to an
28
+ optional `targetVersion` in order — recording progress (and per-task success/error events) in a
29
+ `migration_summary` / `migration_events` table pair it creates automatically. Migration scripts
30
+ can reference `$(schema)`, `$(tablespace)` and other variables that get substituted per-run.
31
+
32
+ ```ts
33
+ import { DbMigrator } from '@sqb/migrator';
34
+
35
+ const migrator = new DbMigrator();
36
+ await migrator.execute({
37
+ connection: { dialect: 'postgres', database: 'mydb' },
38
+ migrationPackage: {
39
+ name: 'my-app',
40
+ migrations: ['migrations/v*/migration.json'],
41
+ },
42
+ });
43
+ ```
44
+
45
+ Currently only PostgreSQL is supported, via the bundled `PgMigrationAdapter`.
46
+
21
47
  ## Main goals
22
48
 
23
49
  - Single code base for any sql based database
@@ -41,7 +67,7 @@ $ npm install @sqb/migrator --save
41
67
 
42
68
  ## Node Compatibility
43
69
 
44
- - node >= 16.x
70
+ - node >= 20.x
45
71
 
46
72
  ### License
47
73
 
@@ -49,17 +75,9 @@ SQB is available under [MIT](LICENSE) license.
49
75
 
50
76
  [npm-image]: https://img.shields.io/npm/v/@sqb/migrator.svg
51
77
  [npm-url]: https://npmjs.org/package/@sqb/migrator
52
- [travis-image]: https://img.shields.io/travis/sqbjs/@sqb/migrator/master.svg
53
- [travis-url]: https://travis-ci.org/sqbjs/@sqb/migrator
54
- [coveralls-image]: https://img.shields.io/coveralls/sqbjs/@sqb/migrator/master.svg
55
- [coveralls-url]: https://coveralls.io/r/sqbjs/@sqb/migrator
56
78
  [downloads-image]: https://img.shields.io/npm/dm/@sqb/migrator.svg
57
79
  [downloads-url]: https://npmjs.org/package/@sqb/migrator
58
- [gitter-image]: https://badges.gitter.im/sqbjs/@sqb/migrator.svg
59
- [gitter-url]: https://gitter.im/sqbjs/@sqb/migrator?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
60
- [dependencies-image]: https://david-dm.org/sqbjs/@sqb/migrator/status.svg
61
- [dependencies-url]: https://david-dm.org/sqbjs/@sqb/migrator
62
- [devdependencies-image]: https://david-dm.org/sqbjs/@sqb/migrator/dev-status.svg
63
- [devdependencies-url]: https://david-dm.org/sqbjs/@sqb/migrator?type=dev
64
- [quality-image]: http://npm.packagequality.com/shield/@sqb/migrator.png
65
- [quality-url]: http://packagequality.com/#?package=@sqb/migrator
80
+ [ci-test-image]: https://github.com/panates/sqb/actions/workflows/test.yml/badge.svg
81
+ [ci-test-url]: https://github.com/panates/sqb/actions/workflows/test.yml
82
+ [coveralls-image]: https://coveralls.io/repos/github/sqbjs/sqb/badge.svg?branch=master
83
+ [coveralls-url]: https://coveralls.io/github/sqbjs/sqb?branch=master
@@ -35,6 +35,11 @@ export declare class PgMigrationAdapter extends MigrationAdapter {
35
35
  writeEvent(event: MigrationAdapter.Event): Promise<void>;
36
36
  executeTask(migrationPackage: MigrationPackage, migration: Migration, task: MigrationTask, variables: Record<string, any>): Promise<void>;
37
37
  backupDatabase(): Promise<void>;
38
+ /**
39
+ * Serializes concurrent migration runs against the same package using a
40
+ * Postgres session-level advisory lock, so two processes migrating the
41
+ * same schema at once can't race on the summary table bookkeeping.
42
+ */
38
43
  lockSchema(): Promise<void>;
39
44
  restoreDatabase(): Promise<void>;
40
45
  unlockSchema(): Promise<void>;
@@ -5,6 +5,15 @@ import { MigrationAdapter } from '../migration-adapter.js';
5
5
  import { isCustomMigrationTask, isInsertDataMigrationTask, isSqlScriptMigrationTask, MigrationPackage, } from '../migration-package.js';
6
6
  import { MigrationStatus } from '../types.js';
7
7
  const pgAdapter = new PgAdapter();
8
+ // Quotes a (possibly schema-qualified, e.g. "myschema.mytable") Postgres
9
+ // identifier so reserved words / mixed case / special characters in a
10
+ // migration task's tableName or row keys don't break the generated SQL.
11
+ function quoteIdent(name) {
12
+ return name
13
+ .split('.')
14
+ .map(part => '"' + part.replace(/"/g, '""') + '"')
15
+ .join('.');
16
+ }
8
17
  export class PgMigrationAdapter extends MigrationAdapter {
9
18
  _infoSchema = 'public';
10
19
  _version = 0;
@@ -52,7 +61,10 @@ export class PgMigrationAdapter extends MigrationAdapter {
52
61
  r.rows?.[0]?.current_schema || 'public';
53
62
  }
54
63
  // Check if migration schema
55
- await connection.query(`CREATE SCHEMA IF NOT EXISTS ${adapter.infoSchema} AUTHORIZATION postgres;`);
64
+ // No AUTHORIZATION clause - let it default to the connecting role,
65
+ // since hardcoding a specific owner (e.g. "postgres") makes this
66
+ // fail with a permission error for any non-superuser connection.
67
+ await connection.query(`CREATE SCHEMA IF NOT EXISTS ${adapter.infoSchema};`);
56
68
  // Create summary table if not exists
57
69
  await connection.execute(`
58
70
  CREATE TABLE IF NOT EXISTS ${adapter.summaryTableFull}
@@ -118,7 +130,7 @@ CREATE TABLE IF NOT EXISTS ${adapter.eventTableFull}
118
130
  params.push(info.status);
119
131
  sql += ',\n status = $' + params.length;
120
132
  }
121
- if (info.version && info.version !== this.version) {
133
+ if (info.version != null && info.version !== this.version) {
122
134
  params.push(info.version);
123
135
  sql += ',\n current_version = $' + params.length;
124
136
  }
@@ -132,7 +144,7 @@ CREATE TABLE IF NOT EXISTS ${adapter.eventTableFull}
132
144
  await this._connection.query(sql, { params });
133
145
  if (info.status)
134
146
  this._status = info.status;
135
- if (info.version)
147
+ if (info.version != null)
136
148
  this._version = info.version;
137
149
  }
138
150
  }
@@ -207,19 +219,27 @@ CREATE TABLE IF NOT EXISTS ${adapter.eventTableFull}
207
219
  backupDatabase() {
208
220
  return Promise.resolve(undefined);
209
221
  }
210
- lockSchema() {
211
- return Promise.resolve(undefined);
222
+ /**
223
+ * Serializes concurrent migration runs against the same package using a
224
+ * Postgres session-level advisory lock, so two processes migrating the
225
+ * same schema at once can't race on the summary table bookkeeping.
226
+ */
227
+ async lockSchema() {
228
+ await this._connection.query('SELECT pg_advisory_lock(hashtext($1)::bigint)', { params: [this.infoSchema + '.' + this.packageName] });
229
+ // Another process may have advanced the tracked version while we were
230
+ // waiting for the lock - re-read it now that we hold the lock.
231
+ await this.refresh();
212
232
  }
213
233
  restoreDatabase() {
214
234
  return Promise.resolve(undefined);
215
235
  }
216
- unlockSchema() {
217
- return Promise.resolve(undefined);
236
+ async unlockSchema() {
237
+ await this._connection.query('SELECT pg_advisory_unlock(hashtext($1)::bigint)', { params: [this.infoSchema + '.' + this.packageName] });
218
238
  }
219
239
  rowToSql(tableName, row) {
220
240
  let sql = '';
221
241
  const keys = Object.keys(row);
222
- sql += `insert into ${tableName} (${keys}) values (`;
242
+ sql += `insert into ${quoteIdent(tableName)} (${keys.map(quoteIdent).join(', ')}) values (`;
223
243
  for (let i = 0; i < keys.length; i++) {
224
244
  sql += (i ? ', ' : '') + stringifyValueForSQL(row[keys[i]]);
225
245
  }
package/db-migrator.js CHANGED
@@ -12,7 +12,7 @@ export class DbMigrator extends AsyncEventEmitter {
12
12
  if (minVersion === Number.MAX_SAFE_INTEGER)
13
13
  minVersion = 0;
14
14
  const maxVersion = migrationPackage.migrations.reduce((a, m) => Math.max(a, m.version), 0);
15
- const targetVersion = Math.min(options?.targetVersion || Number.MAX_SAFE_INTEGER, maxVersion);
15
+ const targetVersion = Math.min(options?.targetVersion ?? Number.MAX_SAFE_INTEGER, maxVersion);
16
16
  if (targetVersion && targetVersion < minVersion) {
17
17
  // noinspection ExceptionCaughtLocallyJS
18
18
  throw new Error(`Version mismatch. Target schema version (${targetVersion}) is lower than ` +
@@ -40,6 +40,12 @@ export var MigrationPackage;
40
40
  }
41
41
  }
42
42
  srcMigrations.sort((a, b) => a.version - b.version);
43
+ const seenVersions = new Set();
44
+ for (const m of srcMigrations) {
45
+ if (seenVersions.has(m.version))
46
+ throw new Error(`Migration package "${asyncConfig.name}" has more than one migration defined for version ${m.version}`);
47
+ seenVersions.add(m.version);
48
+ }
43
49
  for (const migration of srcMigrations) {
44
50
  const trgMigration = {
45
51
  baseDir: '',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sqb/migrator",
3
3
  "description": "Database migrator for SQB",
4
- "version": "5.0.7",
4
+ "version": "6.0.0",
5
5
  "author": "Panates",
6
6
  "license": "Apache-2.0",
7
7
  "dependencies": {
@@ -12,9 +12,9 @@
12
12
  "tslib": "^2.8.1"
13
13
  },
14
14
  "peerDependencies": {
15
- "@sqb/builder": "^5.0.7",
16
- "@sqb/connect": "^5.0.7",
17
- "@sqb/postgres": "^5.0.7"
15
+ "@sqb/builder": "^6.0.0",
16
+ "@sqb/connect": "^6.0.0",
17
+ "@sqb/postgres": "^6.0.0"
18
18
  },
19
19
  "type": "module",
20
20
  "module": "./index.js",
@@ -1,11 +1,16 @@
1
1
  const PATH_PATTERN = /^(?:file:\/\/)?(.+)$/;
2
2
  export function getCallingFilename(position = 0) {
3
3
  position++;
4
- if (position >= Error.stackTraceLimit)
5
- return '';
4
+ const oldStackTraceLimit = Error.stackTraceLimit;
6
5
  const oldPrepareStackTrace = Error.prepareStackTrace;
6
+ // Force the limit deep enough for this call regardless of any ambient
7
+ // Error.stackTraceLimit set elsewhere in the process - otherwise a small
8
+ // ambient limit makes this silently return '' and callers fall back to
9
+ // the wrong base directory.
10
+ Error.stackTraceLimit = position + 1;
7
11
  Error.prepareStackTrace = (_, stack) => stack;
8
12
  const stack = new Error().stack;
13
+ Error.stackTraceLimit = oldStackTraceLimit;
9
14
  Error.prepareStackTrace = oldPrepareStackTrace;
10
15
  if (stack !== null && typeof stack === 'object') {
11
16
  // stack[0] holds this file