Files
Foundry-VTT-Docker/resources/app/dist/database/backend/server-document.mjs
2025-01-04 00:34:03 +01:00

1 line
16 KiB
JavaScript

import fs from"node:fs";import path from"node:path";import NeDB from"nedb";import Files from"../../files/files.mjs";import LevelDatabase from"./level-database.mjs";import{DOCUMENT_OWNERSHIP_LEVELS}from"../../../common/constants.mjs";import{deepClone,isEmpty,isNewerVersion,getProperty,setProperty,getType}from"../../../common/utils/helpers.mjs";import*as fields from"../../../common/data/fields.mjs";import{DocumentStatsField,EmbeddedCollectionField,EmbeddedDocumentField}from"../../../common/data/fields.mjs";import Document from"../../../common/abstract/document.mjs";import{tagModelStats}from"../../core/utils.mjs";const DATABASE_STATES=Object.freeze({DISCONNECTED:0,CONNECTING:1,CONNECTION_FAILED:-1,CONNECTED:2,MIGRATING:3,MIGRATION_FAILED:-3,PARTIALLY_MIGRATED:4,FULLY_MIGRATED:5});export default function ServerDocumentMixin(e){class t extends e{constructor(e={},t={}){super(e,t);const{db:i,sublevelName:s,sublevel:a}=this._configureDB();Object.defineProperties(this,{db:{value:i,writable:!1},sublevelName:{value:s,writable:!1},sublevel:{value:a,writable:!1}})}static name="ServerDocumentMixin";static isCached=!1;static get sanitizedFields(){return t.#e}static#e;closestDeltaAncestor(){return this.parent?this.parent.constructor.isDelta?this.parent:this.parent.closestDeltaAncestor():null}_initialize(e={}){super._initialize(e),Object.defineProperty(this,"dbKey",{value:this._getDBKey(),writable:!1,configurable:!0})}static fromImport(e,t){if(!CONST.PRIMARY_DOCUMENT_TYPES.includes(this.documentName))throw new Error("Only primary Documents may be imported");return this._migrateRecord(e),this.fromSource(e,{strict:!0,...t})}static _migrationRegistry=[{fn:migratePermissionToOwnership,version:12}];static async migrateDocuments(){if(this._dbWait)return await this._dbWait,this.migrateDocuments();this._dbState=DATABASE_STATES.MIGRATING;try{this._dbWait=this._migrateDocuments(),this._dbWait.then((e=>{e?this._dbState=DATABASE_STATES.FULLY_MIGRATED:(this._dbState=DATABASE_STATES.PARTIALLY_MIGRATED,global.logger.warn(`Database ${this.collectionName} was not fully migrated due to errors during migration.`))})),await this._dbWait}catch(e){this._dbState=DATABASE_STATES.MIGRATION_FAILED,global.logger.error(`Migration failed for database "${this.collectionName}":\n${e.stack}`)}finally{this._dbWait=void 0}}static async _migrateDocuments(){if(!CONST.PRIMARY_DOCUMENT_TYPES.includes(this.documentName))throw new Error("Only primary Documents may be migrated");let e=!0;const t=await this.sublevel.find({},{map:e=>this.expandEmbedded(e)}),i=[];for(const s of t)try{if(this._migrateRecord(s)){this.sanitizeUserInput(s,{documentId:s._id,skipSystem:!0});const e=this.fromSource(s);i.push(e),global.logger.info(`Migrated ${this.documentName} record [${e._id}] of database "${this.collectionName}".`)}}catch(t){e=!1,global.logger.error(`An error occurred during the migration of ${this.documentName} record [${s._id}] of database "${this.collectionName}":\n${t.stack}`)}if(!i.length)return e;const s=this.db.batch();for(const e of i)global.logger.info(`Persisting migrated ${this.documentName} record [${e._id}] of database "${this.collectionName}".`),e.batchWrite(s,{generateIds:!0});return await s.write(),global.logger.info(`Completed migration of database "${this.collectionName}."`),e}static _migrateRecord(e,{ancestorStats:i,ancestorMigrated:s=!1}={}){if("Object"!==getType(e)||e._tombstone)return!1;let a=t.#t(e);const o=e._stats??i,n=o.coreVersion;if(n&&isNewerVersion(n,global.release.version))throw new Error("Documents from a core version newer than the running version cannot be migrated");const r=this._migrationRegistry;for(const{fn:t,version:i}of r)n&&!isNewerVersion(i,n)||t(e)&&(a=!0);(!n||isNewerVersion(this.metadata.schemaVersion,n))&&(a=!0);const d={ancestorStats:o,ancestorMigrated:a||s},c=this._migrateEmbeddedRecords(e,d);return a||=c,(a||s)&&e._stats&&e._stats.coreVersion!==global.release.version&&(e._stats.coreVersion=global.release.version,a=!0),a}static _migrateEmbeddedRecords(e,t){let i=!1;for(const[s,a]of Object.entries(this.hierarchy))if(a instanceof EmbeddedDocumentField){const o=a.model._migrateRecord(e[s],t);i||=o}else{if(!(a instanceof EmbeddedCollectionField))throw new Error("Unknown embedded field");{const o=e[s];if(Array.isArray(o))for(const e of o){const s=a.model._migrateRecord(e,t);i||=s}}}return i}static#t(e){return!(!this.schema.has("_stats")||"_stats"in e)&&(e._stats={},DocumentStatsField.fields.forEach((t=>e._stats[t]=null)),!0)}static get db(){return this._db}static _db;static _dbState=0;static _dbWait;static sublevel;db;sublevelName;sublevel;static get connected(){return this._dbState>=DATABASE_STATES.CONNECTED&&"open"===this.db?.status}static get ready(){return this._dbState>=DATABASE_STATES.PARTIALLY_MIGRATED&&"open"===this.db?.status}static get filename(){const e=global.game.world;if(!e)throw new Error(`You cannot access the ${this.collectionName} database before the game is ready!`);return Files.standardizePath(path.join(e.path,"data",this.collectionName))}getSublevel(e){const t=LevelDatabase.formatKey(this.sublevelName,e);return this.db.sublevels[t]}static _getSublevelNames(){const e=[],t=(i,s)=>{e.push(i);for(const[e,a]of Object.entries(s.hierarchy))t(LevelDatabase.formatKey(i,e),a.model)};return t(this.metadata.collection,this),e}static async connect({strict:e=!0}={}){if(this.connected)return this.db;if(this._dbState===DATABASE_STATES.DISCONNECTED){if(this._dbWait instanceof Promise)return await this._dbWait,this.connect({strict:e});this._dbState=DATABASE_STATES.CONNECTING;try{this._dbWait=this._connect(),await this._dbWait}catch(t){if(this.packData){const{packageName:e,packageType:i}=this.packData;packages?.warnings?.add(e,{type:i,message:t.message})}if("open"===this.db?.status&&await this.db.close(),this._dbState=DATABASE_STATES.CONNECTION_FAILED,this._db=void 0,e)throw t;logger.error(t.message)}finally{this._dbWait=void 0}}}static async _connect(){const e=this._getSublevelNames(),i=fs.existsSync(path.join(this.filename,"CURRENT"));this._db=await LevelDatabase.connect(this.collectionName,this.filename,{sublevels:e}),this.sublevel=this._db.sublevels[this.metadata.collection];const s=this.filename+".db";return fs.existsSync(s)&&(i||await this._migrateNEDBToLevelDB(s),global.config.options.deleteNEDB&&(global.logger.info(`Deleting migrated NEDB file "${s}"`),fs.rmSync(s))),await this.deleteOrphanDocuments(),t.identifySanitizedFields(),void 0!==global.gc&&global.gc(),this._dbState=DATABASE_STATES.CONNECTED,this._db}static async disconnect(){if(this._dbState!==DATABASE_STATES.DISCONNECTED){if(this._dbWait instanceof Promise)return await this._dbWait,this.disconnect();try{this.connected&&(this._dbWait=this.db.close(),await this._dbWait)}finally{this._db=void 0,this._dbState=DATABASE_STATES.DISCONNECTED,this._dbWait=void 0}}}static async assertReady(){if(this._dbWait instanceof Promise)return await this._dbWait,this.assertReady();if(this.ready)return;const e=this._dbState;if(e===DATABASE_STATES.CONNECTION_FAILED)throw new Error(`Database ${this.collectionName} failed connection and cannot be accessed.`);if(e===DATABASE_STATES.MIGRATION_FAILED)throw new Error(`Database ${this.collectionName} failed migration and cannot be accessed.`);throw new Error(`Database ${this.collectionName} is not ready to be accessed.`)}static async _migrateNEDBToLevelDB(e){global.logger.info(`Performing one-time migration of table "${this.collectionName}" from NEDB to LevelDB`);const t=await new Promise(((t,i)=>{const s=new NeDB(e);s.loadDatabase((e=>e?i(e):t(s)))})),i=await new Promise(((e,i)=>{t.find({},((t,s)=>t?i(t):e(s)))})),s=this._db.batch();for(const e of i)this.batchWrite(e,s,{writeEmbedded:!0,generateIds:!0});await s.write(),global.logger.info(`Completed migration of table "${this.collectionName} to LevelDB "${this.filename}"`)}static async deleteOrphanDocuments(){if(isEmpty(this.hierarchy))return;const e=new Set(await this.sublevel.keys().all()),t=this._db.batch();let i=0;const s=async(e,a,o)=>{for(const[n,r]of Object.entries(e.hierarchy)){const e=r.model,d=LevelDatabase.formatKey(a,n),c=this._db.sublevels[d],l=await c.keys().all();for(const s of l){const a=s.substring(0,s.lastIndexOf("."));if(!o.has(a)){i++;const o=await c.get(s);await e.expandEmbedded(o,{idPrefix:a,sublevelName:d,ldb:this.db}),e.batchDelete(o,t,{idPrefix:a,sublevelName:d})}}await s(e,d,new Set(l))}};await s(this,this.metadata.collection,e),await t.write(),i&&global.logger.warn(`Deleted ${i} orphaned embedded documents from the ${this.collectionName} database`)}_getDBKey(){if(!this.id)return null;const e=[this.id];let t=this.parent;for(;t;)e.unshift(t.id),t=t.parent;return LevelDatabase.formatKey(...e)}_configureDB(){let e,t=this;const i=[];do{i.unshift(t.isEmbedded?t.parentCollection:t.constructor.metadata.collection),e=t.constructor._db,t=t.parent}while(t);const s=LevelDatabase.formatKey(...i);return{db:e,sublevelName:s,sublevel:e.sublevels[s]}}static async dump({sort:e}={}){return await this.assertReady(),this.sublevel.find(void 0,{sort:e,map:async e=>(await this.expandEmbedded(e),e)})}static async get(e,t={},i){await this.assertReady();const s=await this.sublevel.get(e);if(void 0!==s)return await this.expandEmbedded(s),this.fromSource(s,t);if(!0===t.strict)throw new Error(`The ${this.name} ${e} does not exist in ${this.collectionName}`)}static async getMany(e,t={}){await this.assertReady();const i=await this.sublevel.getMany(e);return Promise.all(i.map((async e=>{if(e)return await this.expandEmbedded(e),this.fromSource(e,t)})))}static async expandEmbedded(e,{idPrefix:t,sublevelName:i,ldb:s,partial:a=!1}={}){s=s??this.db,i=i??this.metadata.collection;const o=t?LevelDatabase.formatKey(t,e._id):e._id;for(const[t,n]of Object.entries(this.hierarchy))a&&!(t in e)||(e[t]=await n.expandEmbedded(e,o,i,s));return e}static async find(e,t={}){return await this.assertReady(),this.sublevel.find(e,{map:async e=>(await this.expandEmbedded(e),this.fromSource(e,t))})}static async findOne(e,t={}){await this.assertReady();const i=await this.sublevel.findOne(e);if(i)return await this.expandEmbedded(i),this.fromSource(i,t)}static batchWrite(e,t,{writeEmbedded:i=!0,generateIds:s=!1,idPrefix:a,dbKey:o,sublevelName:n}={}){n=n??this.metadata.collection;const r=o??(a?LevelDatabase.formatKey(a,e._id):e._id);e=Object.assign({},e);for(const[a,o]of Object.entries(this.hierarchy))e[a]=o._dbWrite(e,t,r,n,{writeEmbedded:i,generateIds:s});const d=t.db.sublevels[n].prefixKey(r);t.put(d,e)}static batchDelete(e,t,{idPrefix:i,dbKey:s,sublevelName:a}={}){a=a??this.metadata.collection;const o=s??(i?LevelDatabase.formatKey(i,e._id):e._id);for(const i of Object.values(this.hierarchy))i._dbDelete(e,t,o,a);const n=t.db.sublevels[a];t.del(n.prefixKey(o))}async loadRelatedDocuments(){}async save(e={}){if(this.invalid)throw new Error("You may not save a Document which has an invalid DataModel.");if(!this.id)throw new Error("You may not save a Document which does not have an id.");const t=this.db.batch();return this.batchWrite(t,e),await t.write(),this}batchWrite(e,{writeEmbedded:t=!0,generateIds:i=!1,writeAncestorDeltas:s=!1,childModified:a=!1}={}){if(a||s)if(this.closestDeltaAncestor()){const e=this.parent.getEmbeddedCollection(this.parentCollection);e.manages?.(this.id)?s=!1:(s=!0,e.set(this.id,this))}else s=!1;this.parent&&s&&this.parent.batchWrite(e,{writeEmbedded:t,generateIds:i,writeAncestorDeltas:s});const{dbKey:o,sublevelName:n}=this;t||=s,this.constructor.batchWrite(this._source,e,{dbKey:o,sublevelName:n,generateIds:i,writeEmbedded:t})}batchDelete(e){const{dbKey:t,sublevelName:i}=this;this.constructor.batchDelete(this._source,e,{dbKey:t,sublevelName:i})}static sanitizeUserInput(e,i={}){return i.fieldPath??=[],i.assetPath=this.extractedAssetPath,t.#i(this.sanitizedFields,e,i),e}static#i(e,i,s={}){if(!i)return;const{document:a,fieldPath:o}=s;e._types&&(e=t.#s(e,i,a));for(const[a,n]of Object.entries(i)){if("system"===a&&s.skipSystem)continue;let r=e[a];if(!r)continue;const d=o.concat([a]);if(r instanceof fields.DataField){if(r.gmOnly&&!s.user.isGM)throw new Error(`The "${d.join(".")}" field may only be modified by a GM or Assistant GM user.`);r.sanitize instanceof Function&&(i[a]=r.sanitize(n,{...s,fieldPath:d}))}else if(n instanceof Array){const e=r instanceof Array?r[0]:r;for(const i of n)t.#i(e,i,{...s,fieldPath:d})}else n instanceof Object&&t.#i(r,n,{...s,fieldPath:d})}}static identifySanitizedFields(){if(t.#e)return t.#e;const e=this.schema.apply((function(){if(this.sanitize instanceof Function)return this}),{},{filter:!0,initializeArrays:!0});e._types={};for(const[t,i]of Object.entries(this.hierarchy))i.model.identifySanitizedFields(),i.sanitize instanceof Function&&(e[t]=i);const i=this._getTemplateFields(game.system.documentTypes);for(const[s,a]of Object.entries(i)){const i=e._types[s]={};t.#a(a,i)}for(const i of packages.Module.getPackages()){const s=this._getTemplateFields(i.documentTypes);for(const[a,o]of Object.entries(s)){const s=e._types[`${i.id}.${a}`]={};t.#a(o,s)}}return t.#e=e}static clearSanitizedFields(){t.#e=void 0}static#a(e,t){for(const i of e.htmlFields||[]){const e=`system.${i}`;setProperty(t,e,new fields.HTMLField({name:e}))}for(const[i,s]of Object.entries(e.filePathFields||{})){const e=`system.${i}`;setProperty(t,e,new fields.FilePathField({name:e,categories:s}))}for(const i of e.gmOnlyFields||[]){const e=`system.${i}`,s=getProperty(t,e);s?s.gmOnly=!0:setProperty(t,e,new fields.AnyField({name:e,gmOnly:!0}))}}static#s(e,t,i){const s=t.type??i?.type;if(!s)return e;const a=(e=deepClone(e))._types[s]||{};return a&&(e.system=Object.assign(e.system||{},a.system)),delete e._types,e}static _getTemplateFields(e){return e[this.documentName]||{}}static get extractedAssetPath(){const e=this.package??game.world;return path.join(e.path,"assets",this.metadata.collection)}_deleteExtractedAssets(){const e=this.constructor.extractedAssetPath;if(!fs.existsSync(e))return;const t=this.parent?[this.parent.id,this.collectionName,this.id].join("-"):this.id;for(const i of fs.readdirSync(e))if(i.startsWith(t)){const t=path.join(e,i);fs.unlinkSync(t),logger.info(`Deleted extracted base64 asset: ${t}`)}}static async migrateSystem(){if(!this.hasTypeData)throw new Error(`Document ${this.documentName} does not have type data`);globalThis.logger.info(`Migrating ${this.documentName} documents to the latest game system data model`);const e=await this.find(),t=this.db.batch();for(const i of e)try{i.updateSource({system:i.migrateSystemData()});for(const[e,t]of Object.entries(this.metadata.embedded)){if(global.db[e].hasTypeData)for(const e of i[t])e.updateSource({system:e.migrateSystemData()})}i.batchWrite(t,{writeEmbedded:!0})}catch(e){globalThis.logger.error(e)}await t.write(),globalThis.logger.info(`Successfully migrated ${e.length} ${this.documentName} documents to the latest system data model.`)}static _deleteStats(e){if("Object"===getType(e)){"Object"!==getType(e._stats)?delete e._stats:DocumentStatsField.managedFields.forEach((t=>delete e._stats[t]));for(const[t,i]of Object.entries(this.hierarchy))if(i instanceof EmbeddedDocumentField)i.model._deleteStats(e[t]);else{if(!(i instanceof EmbeddedCollectionField))throw new Error("Unknown embedded field");{const s=e[t];if(Array.isArray(s))for(const e of s)i.model._deleteStats(e)}}}}static async preprocessData(e,{document:t,documentId:i,user:s}){this.sanitizeUserInput(e,{document:t,documentId:i,user:s}),this._deleteStats(e)}async _generateEmbeddedDocumentIds(e=!0){for(const[t,i]of this.traverseEmbeddedDocuments()){if(i._id&&!1!==e)continue;const s=this.getSublevel(t);i.updateSource({_id:await s.createNewId()})}}async _preCreate(e,t,i){if(!1===await super._preCreate(e,t,i))return!1;await this._generateEmbeddedDocumentIds(t.keepEmbeddedIds),this.ownership&&i&&!(i.id in this.ownership)&&this.updateSource({[`ownership.${i.id}`]:DOCUMENT_OWNERSHIP_LEVELS.OWNER}),tagModelStats(this,{user:i,modifiedTime:t.modifiedTime})}async _preUpdate(e,t,i){if(!1===await super._preUpdate(e,t,i))return!1;tagModelStats(this,{changes:e,user:i,modifiedTime:t.modifiedTime})}static async _onCreateOperation(e,t,i){await super._onCreateOperation(e,t,i),delete t.parent,delete t.data}static async _onUpdateOperation(e,t,i){await super._onUpdateOperation(e,t,i),delete t.parent,delete t.updates}_onDelete(e,t){super._onDelete(e,t),Promise.resolve().then((()=>this._deleteExtractedAssets()))}static async _onDeleteOperation(e,t,i){await super._onDeleteOperation(e,t,i),delete t.parent,delete t.ids}}return t}function migratePermissionToOwnership(e){return Document._addDataFieldMigration(e,"permission","ownership")}