@resin/pinejs
Version:
Pine.js is a sophisticated rules-driven API engine that enables you to define rules in a structured subset of English. Those rules are used in order for Pine.js to generate a database schema and the associated [OData](http://www.odata.org/) API. This make
270 lines (237 loc) • 7.03 kB
text/typescript
import type { Tx } from '../database-layer/db';
import type { Resolvable } from '../sbvr-api/common-types';
import type { Config, Model } from '../config-loader/config-loader';
import { Engines } from '@resin/abstract-sql-compiler';
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import { TypedError } from 'typed-error';
import { migrator as migratorEnv } from '../config-loader/env';
import * as sbvrUtils from '../sbvr-api/sbvr-utils';
// tslint:disable-next-line:no-var-requires
const modelText: string = require('./migrations.sbvr');
type ApiRootModel = Model & { apiRoot: string };
type SbvrUtils = typeof sbvrUtils;
type MigrationTuple = [string, Migration];
export type MigrationFn = (tx: Tx, sbvrUtils: SbvrUtils) => Resolvable<void>;
export type Migration = string | MigrationFn;
export class MigrationError extends TypedError {}
// Tagged template to convert binds from `?` format to the necessary output format,
// eg `$1`/`$2`/etc for postgres
const binds = (strings: TemplateStringsArray, ...bindNums: number[]) =>
strings
.map((str, i) => {
if (i === bindNums.length) {
return str;
}
if (i + 1 !== bindNums[i]) {
throw new SyntaxError('Migration sql binds must be sequential');
}
if (sbvrUtils.db.engine === Engines.postgres) {
return str + `$${bindNums[i]}`;
}
return str + `?`;
})
.join('');
export const postRun = Bluebird.method(
async (tx: Tx, model: ApiRootModel): Promise<void> => {
const { initSql } = model;
if (initSql == null) {
return;
}
const modelName = model.apiRoot;
const exists = await checkModelAlreadyExists(tx, modelName);
if (!exists) {
(sbvrUtils.api.migrations?.logger.info ?? console.info)(
'First time executing, running init script',
);
await Bluebird.using(lockMigrations(tx, modelName), async () => {
await tx.executeSql(initSql);
});
}
},
);
export const run = Bluebird.method(
async (tx: Tx, model: ApiRootModel): Promise<void> => {
const { migrations } = model;
if (migrations == null || _.isEmpty(migrations)) {
return;
}
const modelName = model.apiRoot;
// migrations only run if the model has been executed before,
// to make changes that can't be automatically applied
const exists = await checkModelAlreadyExists(tx, modelName);
if (!exists) {
(sbvrUtils.api.migrations?.logger.info ?? console.info)(
'First time model has executed, skipping migrations',
);
return setExecutedMigrations(tx, modelName, Object.keys(migrations));
}
await Bluebird.using(lockMigrations(tx, modelName), async () => {
const executedMigrations = await getExecutedMigrations(tx, modelName);
const pendingMigrations = filterAndSortPendingMigrations(
migrations,
executedMigrations,
);
if (pendingMigrations.length === 0) {
return;
}
const newlyExecutedMigrations = await executeMigrations(
tx,
pendingMigrations,
);
return setExecutedMigrations(tx, modelName, [
...executedMigrations,
...newlyExecutedMigrations,
]);
});
},
);
const checkModelAlreadyExists = Bluebird.method(
async (tx: Tx, modelName: string): Promise<boolean> => {
const result = await tx.tableList("name = 'migration'");
if (result.rows.length === 0) {
return false;
}
const { rows } = await tx.executeSql(
binds`
SELECT 1
FROM "model"
WHERE "model"."is of-vocabulary" = ${1}
LIMIT 1`,
[modelName],
);
return rows.length > 0;
},
);
const getExecutedMigrations = async (
tx: Tx,
modelName: string,
): Promise<string[]> => {
const { rows } = await tx.executeSql(
binds`
SELECT "migration"."executed migrations" AS "executed_migrations"
FROM "migration"
WHERE "migration"."model name" = ${1}`,
[modelName],
);
const data = rows[0];
if (data == null) {
return [];
}
return JSON.parse(data.executed_migrations) as string[];
};
const setExecutedMigrations = async (
tx: Tx,
modelName: string,
executedMigrations: string[],
): Promise<void> => {
const stringifiedMigrations = JSON.stringify(executedMigrations);
const result = await tx.tableList("name = 'migration'");
if (result.rows.length === 0) {
return;
}
const { rowsAffected } = await tx.executeSql(
binds`
UPDATE "migration"
SET "model name" = ${1},
"executed migrations" = ${2}
WHERE "migration"."model name" = ${3}`,
[modelName, stringifiedMigrations, modelName],
);
if (rowsAffected === 0) {
tx.executeSql(
binds`
INSERT INTO "migration" ("model name", "executed migrations")
VALUES (${1}, ${2})`,
[modelName, stringifiedMigrations],
);
}
};
// turns {"key1": migration, "key3": migration, "key2": migration}
// into [["key1", migration], ["key2", migration], ["key3", migration]]
const filterAndSortPendingMigrations = (
migrations: NonNullable<Model['migrations']>,
executedMigrations: string[],
): MigrationTuple[] =>
(_(migrations).omit(executedMigrations) as _.Object<typeof migrations>)
.toPairs()
.sortBy(([migrationKey]) => migrationKey)
.value();
const lockMigrations = (tx: Tx, modelName: string): Bluebird.Disposer<void> =>
Bluebird.try(async () => {
try {
await tx.executeSql(
binds`
DELETE FROM "migration lock"
WHERE "model name" = ${1}
AND "created at" < ${2}`,
[modelName, new Date(Date.now() - migratorEnv.lockTimeout)],
);
await tx.executeSql(
binds`
INSERT INTO "migration lock" ("model name")
VALUES (${1})`,
[modelName],
);
} catch (err) {
await Bluebird.delay(migratorEnv.lockFailDelay);
throw err;
}
}).disposer(async () => {
await tx.executeSql(
binds`
DELETE FROM "migration lock"
WHERE "model name" = ${1}`,
[modelName],
);
});
const executeMigrations = async (
tx: Tx,
migrations: MigrationTuple[] = [],
): Promise<string[]> => {
try {
for (const migration of migrations) {
await executeMigration(tx, migration);
}
} catch (err) {
(sbvrUtils.api.migrations?.logger.error ?? console.error)(
'Error while executing migrations, rolled back',
);
throw new MigrationError(err);
}
return migrations.map(([migrationKey]) => migrationKey); // return migration keys
};
const executeMigration = async (
tx: Tx,
[key, migration]: MigrationTuple,
): Promise<void> => {
(sbvrUtils.api.migrations?.logger.info ?? console.info)(
`Running migration ${JSON.stringify(key)}`,
);
if (typeof migration === 'function') {
await migration(tx, sbvrUtils);
} else if (typeof migration === 'string') {
await tx.executeSql(migration);
} else {
throw new MigrationError(`Invalid migration type: ${typeof migration}`);
}
};
export const config: Config = {
models: [
{
modelName: 'migrations',
apiRoot: 'migrations',
modelText,
migrations: {
'11.0.0-modified-at': `
ALTER TABLE "migration"
ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL;
`,
'11.0.1-modified-at': `
ALTER TABLE "migration lock"
ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL;
`,
},
},
],
};