mongoose-auto-increment-reworked
Version:
An auto-incrementing field generator for Mongoose 4 & 5
444 lines (443 loc) • 17.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const debug = require("debug");
const mongoose_1 = require("mongoose");
const typescript_lazy_get_decorator_1 = require("typescript-lazy-get-decorator");
/** Default options */
let defaultOptions = {
field: '_id',
incrementBy: 1,
nextCount: '_nextCount',
resetCount: '_resetCount',
startAt: 1,
unique: true
};
/** Debug logger */
const log = debug('mongoose-auto-increment-reworked');
/** Map containing ready states */
const readyMap = new WeakMap();
/** Mongoose plugin for automatically generating auto-incrementing IDs */
class MongooseAutoIncrementID {
/**
* Create a new plugin instance
* @param schema Schema to apply the plugin to. Must be an instance of Mongoose Schema
* @param modelName Name of the model that this schema will use.
* @param options Plugin configuration
* @throws {TypeError} If schema is not given or is not an instance of Mongoose Schema
* @throws {TypeError} If modelName is not given or is not a string
*/
constructor(schema, modelName, options = {}) {
debug('Constructing');
if (!schema || !(schema instanceof mongoose_1.Schema)) {
throw new TypeError('Schema is required and must be an instance of Mongoose Schema');
}
if (!modelName || typeof modelName !== 'string') {
throw new TypeError('Model name must be a string');
}
debug(`Model name set to ${modelName}`);
this.model = modelName;
this.schema = schema;
this._options = options;
}
/** The model instance */
static get idCounter() {
log('Creating schema');
/** Schema definition */
const idCounterSchema = new mongoose_1.Schema({
c: {
default: 0,
required: true,
type: Number
},
f: {
required: true,
type: String
},
m: {
required: true,
type: String
},
}, {
autoIndex: true,
id: false,
skipVersioning: true,
timestamps: false,
versionKey: false,
});
log('Defining index');
idCounterSchema.index({ f: 1, m: 1 }, { unique: true });
log('Creating model instance');
return mongoose_1.model(MongooseAutoIncrementID.modelName, idCounterSchema);
}
/** Error, if any, thrown by applyPlugin() */
get error() {
return this.state.error;
}
/** Whether or not the initialisation has completed */
get isReady() {
return !!this.state.ready;
}
/** Promise returned by applyPlugin() */
get promise() {
return this.state.promise;
}
/** Default arguments for querying the id counter collection */
get findArgs() {
const out = {
f: this.options.field,
m: this.model
};
log('findArgs field defined as %s, model as %s', out.f, out.m);
return Object.freeze(out);
}
/** The initial counter value */
get initialStart() {
const ret = this.options.startAt - this.options.incrementBy;
log('initialStart defined as %s', ret);
return ret;
}
/** User-supplied options merged with defaults */
get options() {
const value = MongooseAutoIncrementID.getDefaults();
Object.assign(value, this._options);
value.unique = !!value.unique;
for (const field of Object.keys(value)) {
log('%s option defined as %s', field, value[field]);
}
return Object.freeze(value);
}
/** The plugin instante's ready state */
get state() {
let states = readyMap.get(this.schema);
if (!states) {
states = {};
readyMap.set(this.schema, states);
}
let state = states[this.model];
// istanbul ignore else
if (!state) {
state = {};
states[this.model] = state;
}
return state;
}
/** The default options used by the plugin */
static getDefaults() {
return Object.assign({}, defaultOptions);
}
/**
* Get the initialisation error for the given schema and model
* @param schema The schema
* @param modelName The mode
* @returns The initialisation error that occurred or undefined if the schema/model combination hasn't been plugged
* into yet or hasn't errored.
*/
static getErrorFor(schema, modelName) {
const state = MongooseAutoIncrementID.getStateFor(schema, modelName);
if (state) {
return state.error;
}
}
/**
* Get the initialisation promise for the given schema and model
* @param schema The schema
* @param modelName The mode
* @returns The initialisation promise or undefined if the schema/model combination hasn't been plugged into yet.
*/
static getPromiseFor(schema, modelName) {
const state = MongooseAutoIncrementID.getStateFor(schema, modelName);
if (state) {
return state.promise;
}
}
/**
* Set the model name this plugin will use. Has no effect if you have already applied the plugin to a schema.
* @param modelName Name of the plugin model
* @throws {TypeError} If model name is not a string. Won't happen if you omit the parameter.
*/
static initialise(modelName) {
log('Performing static initialisation with name %s', modelName);
if (modelName !== undefined) {
if (typeof modelName !== 'string') {
throw new TypeError('Model name must be a string');
}
MongooseAutoIncrementID.modelName = modelName;
}
// Perform no-op initialisation
((m) => m)(MongooseAutoIncrementID.idCounter);
}
/**
* Check if the given schema and model have finished their plugin initialisation
* @param schema The schema
* @param modelName The mode
* @returns true if the initialisation completed successfully, false if it hasn't completed yet, it errored or hasn't
* started yet.
*/
static isReady(schema, modelName) {
const state = MongooseAutoIncrementID.getStateFor(schema, modelName);
if (state) {
return !!state.ready;
}
return false;
}
/**
* Handler for applying the plugin via schema.plugin()
* @param schema The schema we're applying the plugin to
* @param options Plugin options. This should be a {@link SchemaPluginOptions} object containing a modelName key and,
* optionally, any of the other plugin options.
*/
static plugin(schema, options) {
if (!options) {
throw new Error('Options are required');
}
const modelName = options.modelName;
if (!modelName) {
throw new Error('Options must contain a "modelName" key');
}
return new MongooseAutoIncrementID(schema, modelName, options).applyPlugin();
}
/**
* Set default options used by the plugin
* @param newOpts The options
* @throws If any parameter is invalid
*/
static setDefaults(newOpts = {}) {
const out = Object.assign(MongooseAutoIncrementID.getDefaults(), newOpts);
MongooseAutoIncrementID.validateOptions(out);
defaultOptions = out;
}
/**
* Internal static state getter
* @param schema Schema we're getting the state for
* @param modelName Model we're getting the state for
* @returns The ready state or undefined if the state cannot be found
*/
static getStateFor(schema, modelName) {
const states = readyMap.get(schema);
if (states) {
return states[modelName];
}
}
/**
* Validates the options supplied by the user
* @param options The options to validate
* @throws {TypeError} if a validation fails
*/
static validateOptions(options) {
log('Validating options');
if (!options) {
throw new TypeError('Options missing');
}
log('Validating field');
if (typeof options.field !== 'string') {
throw new TypeError('field must be a string');
}
log('Validating incrementBy');
if (typeof options.incrementBy !== 'number') {
throw new TypeError('incrementBy must be a number');
}
log('Validating nextCount');
if (typeof options.nextCount !== 'string' && options.nextCount !== false) {
throw new TypeError('nextCount must be a string or false');
}
log('Validating resetCount');
if (typeof options.resetCount !== 'string' && options.resetCount !== false) {
throw new TypeError('resetCount must be a string or false');
}
log('Validating startAt');
if (typeof options.startAt !== 'number') {
throw new TypeError('startAt must be a number');
}
}
/**
* Apply the plugin to the given schema
* @returns A void promise that resolves when the initialisation has completed. You do not need to wait for the
* promise to resolve - any document insertion queries will be queued until the initialisation completes. You
* should, however, catch any errors if this promise rejects.
*/
applyPlugin() {
log('Running plugin initialisation on %s', this.model);
try {
this.validateOptions();
this.addFieldToSchema();
this.addNextCount();
this.addResetCount();
this.addPreSave();
this.state.promise = this.init()
.catch((e) => {
this.state.error = e;
throw e;
});
return this.state.promise;
}
catch (e) {
this.state.error = e;
this.state.promise = Promise.reject(this.state.error);
return this.state.promise;
}
}
/**
* Validates the options supplied by the user
* @throws {TypeError} if a validation fails
*/
validateOptions() {
MongooseAutoIncrementID.validateOptions(this.options);
}
/** Add the field containing our auto-incremented ID to the schema definition */
addFieldToSchema() {
const fieldDef = {
required: false,
type: Number
};
if (this.options.field !== '_id') {
fieldDef.unique = this.options.unique;
}
log('Field defined as %s', JSON.stringify(fieldDef));
log('Adding field to %s schema', this.model);
this.schema.add({ [this.options.field]: fieldDef });
}
/** Add the next count function if enabled */
addNextCount() {
if (this.options.nextCount) {
log('Adding %s instance and static methods for %s', this.options.nextCount, this.model);
const fn = () => {
const exec = MongooseAutoIncrementID.idCounter.findOne(this.findArgs, { c: 1 }).lean().exec();
return exec
.then((doc) => {
return doc === null ? this.options.startAt : doc.c + this.options.incrementBy;
});
};
this.schema.static(this.options.nextCount, fn);
this.schema.method(this.options.nextCount, fn);
}
else {
log('Skipping nextCount methods for %s', this.model);
}
}
/** Add the pre-save hook to the schema that will generate our IDs */
addPreSave() {
// tslint:disable-next-line:no-this-assignment
const self = this;
log('Adding pre-save hook');
this.schema.pre('save', function (next) {
debug('Doc save hook triggered');
// Only work with new documents
if (this.isNew) {
log('Document is new');
// Plugin might not be ready yet. Define a save function
const save = () => {
if (self.isReady) {
log('Plugin ready. Performing onSave logic.');
// The payload contains a numeric value on the field. Update the ID counter collection if necessary.
if (typeof this[self.options.field] === 'number') {
log('%s is a number: %d', self.options.field, self.options.field);
self.onSaveNumberField(this[self.options.field])
.then(() => next())
.catch(next);
}
else {
log('%s is not a number: %s', self.options.field, JSON.stringify(this[self.options.field]));
// Fiels does not contain an ID or is NaN. Generate the next value and set it on the payload.
self.onSaveAnyField()
.then((newCount) => {
this[self.options.field] = newCount;
next();
})
.catch(next);
}
}
else if (!self.error) {
log('Plugin not ready yet; waiting for %d ms before retrying', 5 /* RETRY_TIMEOUT */);
setTimeout(save, 5 /* RETRY_TIMEOUT */);
}
else {
log('Failing; plugin initialisation rejected.');
setImmediate(next, new Error('The initialisation promise rejected'));
}
};
// Immediately call the save function
setImmediate(save);
}
else {
log('Document is not new; skipping.');
setImmediate(next);
}
});
}
/** Add the reset count function if enabled */
addResetCount() {
if (this.options.resetCount) {
log('Adding %s instance and static methods for %s', this.options.resetCount, this.model);
const fn = () => {
return MongooseAutoIncrementID.idCounter
.findOneAndUpdate(this.findArgs, { c: this.initialStart }, { new: true, upsert: true, fields: { _id: 1 } })
.lean()
.then(() => this.options.startAt);
};
this.schema.static(this.options.resetCount, fn);
this.schema.method(this.options.resetCount, fn);
}
else {
log('Skipping resetCount methods for %s', this.model);
}
}
/** Initialise the counter for this schema */
init() {
log('Performing plugin initialisation');
return MongooseAutoIncrementID.idCounter.findOne(this.findArgs, { _id: 1 })
.lean()
.then((doc) => {
if (!doc) {
const payload = {
c: this.initialStart,
f: this.options.field,
m: this.model
};
log('No counter document found for %s; creating with %s', this.model, JSON.stringify(payload));
return MongooseAutoIncrementID.idCounter.create(payload)
.then(() => {
log('Counter document for %s created', this.model);
this.state.ready = true;
});
}
else {
// All good
this.state.ready = true;
}
});
}
/** Generate the next ID, update counter document, return ID */
onSaveAnyField() {
return MongooseAutoIncrementID.idCounter
.findOneAndUpdate(this.findArgs, { $inc: { c: this.options.incrementBy } }, { new: true, setDefaultsOnInsert: true, upsert: true, fields: { c: 1 } })
.lean()
.exec()
.then((doc) => doc.c);
}
/** Synchronise the ID provided in the document with the database value */
onSaveNumberField(value) {
return MongooseAutoIncrementID.idCounter
.findOneAndUpdate({ f: this.options.field, m: this.model, c: { $lt: value } }, { c: value }, { new: true, fields: { _id: 1 } })
.lean()
.exec();
}
}
/** Name of the model used by the plugin */
MongooseAutoIncrementID.modelName = 'IdCounter';
tslib_1.__decorate([
typescript_lazy_get_decorator_1.LazyGetter()
], MongooseAutoIncrementID.prototype, "findArgs", null);
tslib_1.__decorate([
typescript_lazy_get_decorator_1.LazyGetter()
], MongooseAutoIncrementID.prototype, "initialStart", null);
tslib_1.__decorate([
typescript_lazy_get_decorator_1.LazyGetter()
], MongooseAutoIncrementID.prototype, "options", null);
tslib_1.__decorate([
typescript_lazy_get_decorator_1.LazyGetter()
], MongooseAutoIncrementID.prototype, "state", null);
tslib_1.__decorate([
typescript_lazy_get_decorator_1.LazyGetter()
], MongooseAutoIncrementID, "idCounter", null);
exports.MongooseAutoIncrementID = MongooseAutoIncrementID;