Began rewrite of all parenting functions to nested sets

What have I gotten myself into :(
This commit is contained in:
ThaumRystra
2023-09-13 23:18:03 +02:00
parent 00395a3e79
commit d57e49f969
18 changed files with 2427 additions and 743 deletions

View File

@@ -50,3 +50,4 @@ littledata:synced-cron
typescript@4.9.4
seba:minifiers-autoprefixer
mixmax:smart-disconnect
zodern:types

View File

@@ -125,3 +125,4 @@ url@1.3.2
webapp@1.13.5
webapp-hashing@1.1.1
zer0th:meteor-vuetify-loader@0.1.41
zodern:types@1.0.9

View File

@@ -1,41 +0,0 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const RefSchema = new SimpleSchema({
id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
// TODO: Rather than indexing this field, index `ancestors.0.id` to only
// index the root of the ancestor heirarchy to significantly reduce
// index size and improve performance
// All queries on an ancestor document need to target `ancestors.0.id` first
// before targeting a younger ancestor
index: 1
},
collection: {
type: String,
max: STORAGE_LIMITS.collectionName,
},
});
let ChildSchema = new SimpleSchema({
order: {
type: Number,
},
parent: {
type: RefSchema,
optional: true,
},
// Change this from ancestor list to left and right indices
ancestors: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.ancestorCount,
},
'ancestors.$': {
type: RefSchema,
},
});
export default ChildSchema;
export { RefSchema };

View File

@@ -0,0 +1,78 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const RefSchema = new SimpleSchema({
id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
collection: {
type: String,
max: STORAGE_LIMITS.collectionName,
},
});
const ChildSchema = new SimpleSchema({
root: {
type: RefSchema,
},
'root.id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
// Parent id of a document in the same collection
// Undefined parent id implies the root is the parent
parentId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
/**
* The tree structure goes as follows where the numbering follows a counterclockwise depth first
* path around the tree. The canonical structure comes from the root and parentId references,
* while the left and right numbering is used to optimize ancestor queries.
*
* Left can be used as the canonical ordering of properties in an expanded tree folder view.
*
* 1 Books 12
* ┃
* 2 Programming 11
* ┏━━━━━━━━┻━━━━━━━━━┓
* 3 Languages 4 5 Databases 10
* ┏━━━━━━━┻━━━━━━━┓
* 6 MongoDB 7 8 dbm 9
*/
left: {
type: Number,
index: 1,
},
right: {
type: Number,
index: 1,
}
});
export interface Reference {
collection: string,
id: string,
}
export interface TreeDoc {
_id: string,
root: Reference,
parentId?: string,
left: number,
right: number,
}
export const treeDocFields = {
_id: 1,
root: 1,
parentId: 1,
left: 1,
right: 1,
}
export default ChildSchema;
export { RefSchema };

View File

@@ -1,12 +0,0 @@
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
const docNotFoundError = function({id, collection}){
throw new Meteor.Error('document-not-found',
`No document could be found with id: ${id} in ${collection}`
);
};
export default function fetchDocByRef({id, collection}, options){
return getCollectionByName(collection).findOne(id, options) ||
docNotFoundError({id, collection});
}

View File

@@ -1,11 +0,0 @@
const collectionDoesntExistError = function(collectionName){
throw new Meteor.Error('bad-collection-reference',
`Parent references collection ${collectionName}, which does not exist`
);
};
const getCollectionByName = function(name){
return Mongo.Collection.get(name) || collectionDoesntExistError(name);
};
export default getCollectionByName;

View File

@@ -1,27 +0,0 @@
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
export default function getDescendantsInDepthFirstOrder({
collection,
ancestorId,
filter,
options = {fields: {order: 1, ancestors: 1}},
}){
let forest = nodesToTree({collection, ancestorId, filter, options});
let orderMemo = getDocsInDepthFirstOrder(forest);
return orderMemo;
}
export function getDocsInDepthFirstOrder(forest){
let docs = [];
forest.forEach(node => {
addNodeAndTraverse(node, docs)
});
return docs;
}
function addNodeAndTraverse(node, docs){
docs.push(node.node);
node.children.forEach(child => {
addNodeAndTraverse(child, docs)
});
}

View File

@@ -1,122 +0,0 @@
import { union, difference, sortBy, findLast, intersection } from 'lodash';
export function nodeArrayToTree(nodes) {
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
nodes.forEach(node => {
let treeNode = {
node: node,
children: [],
};
nodeIndex[node._id] = treeNode;
nodeList.push(treeNode);
});
// Create a forest of trees
let forest = [];
// Either the node is a child of its nearest found ancestor, or in the forest as a root
nodeList.forEach(treeNode => {
let ancestorInForest = findLast(
treeNode.node.ancestors,
ancestor => !!nodeIndex[ancestor.id]
);
if (ancestorInForest) {
nodeIndex[ancestorInForest.id].children.push(treeNode);
} else {
forest.push(treeNode);
}
});
forest.nodeIndex = nodeIndex;
return forest;
}
// Fetch the documents from a collection, and return the tree of those documents
export default function nodesToTree({
collection, ancestorId, filter, options = {},
includeFilteredDocAncestors = false, includeFilteredDocDescendants = false
}) {
// Setup the filter
let collectionFilter = {
'ancestors.id': ancestorId,
'removed': { $ne: true },
};
if (filter) {
collectionFilter = {
...collectionFilter,
...filter,
}
}
// Set up the options
let collectionSort = {
order: 1
};
if (options && options.sort) {
collectionSort = {
...collectionSort,
...options.sort,
}
}
let collectionOptions = {
sort: collectionSort,
}
if (options) {
collectionOptions = {
...collectionOptions,
...options,
}
}
// Find all the nodes that match the filter
let docs = collection.find(collectionFilter, collectionOptions).map(doc => {
if (!filter) return doc;
// Mark the nodes that were found by the custom filter
doc._matchedDocumentFilter = true;
return doc;
});
let ancestors = [];
let ancestorIds = [];
let docIds = [];
if (filter && (includeFilteredDocAncestors || includeFilteredDocDescendants)) {
docIds = docs.map(doc => doc._id)
}
if (filter && includeFilteredDocAncestors) {
// Add all ancestor ids to an array
docs.forEach(doc => {
ancestorIds = union(ancestorIds, doc.ancestors.map(ref => ref.id));
});
// Get all the docs that are also ancestors and mark them
docs.forEach(doc => {
if (ancestorIds.includes(doc._id)) {
doc._ancestorOfMatchedDocument = true;
}
});
// Remove the ancestor IDs of docs we have already found
ancestorIds = difference(ancestorIds, docIds);
// Get the ancestor docs from the collection, don't worry about `removed` docs,
// if their descendant was not removed, neither are they
ancestors = collection.find({ _id: { $in: ancestorIds } }).map(doc => {
// Mark that the nodes are ancestors of the found nodes
doc._ancestorOfMatchedDocument = true;
return doc;
});
}
let descendants = [];
if (filter && includeFilteredDocDescendants) {
let exludeIds = union(ancestorIds, docIds);
descendants = collection.find({
'_id': { $nin: exludeIds },
'ancestors.id': { $in: docIds },
'removed': { $ne: true },
}).map(doc => {
// Mark that the nodes are descendants of the found nodes
doc._descendantOfMatchedDocument = true;
return doc;
});
}
let nodes = sortBy([
...ancestors,
...docs,
...descendants
], 'order');
// Find all the nodes
return nodeArrayToTree(nodes);
}

View File

@@ -1,156 +0,0 @@
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import getDescendantsInDepthFirstOrder from '/imports/api/parenting/getDescendantsInDepthFirstOrder.js'
// Docs keep track of their depth-first order amongst their entire ancestor tree
export function compareOrder(docA, docB){
// < 0 if A comes before B
// = 0 if A and B are the same order
// > 0 if B comes before A
// They must share a root ancestor to be meaningfully sorted
if (docA.ancestors[0].id !== docB.ancestors[0].id){
return 0;
} else {
return docA.order - docB.order;
}
}
export function getHighestOrder({collection, ancestorId}){
const highestOrderedDoc = collection.findOne({
'ancestors.id': ancestorId,
}, {
fields: {order: 1},
sort: {order: -1},
});
return highestOrderedDoc ? highestOrderedDoc.order : -1;
}
export function setDocToLastOrder({collection, doc}){
doc.order = getHighestOrder({
collection,
ancestorId: doc.ancestors[0].id,
}) + 1;
}
// update the order of a doc, and shift the related docs around to suit the new
// order
function cheapUpdateDocOrder({docRef, order}){
let doc = fetchDocByRef(docRef, {fields: {
order: 1,
parent: 1,
}});
let collection = getCollectionByName(docRef.collection);
const currentOrder = doc.order;
if (currentOrder === order){
return;
} else {
// First move the documents that are in the way
let inBetweenSelector, increment;
if (order > currentOrder){
// Move in-between docs backward
inBetweenSelector = {
$gt: currentOrder,
$lte: order
};
increment = -1;
} else if (order < currentOrder){
// Move in-between docs forward
inBetweenSelector = {
$lt: currentOrder,
$gte: order
};
increment = 1;
}
collection.update({
'ancestors.id': doc.ancestors[0].id,
order: inBetweenSelector,
}, {
$inc: {order: increment},
}, {
multi: true,
selector: {type: 'any'},
});
// Then move the document itself
collection.update(doc._id, {$set: {order}}, {selector: {type: 'any'}});
}
}
export function cheapRemovedDocAtOrder({collection, doc}){
// Decrement the order of all docs after the removed doc
collection.update({
'ancestors.id': doc.ancestors[0].id,
order: {$gt: doc.order},
}, {
$inc: {order: -1},
}, {
multi: true,
selector: {type: 'any'},
});
}
export function cheapInsertedDocAtOrder({collection, ancestorId, order}){
// Increment the order of all docs after the inserted doc
collection.update({
'ancestors.id': ancestorId,
order: {$gte: order},
}, {
$inc: {order: 1},
}, {
multi: true,
selector: {type: 'any'},
});
}
// Update the order a single doc and re-order the entire related doc list
// with the change
export function safeUpdateDocOrder({docRef, order}){
let collection = getCollectionByName(docRef.collection);
// Put the new doc half a step in front of its new order
// to ensure it's in front of whichever doc was there before
collection.update(docRef.id, {
$set: {order}
}, {
selector: {type: 'any'}
});
// reorder all related docs so that order is back to being a continous
// set of whole numbers
let movedDoc = fetchDocByRef(docRef, {fields: {ancestors: 1}});
let ancestorId = movedDoc.ancestors[0].id;
reorderDocs({collection, ancestorId});
}
export function reorderDocs({collection, ancestorId}){
let orderedDocs = getDescendantsInDepthFirstOrder({collection, ancestorId});
let bulkWrite = [];
orderedDocs.forEach((doc, index) => {
if (doc.order !== index){
bulkWrite.push({
updateOne : {
filter: {_id: doc._id},
update: {$set: {order: index}},
},
});
}
});
if (Meteor.isServer && bulkWrite.length){
collection.rawCollection().bulkWrite(
bulkWrite,
{ordered : false},
function(e){
if (e) {
console.error('Bulk write failed: ');
console.error(e);
}
}
);
} else {
bulkWrite.forEach(op => {
collection.update(
op.updateOne.filter,
op.updateOne.update,
{selector: {type: 'any'}}
);
});
}
}

View File

@@ -2,12 +2,9 @@ import SimpleSchema from 'simpl-schema';
import { union } from 'lodash';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { updateParent } from '/imports/api/parenting/parenting.js';
import { reorderDocs, safeUpdateDocOrder } from '/imports/api/parenting/order.js';
import { RefSchema } from '/imports/api/parenting/ChildSchema.js';
import { changeParent, fetchDocByRef, getCollectionByName } from '/imports/api/parenting/parentingFunctions';
import { RefSchema } from '/imports/api/parenting/ChildSchema';
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
const organizeDoc = new ValidatedMethod({
@@ -33,47 +30,45 @@ const organizeDoc = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({ docRef, parentRef, order, skipRecompute, skipClient }) {
async run({ docRef, parentId, order, skipRecompute, skipClient }) {
if (skipClient && this.isSimulation) {
return;
}
let doc = fetchDocByRef(docRef);
let collection = getCollectionByName(docRef.collection);
const collection = getCollectionByName(docRef.collection);
const [doc, parent] = await Promise.all([
collection.findOneAsync(docRef.id),
collection.findOneAsync(parentId),
]);
if (!doc) throw new Meteor.Error('Document not found', 'The property to move could not be found');
if (!parent) throw new Meteor.Error('Document not found', 'The new parent could not be found');
// The user must be able to edit both the doc and its parent to move it
// successfully
assertDocEditPermission(doc, this.userId);
let parent = fetchDocByRef(parentRef);
assertDocEditPermission(parent, this.userId);
await Promise.all([
assertDocEditPermission(doc, this.userId),
// Only check parent if it has a different root
doc.root.id !== parent.root.id && assertDocEditPermission(parent, this.userId),
]);
// Change the doc's parent
updateParent({ docRef, parentRef });
// Change the doc's order to be a half step ahead of its target location
collection.update(doc._id, { $set: { order } }, { selector: { type: 'any' } });
// Reorder both ancestors' documents
let oldAncestorId = doc.ancestors[0].id;
reorderDocs({ collection, ancestorId: oldAncestorId });
let newAncestorId = getRootId(parent);
if (newAncestorId !== oldAncestorId) {
reorderDocs({ collection, ancestorId: newAncestorId });
}
await changeParent(doc, parent, collection, order);
// Figure out which creatures need to be recalculated after this move
let docCreatures = getCreatureAncestors(doc);
let parentCreatures = getCreatureAncestors(parent);
if (!skipRecompute) {
let creaturesToRecompute = union(docCreatures, parentCreatures);
if (!skipRecompute && docRef.collection === 'creatures') {
const creaturesToRecompute = union[doc.root.id, parent.root.id];
// Mark the creatures for recompute
Creatures.update({
await Creatures.updateAsync({
_id: { $in: creaturesToRecompute }
}, {
$set: { dirty: true },
}, {
multi: true
});
}
},
});
// TODO, rewrite
const reorderDoc = new ValidatedMethod({
name: 'organize.reorderDoc',
validate: new SimpleSchema({
@@ -104,27 +99,4 @@ const reorderDoc = new ValidatedMethod({
},
});
function getRootId(doc) {
if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) {
return doc.ancestors[0].id;
} else {
return doc._id;
}
}
function getCreatureAncestors(doc) {
let ids = [];
if (doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster') {
ids.push(doc._id);
}
if (doc.ancestors) {
doc.ancestors.forEach(ancestorRef => {
if (ancestorRef.collection === 'creatures') {
ids.push(ancestorRef.id);
}
});
}
return ids;
}
export { organizeDoc, reorderDoc };

View File

@@ -1,217 +0,0 @@
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { flatten } from 'lodash';
const generalParents = [
'attribute',
'buff',
'classLevel',
'feature',
'folder',
'root',
'item',
'spell',
];
// Which types are allowed as parents for other types
const allowedParenting = {
folder: [...generalParents, 'container'],
rollResult: ['roll', 'rollResult'],
container: ['root', 'folder'],
item: ['root', 'container', 'folder'],
};
const allParentTypes = new Set(flatten(Object.values(allowedParenting)));
export function canBeParent(type){
return true;
//TODO until there is a good reason to disallow certain parenting options,
// this should just let the user do whatever
return type && allParentTypes.has(type);
}
export function getAllowedParents({childType}){
return allowedParenting[childType] || generalParents;
}
export function isParentAllowed({parentType = 'root', childType}){
return true;
//TODO until there is a good reason to disallow certain parenting options,
// this should just let the user do whatever
if (!childType) throw 'childType is required';
let allowedParents = getAllowedParents({childType});
return allowedParents.includes(parentType);
}
export function fetchParent({id, collection}){
return fetchDocByRef({id, collection});
}
export function fetchChildren({ collection, parentId, filter = {}, options = {sort: {order: 1}} }){
filter['parent.id'] = parentId;
let children = [];
children.push(
...collection.find({
'parent.id': parentId
}, options).fetch()
);
return children;
}
export function updateChildren({collection, parentId, filter = {}, modifier, options={}}){
filter['parent.id'] = parentId;
options.multi = true;
collection.update(filter, modifier, options);
}
export function fetchDescendants({ collection, ancestorId, filter = {}, options}){
filter['ancestors.id'] = ancestorId;
let descendants = [];
descendants.push(...collection.find(filter, options).fetch());
return descendants;
}
export function updateDescendants({collection, ancestorId, filter = {}, modifier, options={}}){
filter['ancestors.id'] = ancestorId;
options.multi = true;
options.selector = {type: 'any'};
collection.update(filter, modifier, options);
}
export function forEachDescendant({collection, ancestorId, filter = {}, options}, callback){
filter['ancestors.id'] = ancestorId;
collection.find(filter, options).forEach(callback);
}
// 1 database read
export function getAncestry({parentRef, inheritedFields = {}}){
let parentDoc = fetchDocByRef(parentRef, {fields: inheritedFields});
let parent = { ...parentRef};
for (let field in inheritedFields){
if (inheritedFields[field]){
parent[field] = parentDoc[field];
}
}
// Ancestors is [...parent's ancestors, parent ref]
let ancestors = parentDoc.ancestors || [];
ancestors.push(parent);
return {parentDoc, parent, ancestors};
}
export function setLineageOfDocs({docArray, oldParent, newAncestry}){
const newParent = newAncestry[newAncestry.length - 1];
docArray.forEach(doc => {
if(doc.parent.id === oldParent.id){
doc.parent = newParent;
}
let oldAncestors = doc.ancestors;
let oldParentIndex = oldAncestors.findIndex(a => a.id === oldParent.id);
if (oldParentIndex === -1) return;
doc.ancestors = [...newAncestry, ...oldAncestors.slice(oldParentIndex + 1)];
});
}
/**
* Give documents new random ids and transform their references.
* Transform collections of re-IDed docs according to the collection map
*/
export function renewDocIds({docArray, collectionMap, idMap = {}}){
// idMap is a map of {oldId: newId}
// Get a random generator that's consistent on client and server
let randomSrc = DDP.randomStream('renewDocIds');
// Give new ids and map the changes as {oldId: newId}
docArray.forEach(doc => {
let oldId = doc._id;
let newId = idMap[oldId] || randomSrc.id();
doc._id = newId;
idMap[oldId] = newId;
});
// Remap all references using the new IDs
const remapReference = ref => {
if (idMap[ref.id]){
ref.id = idMap[ref.id];
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
}
}
docArray.forEach(doc => {
remapReference(doc.parent);
doc.ancestors.forEach(remapReference);
});
}
export function updateParent({docRef, parentRef}){
let collection = getCollectionByName(docRef.collection);
let oldDoc = fetchDocByRef(docRef, {fields: {
parent: 1,
ancestors: 1,
type: 1,
}});
let updateOptions = { selector: {type: 'any'} };
// Skip if we aren't changing the parent id
if (oldDoc.parent.id === parentRef.id) return;
// Get the parent and its ancestry
let {parentDoc, parent, ancestors} = getAncestry({parentRef});
// Check that the doc isn't its own ancestor
ancestors.forEach(ancestor => {
if (docRef.id === ancestor.id){
throw new Meteor.Error('invalid parenting',
'A doc can\'t be its own ancestor')
}
});
// If the doc and its parent are in the same collection, apply the allowed
// parent rules based on type
if (docRef.collection === parentRef.collection){
let parentAllowed = isParentAllowed({
parentType: parentDoc.type,
childType: oldDoc.type
});
if (!parentAllowed){
throw new Meteor.Error('invalid parenting',
`Can't make ${oldDoc.type} a child of ${parentDoc.type}`)
}
}
// update the document's parenting
collection.update(docRef.id, {
$set: {parent, ancestors}
}, updateOptions);
// Remove the old ancestors from the descendants
updateDescendants({
collection,
ancestorId: docRef.id,
modifier: {$pullAll: {
ancestors: oldDoc.ancestors,
}},
options: updateOptions,
});
// Add the new ancestors to the descendants
updateDescendants({
collection,
ancestorId: docRef.id,
modifier: {$push: {
ancestors: {
$each: ancestors,
$position: 0,
},
}},
options: updateOptions,
});
}
export function getName(doc){
if (doc.name) return name;
var i = doc.ancestors.length;
while(i--) {
if (doc.ancestors[i].name) return doc.ancestors[i].name;
}
}

View File

@@ -0,0 +1,77 @@
import { docsToForest, calculateNestedSetOperations } from '/imports/api/parenting/parentingFunctions'
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
import { assert } from 'chai';
function doc(_id, left, right, parentId?): TreeDoc {
return { _id, root: { id: 'root', collection: 'col' }, left, right, parentId };
}
function op(_id, left, right) {
return {
updateOne: {
filter: { _id },
update: { $set: { left, right } },
},
};
}
describe('Parenting with nested sets', function () {
/**
* Test the following structure
*
* 1 Books 12
* ┃
* 2 Programming 11
* ┏━━━━━━━━┻━━━━━━━━━┓
* 3 Languages 4 5 Databases 10
* ┏━━━━━━━┻━━━━━━━┓
* 6 MongoDB 7 8 dbm 9
*/
it('Takes a set of documents and builds the forest', function () {
const docArray: Array<TreeDoc> = [
doc('Books', 1, 12),
doc('Programming', 2, 11),
doc('Languages', 3, 4),
doc('Databases', 5, 10),
doc('MongoDB', 6, 7),
doc('dbm', 8, 9),
];
const forest = docsToForest(docArray);
assert.deepEqual(forest, [
{
doc: doc('Books', 1, 12), children: [
{
doc: doc('Programming', 2, 11), children: [
{ doc: doc('Languages', 3, 4), children: [] },
{
doc: doc('Databases', 5, 10), children: [
{ doc: doc('MongoDB', 6, 7), children: [] },
{ doc: doc('dbm', 8, 9), children: [] },
]
}
]
}
]
}
]);
});
it('Can recalculate left and right for docs with set parents', function () {
const docArray = [
doc('Books', 71, 33, undefined),
doc('Programming', 72, 33, 'Books'),
doc('Languages', 73, 33, 'Programming'),
doc('Databases', 74, 33, 'Programming'),
doc('MongoDB', 75, 33, 'Databases'),
doc('dbm', 76, 33, 'Databases'),
];
const ops = calculateNestedSetOperations(docArray);
assert.deepEqual(ops, [
op('Books', 1, 12),
op('Programming', 2, 11),
op('Languages', 3, 4),
op('Databases', 5, 10),
op('MongoDB', 6, 7),
op('dbm', 8, 9),
]);
});
});

View File

@@ -0,0 +1,494 @@
import { chain, reverse } from 'lodash';
import { TreeDoc, treeDocFields, Reference } from '/imports/api/parenting/ChildSchema';
export function getCollectionByName(name: string): Mongo.Collection<TreeDoc> {
const collection = Mongo.Collection.get(name)
if (!collection) {
throw new Meteor.Error('bad-collection-reference',
`Parent references collection ${name}, which does not exist`
);
}
return collection;
}
export async function fetchDocByRef(ref: Reference, options?: Mongo.Options<object>) {
const doc = await getCollectionByName(ref.collection).findOneAsync(ref.id, options);
if (!doc) {
throw new Meteor.Error('document-not-found',
`No document could be found with id: ${ref.id} in ${ref.collection}`
);
}
return doc;
}
interface TreeNode<T> {
doc: T,
children: TreeNode<T>[]
}
/**
*
* @param nodes An array of documents that share a common root. Must already be sorted by `.left` in ascending order
* @returns An array of tree nodes that each contain a document and its children. Children are
* assigned based on the nearest ancestor included in the input, which may or may not be their
* actual direct parents
*/
export function docsToForest(docs: Array<TreeDoc>): TreeNode<TreeDoc>[] {
if (!docs.length) return [];
const forest: TreeNode<TreeDoc>[] = [];
const ancestorStack: TreeNode<TreeDoc>[] = [];
let currentAncestor: TreeNode<TreeDoc> | undefined;
docs.forEach(doc => {
const node: TreeNode<TreeDoc> = {
doc,
children: [],
};
// Remove ancestors from the stack until we find one that contains the current document
// Ancestor contains document if ancestor.left < doc.left and ancestor.right > doc.right
// ancestor.left < doc.left is ensured already, because we sorted by doc.left
while (currentAncestor && currentAncestor.doc.right < doc.left) {
currentAncestor = ancestorStack.pop();
}
// Add this child to its place in the forest, either as a child of the ancestor or as the root
// of a new tree
if (currentAncestor) {
currentAncestor.children.push(node);
} else {
forest.push(node);
}
// Move the last ancestor onto the stack and make this node the new one
if (currentAncestor) ancestorStack.push(currentAncestor);
currentAncestor = node;
});
return forest;
}
/**
* Fetch the documents from a collection, and return the tree of those documents, potentially
* including their ancestors or descendants as required
* @param param options
* @returns An array of tree nodes that each contain a document and its children. Children are
* assigned based on the nearest ancestor included in the input, which may or may not be their
* actual direct parents
*/
type FilteredDoc = {
_descendantOfMatchedDocument?: boolean,
_matchedDocumentFilter?: boolean,
_ancestorOfMatchedDocument?: boolean,
} & TreeDoc;
export default async function filterToForest(
collection: Mongo.Collection<TreeDoc>,
rootId: string,
filter: Mongo.Query<TreeDoc>,
options: Mongo.Options<object> = {},
includeFilteredDocAncestors = false,
includeFilteredDocDescendants = false
): Promise<TreeNode<FilteredDoc>[]> {
// Setup the filter
let collectionFilter = {
'root.id': rootId,
'removed': { $ne: true },
};
if (filter) {
collectionFilter = {
...collectionFilter,
...filter,
}
}
// Set up the options
let collectionSort = {
left: 1
};
if (options && options.sort) {
collectionSort = {
...collectionSort,
...options.sort,
}
}
let collectionOptions: Mongo.Options<object> = {
sort: collectionSort,
}
if (options) {
collectionOptions = {
...collectionOptions,
...options,
}
}
// Find all the docs that match the filter
const docs: TreeDoc[] = await collection.find(collectionFilter, collectionOptions)
.mapAsync(doc => {
if (!filter) return doc;
// Mark the docs that were found by the custom filter
doc._matchedDocumentFilter = true;
return doc;
});
// Get the doc ancestors
let ancestors: object[] = [];
if (filter && includeFilteredDocAncestors) {
ancestors = await collection.find(getFilter.ancestorsOfAll(docs), collectionOptions).mapAsync(doc => {
// Mark that the nodes are ancestors of the found nodes
doc._ancestorOfMatchedDocument = true;
return doc;
});
}
// Get the doc descendants
let descendants: FilteredDoc[] = [];
if (filter && includeFilteredDocDescendants) {
descendants = await collection.find({
'removed': { $ne: true },
...getFilter.descendantsOfAll(docs),
}).mapAsync((doc: FilteredDoc) => {
// Mark that the nodes are descendants of the found nodes
doc._descendantOfMatchedDocument = true;
return doc;
});
}
const nodes = chain([
ancestors,
docs,
descendants
]).uniqBy('_id')
.sortBy('left')
.value();
// Find all the nodes
return docsToForest(nodes);
}
type ForestAndOrphans = { forest: TreeNode<TreeDoc>[], orphanIds: string[] }
/**
* Takes a complete set of documents and builds a forest using just their `.parentIds`
* Uses `.left` for sibling order within a parent only.
* Orphans whose direct parents can't be found are collected separately
* @param docs An array of all document that share a common root already sorted by `.left` in
* ascending order
* @returns forest: An array of tree nodes that each contain a document and its children.
* orphans: an array of the same, but their parents weren't in the input array
*/
export function docsToForestByParentId(docs: TreeDoc[]): ForestAndOrphans {
// Collect all the docs in a dict by id
const nodesById = <{ [_id: string]: TreeNode<TreeDoc> }>{};
docs.forEach(doc => {
nodesById[doc._id] = { doc, children: [] };
});
// Assign the docs to their parent or the forest or orphanage
const forest: TreeNode<TreeDoc>[] = [];
const orphanIds: string[] = [];
docs.forEach(doc => {
const node = nodesById[doc._id];
if (!doc.parentId) {
// Root is parent
forest.push(node);
} else if (nodesById[doc.parentId]) {
// Parent is found
nodesById[doc.parentId].children.push(node);
} else {
// Parent is missing, unset it, and store orphan
node.doc.parentId = undefined;
orphanIds.push(node.doc._id);
forest.push(node);
}
});
return { forest, orphanIds };
}
export const getFilter = {
/**
*
* @param doc A document or array of documents that share a root
* @returns A query filter that finds all the ancestors of the doc(s)
*/
ancestors(doc: TreeDoc): Mongo.Query<TreeDoc> {
return {
'root.id': doc.root.id,
left: { $lt: doc.left },
right: { $gt: doc.right },
};
},
ancestorsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
// The ancestors of no documents is a query that returns nothing
if (docs.length === 0) {
return { _id: '' };
}
// Fallback to the simpler filter for a single document
if (docs.length === 1) {
return getFilter.ancestors(docs[0]);
}
// Build a filter that selects all ancestors
const filter = {
'root.id': docs[0].root.id,
$or: <object[]>[],
};
docs.forEach(doc => {
filter.$or.push({
left: { $lt: doc.left },
right: { $gt: doc.right },
});
});
return filter;
},
descendants(doc: TreeDoc): Mongo.Query<TreeDoc> {
return {
'root.id': doc.root.id,
left: { $gt: doc.left },
right: { $lt: doc.right },
};
},
descendantsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
// The descendants of no documents is a query that returns nothing
if (docs.length === 0) {
return { _id: '' };
}
// Fallback to the simpler filter for a single document
if (docs.length === 1) {
return getFilter.descendants(docs[0]);
}
// Build a filter that selects all descendants
const filter = {
'root.id': docs[0].root.id,
$or: <object[]>[],
};
docs.forEach(doc => {
filter.$or.push({
left: { $gt: doc.left },
right: { $lt: doc.right },
});
});
return filter;
},
children(doc: TreeDoc): Mongo.Query<TreeDoc> {
return {
'root.id': doc.root.id,
parentId: doc._id,
};
},
parent(doc: TreeDoc): Mongo.Query<TreeDoc> {
return {
_id: doc.parentId,
};
},
}
export async function fetchParent({ id, collection }) {
return await fetchDocByRef({ id, collection });
}
/**
* Give documents new random ids and transform their references.
* Transform collections of re-IDed docs according to the collection map
*/
export function renewDocIds({ docArray, collectionMap, idMap = {} }) {
// idMap is a map of {oldId: newId}
// Get a random generator that's consistent on client and server
const randomSrc = DDP.randomStream('renewDocIds');
// Give new ids and map the changes as {oldId: newId}
docArray.forEach(doc => {
const oldId = doc._id;
const newId = idMap[oldId] || randomSrc.id();
doc._id = newId;
idMap[oldId] = newId;
});
// Remap all references using the new IDs
const remapReference = ref => {
if (idMap[ref.id]) {
ref.id = idMap[ref.id];
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
}
}
docArray.forEach(doc => {
remapReference(doc.parent);
remapReference(doc.root);
});
}
/**
* Changes the doc to be a child of the parent, and then rebuilds the nested sets of the roots
* of both doc and parent
* @param doc The doc to move
* @param parent The new parent of the doc
* @param collection
* @returns
*/
export async function changeParent(doc: TreeDoc, parent: TreeDoc, collection: Mongo.Collection<TreeDoc>, order?: number) {
// Skip if we aren't changing the parent id
if (doc.parentId === parent._id) return;
// Store the original roots
const rootChange = doc.root.id !== parent.root.id;
// Check that the doc isn't becoming its own ancestor
if (parent.left > doc.left && parent.right < doc.right) {
throw new Meteor.Error('invalid parenting', 'A doc can\'t be its own ancestor');
}
// update the document's parenting and root if necessary
const update: Mongo.Modifier<TreeDoc> = {
$set: { parentId: parent._id }
};
if (rootChange && update.$set) {
update.$set.root = parent.root;
}
if (order && update.$set) {
update.$set.left = order;
}
await collection.updateAsync(doc._id, update);
// Rebuild the nested sets of everything on the root document(s)
rebuildNestedSets(collection, doc.root.id);
if (rootChange) {
rebuildNestedSets(collection, parent.root.id);
}
}
export function compareOrder(docA, docB) {
// < 0 if A comes before B
// = 0 if A and B are the same order
// > 0 if B comes before A
// They must share a root ancestor to be meaningfully sorted
if (docA.root.id !== docB.root.id) {
return 0;
} else {
return docA.left - docB.left;
}
}
/**
* @deprecated Just set left to Number.MAX_SAFE_INTEGER instead
*/
export function setDocToLastOrder(collection: Mongo.Collection<TreeDoc>, doc: TreeDoc) {
doc.left = Number.MAX_SAFE_INTEGER;
}
export async function rebuildNestedSets(collection: Mongo.Collection<TreeDoc>, rootId: string) {
const docs = collection.find({
'root.id': rootId,
removed: { $ne: true }
}, {
fields: {
root: 1,
parentId: 1,
left: 1,
right: 1,
},
sort: {
//Reverse sorting so that arrays can be used as stacks with the first item on top
left: 1,
},
}).fetch();
const operations = calculateNestedSetOperations(docs);
await writeBulkOperations(collection, operations);
}
export function calculateNestedSetOperations(docs: TreeDoc[]) {
// Walk around the tree numbering left on the way down and right on the way up like so:
/*
* 1 Books 12
* ┃
* 2 Programming 11
* ┏━━━━━━━━┻━━━━━━━━━┓
* 3 Languages 4 5 Databases 10
* ┏━━━━━━━┻━━━━━━━┓
* 6 MongoDB 7 8 dbm 9
*/
// Get the forest, but in reverse order so that the stack always has the first documents on top
const { forest: stack, orphanIds } = docsToForestByParentId(reverse(docs));
const removeMissingParentsOp = orphanIds.length ? {
updateMany: {
filter: { _id: { $in: orphanIds } },
update: { $unset: { parentId: 1 } },
}
} : undefined;
const visitedNodes = new Set();
const visitedChildren = new Set();
const opsById: { [_id: string]: any } = {}
let count = 1;
while (stack.length) {
const top = stack[stack.length - 1];
if (visitedNodes.has(top)) {
// We've arrived at this node again for some reason, this shouldn't happen
console.log('visited already, parent loop maybe?')
stack.pop();
} else if (visitedChildren.has(top)) {
// We've arrived at this node after visiting the children,
// we must be on the way up, mark the right number
visitedNodes.add(top);
stack.pop();
if (top.doc.right !== count) {
opsById[top.doc._id].updateOne.update.$set.right = count;
}
count += 1;
} else {
// We're arriving at this node for the first time
// We must be on the way down, mark the left number and go visit the children
visitedChildren.add(top);
stack.push(...top.children);
if (top.doc.left !== count) {
opsById[top.doc._id] = {
updateOne: {
filter: { _id: top.doc._id },
update: { $set: { left: count } },
}
};
}
count += 1;
}
}
const operations = [...Object.values(opsById)];
if (removeMissingParentsOp) operations.push(removeMissingParentsOp);
return operations;
}
/**
* Write some number of bulk operations to the collection, uses a bulk write on the server
* and iterates through regular updates on the client
* Resolves once all writes have completed
* @param collection The collection to write to
* @param operations An array of bulk operations to write
* @returns Promise<undefined>
*/
async function writeBulkOperations(collection: Mongo.Collection<TreeDoc>, operations) {
if (Meteor.isServer && operations.length) {
return new Promise((resolve, reject) => {
collection.rawCollection().bulkWrite(
operations,
{ ordered: false },
function (e) {
if (e) {
reject(e);
} else {
resolve(undefined);
}
}
);
});
} else {
const promises = operations.map(op => {
if (op.updateOne) {
return collection.updateAsync(
op.updateOne.filter,
op.updateOne.update,
{ selector: { type: 'any' } }
);
} else if (op.updateMany) {
return collection.updateAsync(
op.updateMany.filter,
op.updateMany.update,
{
selector: { type: 'any' },
multi: true,
},
)
}
});
return Promise.all(promises);
}
}

View File

@@ -1,74 +0,0 @@
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { updateDescendants } from '/imports/api/parenting/parenting.js';
export function softRemove({_id, collection}){
let removalDate = new Date();
if (typeof collection === 'string') {
collection = getCollectionByName(collection);
}
// Remove this document
collection.update(
_id, {
$set: {
removed: true,
removedAt: removalDate,
},
$unset: {
removedWith: 1,
}
}, {
selector: {type: 'any'},
},
);
// Remove all the descendants that have not yet been removed, and set them to be
// removed with this document
updateDescendants({
collection,
ancestorId: _id,
filter: {removed: {$ne: true}},
modifier: {$set: {
removed: true,
removedAt: removalDate,
removedWith: _id,
}},
});
}
const restoreError = function(){
throw new Meteor.Error('restore-failed',
'Could not restore this document, maybe it was removed by a parent?'
);
};
export function restore({ _id, collection, extraUpdates}){
if (typeof collection === 'string') {
collection = getCollectionByName(collection);
}
const update = {
$unset: {
removed: 1,
removedAt: 1,
},
...extraUpdates
}
let numUpdated = collection.update({
_id,
removedWith: {$exists: false}
}, update , {
selector: {type: 'any'},
},);
if (numUpdated === 0) restoreError();
updateDescendants({
collection,
ancestorId: _id,
filter: {
removedWith: _id,
},
modifier: { $unset: {
removed: 1,
removedAt: 1,
removedWith: 1,
}},
});
}

View File

@@ -0,0 +1,90 @@
import { getCollectionByName, getFilter } from '/imports/api/parenting/parentingFunctions';
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
export async function softRemove(collection: Mongo.Collection<TreeDoc> | string, doc?: TreeDoc | string) {
const removalDate = new Date();
if (typeof collection === 'string') {
collection = getCollectionByName(collection);
}
if (typeof doc === 'string') {
doc = await collection.findOneAsync(doc);
}
if (!doc) {
throw new Meteor.Error('not found', 'The document to remove was not found');
}
// Remove this document
const removeDocPromise = collection.updateAsync(
doc._id,
{
$set: {
removed: true,
removedAt: removalDate,
},
$unset: {
removedWith: 1,
}
}, {
selector: { type: 'any' },
},
);
// Remove all the descendants that have not yet been removed, and set them to be
// removed with this document
const removeDescendantsPromise = collection.updateAsync({
...getFilter.descendants(doc),
removed: { $ne: true },
}, {
$set: {
removed: true,
removedAt: removalDate,
removedWith: doc._id,
}
}, {
selector: { type: 'any' },
multi: true,
});
return Promise.all([removeDocPromise, removeDescendantsPromise]);
}
const restoreError = function () {
throw new Meteor.Error('restore-failed',
'Could not restore this document, maybe it was removed by a parent?'
);
};
export async function restore(collection: Mongo.Collection<TreeDoc> | string, doc: TreeDoc | string, extraUpdates) {
if (typeof collection === 'string') {
collection = getCollectionByName(collection);
}
if (typeof doc === 'string') {
const foundDoc = await collection.findOneAsync(doc)
if (!foundDoc) {
throw new Meteor.Error('not found', 'The document to remove was not found');
}
doc = foundDoc;
}
const numUpdated: number = await collection.updateAsync({
_id: doc._id,
removedWith: { $exists: false }
}, {
$unset: {
removed: 1,
removedAt: 1,
},
...extraUpdates
}, {
selector: { type: 'any' },
});
if (numUpdated === 0) restoreError();
return collection.updateAsync({
removedWith: doc._id,
}, {
$unset: {
removed: 1,
removedAt: 1,
removedWith: 1,
}
}, {
selector: { type: 'any' },
multi: true,
});
}

1687
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"@babel/runtime": "^7.21.5",
"@chenfengyuan/vue-countdown": "^1.1.5",
"@tozd/vue-observer-utils": "^0.5.0",
"@types/meteor": "^2.9.4",
"aws-sdk": "^2.1373.0",
"bcrypt": "^5.1.0",
"chroma-js": "^2.4.2",

View File

@@ -7,12 +7,17 @@
"strictNullChecks": true,
"strictFunctionTypes": true,
"baseUrl": ".",
"preserveSymlinks": true,
"paths": {
"/*": [
"./*"
],
"meteor/*": [
"node_modules/@types/meteor/*",
".meteor/local/types/packages.d.ts"
],
"meteor/aldeed:collection2": [
"packages\\collection2\\collection2.js"
"packages/collection2/collection2.js"
]
},
"checkJs": false,