git-goose
Version:
a mongoose plugin that enables git like change tracking
725 lines (724 loc) • 26.3 kB
JavaScript
"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;