schwifty
Version:
A hapi plugin integrating Objection ORM
367 lines (255 loc) • 11.6 kB
JavaScript
;
const Path = require('path');
const Knex = require('knex');
const Hoek = require('@hapi/hoek');
const Joi = require('@hapi/joi');
const Toys = require('toys');
const Migrator = require('./migrator');
const SchwiftyModel = require('./model');
const Schema = require('./schema');
const Helpers = require('./helpers');
const Package = require('../package.json');
const internals = {};
exports.Model = SchwiftyModel;
exports.migrationsStubPath = Path.join(__dirname, 'migration.stub');
exports.assertCompatible = (A, B, msg) => {
const isExtension = (A.prototype instanceof B) || (B.prototype instanceof A);
const nameA = Helpers.getName(A);
const nameB = Helpers.getName(B);
const nameMatches = nameA === nameB; // Will appear by the same name on `server.models()` (plugin compat)
const tablenameMatches = A.tableName === B.tableName; // Will touch the same table in the database (query compat)
Hoek.assert(isExtension && nameMatches && tablenameMatches, msg || 'Models are incompatible. One model must extend the other, they must have the same name, and share the same tableName.');
};
exports.sandbox = Helpers.symbols.sandbox;
exports.bindKnex = Helpers.symbols.bindKnex;
exports.plugin = {
pkg: Package,
multiple: true,
register: (server, options) => {
Joi.assert(options, Schema.plugin, 'Bad plugin options passed to schwifty.');
const { realm } = server;
const rootState = internals.rootState(realm);
if (!rootState.setup) {
server.decorate('server', 'schwifty', function (config) {
return internals.schwifty(this.realm, config);
});
server.decorate('server', 'models', internals.models((srv) => srv.realm));
server.decorate('request', 'models', internals.models((request) => request.route.realm));
server.decorate('toolkit', 'models', internals.models((h) => h.realm));
server.decorate('server', 'knex', internals.knex((srv) => srv.realm));
server.decorate('request', 'knex', internals.knex((request) => request.route.realm));
server.decorate('toolkit', 'knex', internals.knex((h) => h.realm));
server.ext('onPreStart', internals.initialize);
server.ext('onPostStop', internals.stop);
rootState.setup = true;
}
const { collector } = rootState;
// Decide whether server stop should teardown
if (typeof options.teardownOnStop !== 'undefined') {
Hoek.assert(collector.teardownOnStop === null, 'Schwifty\'s teardownOnStop option can only be specified once.');
collector.teardownOnStop = options.teardownOnStop;
}
// Decide whether server start should perform migrations
if (typeof options.migrateOnStart !== 'undefined') {
Hoek.assert(collector.migrateOnStart === null, 'Schwifty\'s migrateOnStart option can only be specified once.');
collector.migrateOnStart = options.migrateOnStart;
}
const config = internals.registrationConfig(options);
// This should always run, even for plugins without options in order to remember the namespace
internals.schwifty(realm.parent, config);
}
};
internals.registrationConfig = (options) => {
const config = { ...options };
delete config.teardownOnStop;
delete config.migrateOnStart;
return config;
};
internals.initialize = async (server) => {
const { collector } = internals.rootState(server.realm);
// Spread realmByModel into a concrete array, so we can safely mutate it while iterating over it
([...collector.realmByModel]).forEach(([Model, realm]) => {
const knex = internals.knex(() => realm)();
const bindKnex = Helpers.getBindKnex(Model);
const BoundModel = (knex && bindKnex && !Model.knex()) ? Model.bindKnex(knex) : Model;
const name = Helpers.getName(Model);
const sandbox = Helpers.getSandbox(Model);
collector.realmByModel.delete(Model);
collector.realmByModel.set(BoundModel, realm);
if (sandbox) {
return internals.addModelToRealm(realm, BoundModel, name, { override: true });
}
Toys.forEachAncestorRealm(realm, (r) => {
internals.addModelToRealm(r, BoundModel, name, { override: true });
});
});
const knexes = Array.from(collector.knexes);
const ping = async (knex) => {
try {
await knex.queryBuilder().select(knex.raw('1'));
}
catch (err) {
const modelInfo = [...collector.realmByModel]
.filter(([Model]) => Model.knex() === knex)
.map(([Model, realm]) => ({
name: Helpers.getName(Model),
sandbox: Helpers.getSandbox(Model),
namespace: realm.plugin
}));
// Augment original error message
const displayInfo = ({ name, sandbox, namespace }) => `"${name}"${sandbox ? ` (${namespace})` : ''}`;
let message = 'Could not connect to database using schwifty knex instance';
message += modelInfo.length ? ` for models: ${modelInfo.map(displayInfo).join(', ')}.` : '.';
err.message = (message + (err.message ? ': ' + err.message : ''));
throw err;
}
};
// Ping each knex connection
await Promise.all(knexes.map(ping));
if (!collector.migrateOnStart) {
return;
}
const migrations = internals.planMigrations(collector.realmsWithMigrationsDir);
const rollback = (collector.migrateOnStart === 'rollback');
await Migrator.migrate(migrations, rollback);
};
internals.planMigrations = (realms) => {
// Compile migration info per knex instance
const migrations = new Map();
realms.forEach((realm) => {
const knex = internals.knex(() => realm)();
const state = internals.state(realm);
Hoek.assert(state.migrationsDir, 'Attempting to plan migrations without a migrations directory. Please file an issue with schwifty.');
Hoek.assert(knex, 'Cannot specify a migrations directory without an available knex instance.');
if (!migrations.has(knex)) {
migrations.set(knex, Object.create(null));
}
const migrationsDirs = migrations.get(knex);
migrationsDirs[state.migrationsDir] = true;
});
return migrations;
};
internals.schwifty = (realm, config) => {
config = Joi.attempt(config, Schema.schwifty);
// Array of models, coerce to config
if (Array.isArray(config)) {
config = { models: config };
}
// Apply empty defaults
config.models = config.models || [];
const state = internals.state(realm);
const rootState = internals.rootState(realm);
internals.setNamespaceFromRealm(rootState, realm);
config.models.forEach((Model) => {
const name = Helpers.getName(Model);
const sandbox = Helpers.getSandbox(Model);
Hoek.assert(name, 'Every model class must have a name.');
Hoek.assert(sandbox || !rootState.models[name], `Model "${name}" has already been registered.`);
rootState.collector.realmByModel.set(Model, realm);
if (sandbox) {
return internals.addModelToRealm(realm, Model, name);
}
Toys.forEachAncestorRealm(realm, (r) => {
internals.addModelToRealm(r, Model, name);
});
});
// Set plugin's migrations dir if available and allowed
if (typeof config.migrationsDir !== 'undefined') {
Hoek.assert(state.migrationsDir === null, 'Schwifty\'s migrationsDir plugin option can only be specified once per plugin.');
const relativeTo = realm.settings.files.relativeTo || '';
state.migrationsDir = Path.resolve(relativeTo, config.migrationsDir);
rootState.collector.realmsWithMigrationsDir.add(realm);
}
// Record this plugin's knex instance if appropriate
if (config.knex) {
Hoek.assert(!state.knex, 'A knex instance/config may be specified only once per server or plugin.');
state.knex = (typeof config.knex === 'function') ? config.knex : Knex(config.knex);
rootState.collector.knexes.add(state.knex);
}
};
internals.models = (getRealm) => {
return function (namespace) {
const realm = internals.getRealmFromNamespace(getRealm(this), namespace);
return internals.state(realm).models;
};
};
internals.knex = (getRealm) => {
return function (namespace) {
const realm = internals.getRealmFromNamespace(getRealm(this), namespace);
let knex;
let iterateRealm = realm;
while (!knex && iterateRealm) {
const state = internals.state(iterateRealm);
knex = state.knex;
// Skip any knexes outside the given namespace that are sandboxed
if (knex && Helpers.getSandbox(knex) && iterateRealm !== realm) {
knex = null;
}
iterateRealm = iterateRealm.parent;
}
return knex || null;
};
};
internals.getRealmFromNamespace = (realm, namespace) => {
if (!namespace) {
return realm;
}
if (typeof namespace === 'string') {
const namespaceSet = internals.rootState(realm).namespaces[namespace];
Hoek.assert(namespaceSet, `The plugin namespace ${namespace} does not exist.`);
Hoek.assert(namespaceSet.size === 1, `The plugin namespace ${namespace} is not unique: is that plugin registered multiple times?`);
const [namespaceRealm] = [...namespaceSet];
return namespaceRealm;
}
return Toys.rootRealm(realm);
};
internals.setNamespaceFromRealm = (rootState, realm) => {
rootState.namespaces[realm.plugin] = rootState.namespaces[realm.plugin] || new Set();
rootState.namespaces[realm.plugin].add(realm);
return rootState;
};
internals.stop = async (server) => {
const { collector } = internals.rootState(server.realm);
// Do not teardown if specifically asked not to
if (collector.teardownOnStop === false) {
return;
}
const knexes = Array.from(collector.knexes);
await Promise.all(
knexes.map((knex) => knex.destroy())
);
};
internals.addModelToRealm = (realm, Model, name, { override = false } = {}) => {
const state = internals.state(realm);
Hoek.assert(override || !state.models[name], `A model named "${name}" has already been registered in plugin namespace "${realm.plugin}".`);
state.models[name] = Model;
};
internals.state = (realm) => {
const state = Toys.state(realm, 'schwifty');
if (Object.keys(state).length === 0) {
Object.assign(state, {
models: {},
migrationsDir: null, // If present, will be resolved to an absolute path
knex: null
});
}
return state;
};
internals.rootState = (realm) => {
const rootRealm = Toys.rootRealm(realm);
const state = internals.state(rootRealm);
if (!state.hasOwnProperty('setup')) {
Object.assign(state, {
setup: false,
namespaces: {},
collector: {
teardownOnStop: null, // Not set, effectively defaults true
migrateOnStart: null, // Not set, effectively defaults false
realmByModel: new Map(),
realmsWithMigrationsDir: new Set(),
knexes: new Set()
}
});
}
return state;
};