UNPKG

git-goose

Version:

a mongoose plugin that enables git like change tracking

725 lines (724 loc) 26.3 kB
"use strict"; var __defProp = Object.defineProperty; var __defProps = Object.defineProperties; var __getOwnPropDescs = Object.getOwnPropertyDescriptors; var __getOwnPropSymbols = Object.getOwnPropertySymbols; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __propIsEnum = Object.prototype.propertyIsEnumerable; var __reflectGet = Reflect.get; var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name); var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); if (__getOwnPropSymbols) for (var prop of __getOwnPropSymbols(b)) { if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); } return a; }; var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); var __objRest = (source, exclude) => { var target = {}; for (var prop in source) if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0) target[prop] = source[prop]; if (source != null && __getOwnPropSymbols) for (var prop of __getOwnPropSymbols(source)) { if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop)) target[prop] = source[prop]; } return target; }; var __superGet = (cls, obj, key) => __reflectGet(__getProtoOf(cls), key, obj); var __async = (__this, __arguments, generator) => { return new Promise((resolve, reject) => { var fulfilled = (value) => { try { step(generator.next(value)); } catch (e) { reject(e); } }; var rejected = (value) => { try { step(generator.throw(value)); } catch (e) { reject(e); } }; var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); step((generator = generator.apply(__this, __arguments)).next()); }); }; var __forAwait = (obj, it, method) => (it = obj[__knownSymbol("asyncIterator")]) ? it.call(obj) : (obj = obj[__knownSymbol("iterator")](), it = {}, method = (key, fn) => (fn = obj[key]) && (it[key] = (arg) => new Promise((yes, no, done) => (arg = fn.call(obj, arg), done = arg.done, Promise.resolve(arg.value).then((value) => yes({ value, done }), no)))), method("next"), method("return"), it); Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } }); const miniRfc6902 = require("mini-rfc6902"); const mongoose = require("mongoose"); class GitError extends Error { } const eq = (a, b, opts) => { if (a instanceof mongoose.Types.ObjectId && b instanceof mongoose.Types.ObjectId) { return a.equals(b); } else if (a instanceof mongoose.Types.Decimal128 && b instanceof mongoose.Types.Decimal128) { return !a.bytes.some((v, k) => v !== b.bytes[k]); } opts.skip(); return false; }; const diff = (a, b, ptr, opts) => { if (a instanceof mongoose.Types.ObjectId && b instanceof mongoose.Types.ObjectId) { return eq(a, b, opts) ? [] : [["~", ptr, b]]; } else if (a instanceof mongoose.Types.Decimal128 && b instanceof mongoose.Types.Decimal128) { return eq(a, b, opts) ? [] : [["~", ptr, b]]; } opts.skip(); return []; }; const clone = (val, opts) => { if (val instanceof mongoose.Types.ObjectId) return new mongoose.Types.ObjectId(val.id); if (val instanceof mongoose.Types.Decimal128) return mongoose.Types.Decimal128.fromString(val.toString()); return opts.skip(); }; function create$1(input, output) { return miniRfc6902.create(input, output, { diff, eq, clone, transform: "maximize" }); } function apply$1(input, patch) { return miniRfc6902.apply(input, patch, { clone, transform: "maximize" }); } function create(input, output) { return miniRfc6902.create(input, output, { eq, diff, clone, transform: "minify" }); } function apply(input, patch) { return miniRfc6902.apply(input, patch, { clone, transform: "minify" }); } const GitGlobalConfig = { collectionSuffix: ".git", snapshotWindow: 100, patcher: "mini-json-patch" }; const RequiredConfig = ["collectionSuffix", "patcher", "snapshotWindow"]; const Patchers = { "json-patch": { create: create$1, apply: apply$1 }, "mini-json-patch": { create, apply } }; function getPatcher(name) { const patchMethod = Patchers[name]; if (!patchMethod) throw new GitError(`PatchMethod not found '${name}'`); if (typeof patchMethod.create !== "function") { throw new GitError(`Invalid PatchMethod '${name}', invalid 'create' function`); } if (typeof patchMethod.apply !== "function") { throw new GitError(`Invalid PatchMethod '${name}', invalid 'apply' function`); } return patchMethod; } const HEAD = "__git_head"; const GIT = "__git"; const PatchSchema = new mongoose.Schema( { type: { type: String, required: true }, ops: { type: mongoose.Schema.Types.Mixed, required: true } }, { methods: { apply(target) { return getPatcher(this.type).apply(target, this.ops); } }, versionKey: false, _id: false } ); const DBCommitSchema = new mongoose.Schema( { date: { type: Date, default: () => /* @__PURE__ */ new Date(), immutable: true }, refId: { type: mongoose.Schema.Types.Mixed, required: true, immutable: true, select: false }, patch: { type: PatchSchema, required: true }, // Snapshot represents the state of the object AFTER this commit has been applied snapshot: { type: mongoose.Schema.Types.Mixed, immutable: true, select: false } }, { versionKey: false, toObject: { virtuals: ["id"] } } ); function GitModel(conf = {}) { var _a, _b, _c; const gitGlobalConfig = GitGlobalConfig; const connection = (_a = conf.connection) != null ? _a : gitGlobalConfig.connection; if (!connection) { throw new GitError("No connection provided, please define one in the options or in the global GitGlobalConfig"); } const collectionName = (_b = conf.collectionName) != null ? _b : gitGlobalConfig.collectionName; if (!collectionName) { throw new GitError("No collectionName provided, please define one in the options or in the global GitGlobalConfig"); } const model = (_c = connection.models[collectionName]) != null ? _c : connection.model(collectionName, DBCommitSchema, collectionName); if (model.schema.obj !== DBCommitSchema.obj) { throw new GitError(`Collection '${collectionName}' is already in use by another model`); } return model; } class GitBase { constructor(referenceModel, conf) { var _a, _b; if (!conf) conf = {}; conf.collectionName = (_a = conf.collectionName) != null ? _a : referenceModel.collection.collectionName + GitBase.staticConf("collectionSuffix", conf); conf.connection = (_b = conf.connection) != null ? _b : referenceModel.db; this._conf = conf; this._referenceModel = referenceModel; this._model = GitModel(conf); } static staticConf(key, conf) { var _a; const val = (_a = conf == null ? void 0 : conf[key]) != null ? _a : GitGlobalConfig[key]; if (!val && RequiredConfig.includes(key)) throw new GitError(`Missing config '${key}'`); return val; } /** * Restore a commit to a {@link Document} of type {@link TargetDocType} * * @param refId - The reference object id * @param commit - The commit identifier */ checkoutFromRefId(refId, commit) { return __async(this, null, function* () { const targetCommit = yield this.rebuildCommitFromRefId(refId, commit); if (!targetCommit) return null; return this._referenceModel.hydrate(targetCommit); }); } /** * Record changes to the document in the commit store * * If no changes are detected it will skip creation * * @param refId - The reference object id * @param prev - The documents previous state * @param curr - The documents new state */ commitFromRefId(refId, prev, curr) { return __async(this, null, function* () { const patch = yield this.createPatch(prev, curr); if (patch) { let snapshot; const snapshotWindow = this.conf("snapshotWindow"); if (snapshotWindow > 0 && (yield this._model.countDocuments({ refId })) % snapshotWindow === 0) { snapshot = curr; } yield this._model.create({ refId, patch, snapshot }); } }); } /** * Fetch the value from the config, or defaulting to the global config value * * @param key - The key to search for */ conf(key) { return GitBase.staticConf(key, this._conf); } /** * Create a patch by invoking the configured patcher * * @param committed - The documents previous state * @param active - The documents new state * @param allowNoop - Allow null patches to be returned */ createPatch(committed, active, allowNoop) { return __async(this, null, function* () { const type = this.conf("patcher"); const ops = yield getPatcher(type).create(committed, active); if (!allowNoop && ops === null) return null; return { type, ops }; }); } /** * Return the difference between two commits * * @param refId - The reference object id * @param commitA - A commit identifier * @param commitB - A commit identifier */ diffFromRefId(refId, commitA, commitB) { return __async(this, null, function* () { const [targetA, targetB] = yield Promise.all([ this.rebuildCommitFromRefId(refId, commitA), this.rebuildCommitFromRefId(refId, commitB) ]); return this.createPatch(targetA, targetB, true); }); } /** * Find commit using an identifier * * @param refId - The reference object id * @param commit - A commit identifier */ findCommitFromRefId(refId, commit) { return __async(this, null, function* () { if (typeof commit === "string") { if (/^(HEAD|@)/.test(commit)) { const matcher = /^(HEAD|@)([\\^~](\d*))?$/.exec(commit); if (!matcher) throw new GitError(`Invalid commit identifier '${commit}'`); commit = matcher[2] ? matcher[3] ? parseInt(matcher[3]) : 1 : 0; } else if (mongoose.isObjectIdOrHexString(commit)) { commit = new mongoose.Types.ObjectId(commit); } else if (!isNaN(Date.parse(commit))) { commit = new Date(commit); } } if (typeof commit === "number") return this.findCommitByOffsetFromRefId(refId, commit); if (commit instanceof Date) return this.findCommitByDateFromRefId(refId, commit); if (commit instanceof mongoose.Types.ObjectId) return this.findCommitByIdFromRefId(refId, commit); throw new GitError(`Invalid commit identifier '${commit}'`); }); } /** * Show commit logs * * Fetch all the commits that match the target id * * @param refId - The reference object id * @param filter - Optional filter for further constraining. * - It will always be constrained by the [target]{@link DBCommit#refId} field * regardless whether you try to override it * @param projection - Optional projection on the {@link Commit} model. * - This is scoped on the {@link Commit} type as opposed to the * actual type {@link DBCommit} to influence intent * - If you try to fetch target or snapshot fields despite the warnings, we will remove them post-processing * @param options - Optional query options for further modifications of the response data * - Unless overridden it will default sort by descending date and limit to the top 10 commits */ logFromRefId(refId, filter, projection, options) { return __async(this, null, function* () { const commits = yield this._model.find(__spreadProps(__spreadValues({}, filter), { refId }), projection, __spreadValues({ sort: { _id: -1 }, limit: 10 }, options)); return commits.map((c) => c.toObject()).map((_a) => { var _b = _a, { refId: _target, snapshot: _snapshot } = _b, o = __objRest(_b, ["refId", "snapshot"]); return o; }); }); } /** * Convert a document into its object form * * @param doc - The document to convert */ objectify(doc) { var _a; return (_a = doc == null ? void 0 : doc.toObject({ virtuals: false, depopulate: true })) != null ? _a : null; } /** * Reconstruct a commit using an identifier * * @param refId - The reference object id * @param commit - A commit identifier * @param nullOnMissingCommit - Return null if the commit doesn't exist */ rebuildCommitFromRefId(refId, commit, nullOnMissingCommit) { return __async(this, null, function* () { var _a; const targetCommit = yield this.findCommitFromRefId(refId, commit); if (!targetCommit) { if (nullOnMissingCommit) return null; throw new GitError(`No commit found for ref '${commit}'`); } const snapshotCommit = yield this._model.findOne( { refId, _id: { $lte: targetCommit._id }, snapshot: { $exists: true } }, { _id: true, snapshot: true }, { sort: { _id: -1 } } ); let build = (_a = snapshotCommit == null ? void 0 : snapshotCommit.snapshot) != null ? _a : null; try { for (var iter = __forAwait(this._model.find( { refId, _id: __spreadValues({ $lte: targetCommit._id }, snapshotCommit ? { $gt: snapshotCommit._id } : {}) }, {}, { sort: { _id: 1 } } ).cursor()), more, temp, error; more = !(temp = yield iter.next()).done; more = false) { const t = temp.value; build = t.patch.apply(build); } } catch (temp) { error = [temp]; } finally { try { more && (temp = iter.return) && (yield temp.call(iter)); } finally { if (error) throw error[0]; } } return build; }); } /** * Find a commit by a date identifier * * Returns the latest commit that matches the date * * If the date is in the future, that is fine, it will return the HEAD commit, * effectively answering the question * * _"if I was to time travel to {@link date} what would the object look like?"_ * * This also means that going backwards in time to before the object * was first initialised, means you will get null * * @param refId - The reference object id * @param date - The date in question */ findCommitByDateFromRefId(refId, date) { return __async(this, null, function* () { return this._model.findOne( { refId, date: { $lte: date } }, {}, { sort: { _id: -1 } } ); }); } /** * Find a commit by its unique identifier * * @param refId - The reference object id * @param commit - The commit id */ findCommitByIdFromRefId(refId, commit) { return __async(this, null, function* () { return this._model.findOne({ refId, _id: commit }); }); } /** * Find a commit in the past using a numerical offset * * If you supply a negative number, it is assumed to be a mistake as you cannot have a commit in the future. * So it will be inverted for you * * @param refId - The reference object id * @param offset - The number of commits to offset by, 0 being the HEAD commit */ findCommitByOffsetFromRefId(refId, offset) { return __async(this, null, function* () { return this._model.findOne( { refId }, {}, { sort: { _id: -1 }, skip: Math.abs(offset) } ); }); } } function getModelSymbolField(doc, key) { doc = Object.getPrototypeOf(doc); const symbols = Object.getOwnPropertySymbols(doc); const symbol = symbols.find((k) => k.toString() === `Symbol(${key})`); if (!symbol) return void 0; return doc[symbol]; } function getModelFromDoc(doc) { if (typeof doc.model === "function") return doc.model(); if (typeof doc.$model === "function") return doc.$model(); const db = getModelSymbolField(doc, "mongoose#Model#db"); const collection = getModelSymbolField(doc, "mongoose#Model#collection"); if (!db || !collection) throw new GitError("Failed to extract model from document"); for (const m of Object.values(db.models)) { if (m.collection === collection) return m; } throw new GitError("Failed to extract model from document"); } class GitWithContext extends GitBase { /** * @param commit - The commit identifier */ checkout(commit) { return __async(this, null, function* () { return this.checkoutFromRefId(this.refId, commit); }); } /** * @param commitA - A commit identifier * @param commitB - A commit identifier */ diff(commitA, commitB) { return __async(this, null, function* () { if (commitA === void 0 && commitB === void 0) return this.status(); if (commitA === void 0) [commitA, commitB] = [commitB, void 0]; if (commitB === void 0) { const targetCommit = yield this.rebuildCommitFromRefId(this.refId, commitA); return this.createPatch(targetCommit, yield this.getActiveDoc(), true); } return this.diffFromRefId(this.refId, commitA, commitB); }); } /** * @param filter - Optional filter for further constraining. * - It will always be constrained by the [target]{@link DBCommit#refId} field * regardless whether you try to override it * @param projection - Optional projection on the {@link Commit} model. * - This is scoped on the {@link Commit} type as opposed to the * actual type {@link DBCommit} to influence intent * - If you try to fetch target or snapshot fields despite the warnings, we will remove them post-processing * @param options - Optional query options for further modifications of the response data * - Unless overridden it will default sort by descending date and limit to the top 10 commits */ log(filter, projection, options) { return __async(this, null, function* () { return this.logFromRefId(this.refId, filter, projection, options); }); } /** * Show the non-committed changes * * Returns the difference between the active document and the current HEAD commit */ status() { return __async(this, null, function* () { return this.createPatch(yield this.getHeadDoc(), yield this.getActiveDoc(), true); }); } commit(curr) { return __async(this, null, function* () { const prev = yield this.getHeadDoc(); if (curr === void 0) curr = yield this.getActiveDoc(); yield this.commitFromRefId(this.refId, prev, curr); }); } /** * Fetch the current state of the active working reference document * * When no direct connection to the currently working document, we fetch from the db and objectify */ getActiveDoc() { return __async(this, null, function* () { return this._referenceModel.findOne({ _id: this.refId }).then(this.objectify); }); } /** * Fetch the current state of the HEAD commit reference document */ getHeadDoc() { return __async(this, null, function* () { return this.rebuildCommitFromRefId(this.refId, "HEAD", true); }); } } class GitFromDocument extends GitWithContext { constructor(doc, conf) { var _a, _b; if (!conf) conf = {}; const model = getModelFromDoc(doc); conf.collectionName = (_a = conf.collectionName) != null ? _a : model.collection.collectionName + GitBase.staticConf("collectionSuffix", conf); conf.connection = (_b = conf.connection) != null ? _b : model.db; super(model, conf); this.doc = doc; } objectifyDoc() { return this.objectify(this.doc); } get refId() { return this.doc._id; } checkout(commit) { return __async(this, null, function* () { const targetCommit = yield __superGet(GitFromDocument.prototype, this, "checkout").call(this, commit); return this._referenceModel.hydrate(targetCommit); }); } /** * Fetch the current state of the active working reference document */ getActiveDoc() { return __async(this, null, function* () { return this.objectifyDoc(); }); } getHeadDoc() { return __async(this, null, function* () { var _a; return (_a = this.doc.$locals[HEAD]) != null ? _a : null; }); } /** * @param commitA - A commit identifier * @param commitB - A commit identifier */ diff(commitA, commitB) { return __async(this, null, function* () { return __superGet(GitFromDocument.prototype, this, "diff").call(this, commitA, commitB); }); } commit() { return __async(this, null, function* () { const curr = yield this.getActiveDoc(); yield __superGet(GitFromDocument.prototype, this, "commit").call(this); this.doc.$locals[HEAD] = curr; }); } } class GitFromRefId extends GitWithContext { constructor(referenceModel, refId, conf) { super(referenceModel, conf); this._refId = refId; } get refId() { return this._refId; } } class GitDetached extends GitBase { checkout() { return __async(this, null, function* () { throw new GitError("Unable to checkout, please specify a referenceId by using 'this.withReference(refId)'"); }); } diff() { return __async(this, null, function* () { throw new GitError("Unable to compute diff, please specify a referenceId by using 'this.withReference(refId)'"); }); } log() { return __async(this, null, function* () { throw new GitError("Unable to fetch logs, please specify a referenceId by using 'this.withReference(refId)'"); }); } /** * Create a new Git context with reference to the provided reference id * * @param refId - The reference object id */ withRefId(refId) { return new GitFromRefId(this._referenceModel, refId, this._conf); } /** * Create a new Git context with reference to the provided reference id * * @param doc - The reference object */ withDocument(doc) { return new GitFromDocument(doc, this._conf); } commit() { return __async(this, null, function* () { throw new GitError( "Unable to commit in headless state, please specify an object by using 'this.withDocument(doc)'" ); }); } } function git(schema, conf = {}) { schema.static("$git", function() { if (!this.$locals) this.$locals = {}; if (!this.$locals[GIT]) this.$locals[GIT] = new GitDetached(this, conf); return this.$locals[GIT]; }); schema.virtual("$git").get(function() { if (!this.$locals[GIT]) this.$locals[GIT] = new GitFromDocument(this, conf); return this.$locals[GIT]; }); schema.post("init", function() { this.$locals[HEAD] = this.$git.objectifyDoc(); }); schema.post("save", function() { return __async(this, null, function* () { yield this.$git.commit(); }); }); schema.pre(["updateOne", "deleteOne", "findOneAndDelete"], function() { return __async(this, null, function* () { const affectedId = yield this.model.findOne(this.getFilter(), { _id: 1 }, this.getOptions()); this.$locals = { affectedId: affectedId == null ? void 0 : affectedId._id }; }); }); schema.post(["updateOne", "deleteOne", "findOneAndDelete"], function(res) { return __async(this, null, function* () { var _a; const git2 = this.model.$git(); if ((_a = this.$locals) == null ? void 0 : _a.affectedId) yield git2.withRefId(this.$locals.affectedId).commit(); if (res == null ? void 0 : res.upsertedId) yield git2.withRefId(res == null ? void 0 : res.upsertedId).commit(); }); }); schema.pre(["updateMany", "deleteMany"], function() { return __async(this, null, function* () { const affectedIds = yield this.model.find(this.getFilter(), { _id: 1 }, this.getOptions()).transform((docs) => docs.map((d) => d._id)); this.$locals = { affectedIds }; }); }); schema.post(["updateMany", "deleteMany"], function(res) { return __async(this, null, function* () { const git2 = this.model.$git(); const session = yield git2._model.startSession(); yield session.withTransaction(() => __async(this, null, function* () { yield Promise.all(this.$locals.affectedIds.map((id) => git2.withRefId(id).commit())); if (res == null ? void 0 : res.upsertedId) yield git2.withRefId(res.upsertedId).commit(); })); yield session.endSession(); }); }); schema.pre(["findOneAndUpdate", "findOneAndReplace"], function() { return __async(this, null, function* () { var _a; const userOptions = this.getOptions(); if (userOptions.upsert && !userOptions.new) throw new GitError( "Please enable 'new: true' in your query options, and ensure your projection also includes the _id, git-goose is unable to track the changed object without an _id returned" ); const userProjection = (_a = this._fields) != null ? _a : {}; if (userProjection["-_id"] === 0 || userProjection["_id"] === 0 || userProjection["_id"] === false) { throw new GitError( "Query projection does not return the '_id' field, git-goose is unable to track the changed object without an _id returned" ); } }); }); schema.post(["findOneAndUpdate", "findOneAndReplace"], function(res) { return __async(this, null, function* () { if (!res) return; if (res._id === void 0) { throw new GitError( "No _id field found in response, please provide _id in the projection and if using 'upsert' option, please include 'new: true'" ); } const git2 = this.model.$git(); yield git2.withRefId(res._id).commit(); }); }); } function committable(model) { if (!("$git" in model.schema.virtuals)) throw new GitError( "model is missing the '$git' virtual, did you run the plugin command before creating the model?" ); return model; } module.exports = git; module.exports.GitGlobalConfig = GitGlobalConfig; module.exports.Patchers = Patchers; module.exports.committable = committable; module.exports.default = git; module.exports.git = git;