import DatabaseBackend from "../../common/abstract/backend.mjs"; /** * @typedef {import("../../common/abstract/_types.mjs").DatabaseAction} DatabaseAction * @typedef {import("../../common/abstract/_types.mjs").DatabaseOperation} DatabaseOperation * @typedef {import("../../common/abstract/_types.mjs").DatabaseGetOperation} DatabaseGetOperation * @typedef {import("../../common/abstract/_types.mjs").DatabaseCreateOperation} DatabaseCreateOperation * @typedef {import("../../common/abstract/_types.mjs").DatabaseUpdateOperation} DatabaseUpdateOperation * @typedef {import("../../common/abstract/_types.mjs").DatabaseDeleteOperation} DatabaseDeleteOperation * @typedef {import("../../common/abstract/_types.mjs").DocumentSocketRequest} DocumentSocketRequest */ /** * The client-side database backend implementation which handles Document modification operations. * @alias foundry.data.ClientDatabaseBackend */ export default class ClientDatabaseBackend extends DatabaseBackend { /* -------------------------------------------- */ /* Get Operations */ /* -------------------------------------------- */ /** * @override * @ignore */ async _getDocuments(documentClass, operation, user) { const request = ClientDatabaseBackend.#buildRequest(documentClass, "get", operation); const response = await ClientDatabaseBackend.#dispatchRequest(request); if ( operation.index ) return response.result; return response.result.map(data => documentClass.fromSource(data, {pack: operation.pack})); } /* -------------------------------------------- */ /* Create Operations */ /* -------------------------------------------- */ /** * @override * @ignore */ async _createDocuments(documentClass, operation, user) { user ||= game.user; await ClientDatabaseBackend.#preCreateDocumentArray(documentClass, operation, user); if ( !operation.data.length ) return []; /** @deprecated since v12 */ // Legacy support for temporary creation option if ( "temporary" in operation ) { foundry.utils.logCompatibilityWarning("It is no longer supported to create temporary documents using the " + "Document.createDocuments API. Use the new Document() constructor instead.", {since: 12, until: 14}); if ( operation.temporary ) return operation.data; } const request = ClientDatabaseBackend.#buildRequest(documentClass, "create", operation); const response = await ClientDatabaseBackend.#dispatchRequest(request); return this.#handleCreateDocuments(response); } /* -------------------------------------------- */ /** * Perform a standardized pre-creation workflow for all Document types. * This workflow mutates the operation data array. * @param {typeof ClientDocument} documentClass * @param {DatabaseCreateOperation} operation * @param {User} user */ static async #preCreateDocumentArray(documentClass, operation, user) { const {data, noHook, pack, parent, ...options} = operation; const type = documentClass.documentName; const toCreate = []; const documents = []; for ( let d of data ) { // Clean input data d = ( d instanceof foundry.abstract.DataModel ) ? d.toObject() : foundry.utils.expandObject(d); d = documentClass.migrateData(d); const createData = foundry.utils.deepClone(d); // Copy for later passing original input data to preCreate // Create pending document let doc; try { doc = new documentClass(createData, {parent, pack}); } catch(err) { Hooks.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"}); continue; } // Call per-document workflows let documentAllowed = await doc._preCreate(d, options, user) ?? true; documentAllowed &&= (noHook || Hooks.call(`preCreate${type}`, doc, d, options, user.id)); if ( documentAllowed === false ) { console.debug(`${vtt} | ${type} creation prevented by _preCreate`); continue; } documents.push(doc); toCreate.push(d); } operation.data = toCreate; if ( !documents.length ) return; // Call final pre-operation workflow Object.assign(operation, options); // Hooks may have changed options const operationAllowed = await documentClass._preCreateOperation(documents, operation, user); if ( operationAllowed === false ) { console.debug(`${vtt} | ${type} creation operation prevented by _preCreateOperation`); operation.data = []; } else operation.data = documents; } /* -------------------------------------------- */ /** * Handle a SocketResponse from the server when one or multiple documents were created. * @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response * @returns {Promise} An Array of created Document instances */ async #handleCreateDocuments(response) { const {type, operation, result, userId} = response; const documentClass = getDocumentClass(type); const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation); const collection = ClientDatabaseBackend.#getCollection(documentClass, operation); const user = game.users.get(userId); const {pack, parentUuid, syntheticActorUpdate, ...options} = operation; operation.data = response.result; // Record created data objects back to the operation // Initial descendant document events const preArgs = [result, options, userId]; parent?._dispatchDescendantDocumentEvents("preCreate", collection.name, preArgs); // Create documents and prepare post-creation callback functions const callbacks = result.map(data => { const doc = collection.createDocument(data, {parent, pack}); collection.set(doc.id, doc, options); return () => { doc._onCreate(data, options, userId); Hooks.callAll(`create${type}`, doc, options, userId); return doc; } }); parent?.reset(); let documents = callbacks.map(fn => fn()); // Call post-operation workflows const postArgs = [documents, result, options, userId]; parent?._dispatchDescendantDocumentEvents("onCreate", collection.name, postArgs); await documentClass._onCreateOperation(documents, operation, user); collection._onModifyContents("create", documents, result, operation, user); // Log and return result if ( CONFIG.debug.documents ) this._logOperation("Created", type, documents, {level: "info", parent, pack}); if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents); return documents; } /* -------------------------------------------- */ /* Update Operations */ /* -------------------------------------------- */ /** * @override * @ignore */ async _updateDocuments(documentClass, operation, user) { user ||= game.user; await ClientDatabaseBackend.#preUpdateDocumentArray(documentClass, operation, user); if ( !operation.updates.length ) return []; const request = ClientDatabaseBackend.#buildRequest(documentClass, "update", operation); const response = await ClientDatabaseBackend.#dispatchRequest(request); return this.#handleUpdateDocuments(response); } /* -------------------------------------------- */ /** * Perform a standardized pre-update workflow for all Document types. * This workflow mutates the operation updates array. * @param {typeof ClientDocument} documentClass * @param {DatabaseUpdateOperation} operation * @param {User} user */ static async #preUpdateDocumentArray(documentClass, operation, user) { const collection = ClientDatabaseBackend.#getCollection(documentClass, operation); const type = documentClass.documentName; const {updates, restoreDelta, noHook, pack, parent, ...options} = operation; // Ensure all Documents which are update targets have been loaded await ClientDatabaseBackend.#loadCompendiumDocuments(collection, updates); // Iterate over requested changes const toUpdate = []; const documents = []; for ( let update of updates ) { if ( !update._id ) throw new Error("You must provide an _id for every object in the update data Array."); // Retrieve the target document and the request changes let changes; if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject(); else changes = foundry.utils.expandObject(update); const doc = collection.get(update._id, {strict: true, invalid: true}); // Migrate provided changes, including document sub-type const addType = ("type" in doc) && !("type" in changes); if ( addType ) changes.type = doc.type; changes = documentClass.migrateData(changes); // Perform pre-update operations let documentAllowed = await doc._preUpdate(changes, options, user) ?? true; documentAllowed &&= (noHook || Hooks.call(`preUpdate${type}`, doc, changes, options, user.id)); if ( documentAllowed === false ) { console.debug(`${vtt} | ${type} update prevented during pre-update`); continue; } // Attempt updating the document to validate the changes let diff = {}; try { diff = doc.updateSource(changes, {dryRun: true, fallback: false, restoreDelta}); } catch(err) { ui.notifications.error(err.message.split("] ").pop()); Hooks.onError("ClientDatabaseBackend##preUpdateDocumentArray", err, {id: doc.id, log: "error"}); continue; } // Retain only the differences against the current source if ( options.diff ) { if ( foundry.utils.isEmpty(diff) ) continue; diff._id = doc.id; changes = documentClass.shimData(diff); // Re-apply shims for backwards compatibility in _preUpdate hooks } else if ( addType ) delete changes.type; documents.push(doc); toUpdate.push(changes); } operation.updates = toUpdate; if ( !toUpdate.length ) return; // Call final pre-operation workflow Object.assign(operation, options); // Hooks may have changed options const operationAllowed = await documentClass._preUpdateOperation(documents, operation, user); if ( operationAllowed === false ) { console.debug(`${vtt} | ${type} creation operation prevented by _preUpdateOperation`); operation.updates = []; } } /* -------------------------------------------- */ /** * Handle a SocketResponse from the server when one or multiple documents were updated. * @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response * @returns {Promise} An Array of updated Document instances */ async #handleUpdateDocuments(response) { const {type, operation, result, userId} = response; const documentClass = getDocumentClass(type); const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation); const collection = ClientDatabaseBackend.#getCollection(documentClass, operation); const user = game.users.get(userId); const {pack, parentUuid, syntheticActorUpdate, ...options} = operation; operation.updates = response.result; // Record update data objects back to the operation // Ensure all Documents which are update targets have been loaded. await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.updates); // Pre-operation actions const preArgs = [result, options, userId]; parent?._dispatchDescendantDocumentEvents("preUpdate", collection.name, preArgs); // Perform updates and create a callback function for each document const callbacks = []; const changes = []; for ( let change of result ) { const doc = collection.get(change._id, {strict: false}); if ( !doc ) continue; doc.updateSource(change, options); collection.set(doc.id, doc, options); callbacks.push(() => { change = documentClass.shimData(change); doc._onUpdate(change, options, userId); Hooks.callAll(`update${type}`, doc, change, options, userId); changes.push(change); return doc; }); } parent?.reset(); let documents = callbacks.map(fn => fn()); operation.updates = changes; // Post-operation actions const postArgs = [documents, changes, options, userId]; parent?._dispatchDescendantDocumentEvents("onUpdate", collection.name, postArgs); await documentClass._onUpdateOperation(documents, operation, user); collection._onModifyContents("update", documents, changes, operation, user); // Log and return result if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack}); if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents); return documents; } /* -------------------------------------------- */ /* Delete Operations */ /* -------------------------------------------- */ /** * @override * @ignore */ async _deleteDocuments(documentClass, operation, user) { user ||= game.user; await ClientDatabaseBackend.#preDeleteDocumentArray(documentClass, operation, user); if ( !operation.ids.length ) return operation.ids; const request = ClientDatabaseBackend.#buildRequest(documentClass, "delete", operation); const response = await ClientDatabaseBackend.#dispatchRequest(request); return this.#handleDeleteDocuments(response); } /* -------------------------------------------- */ /** * Perform a standardized pre-delete workflow for all Document types. * This workflow mutates the operation ids array. * @param {typeof ClientDocument} documentClass * @param {DatabaseDeleteOperation} operation * @param {User} user */ static async #preDeleteDocumentArray(documentClass, operation, user) { let {ids, deleteAll, noHook, pack, parent, ...options} = operation; const collection = ClientDatabaseBackend.#getCollection(documentClass, operation); const type = documentClass.documentName; // Ensure all Documents which are deletion targets have been loaded if ( deleteAll ) ids = Array.from(collection.index?.keys() ?? collection.keys()); await ClientDatabaseBackend.#loadCompendiumDocuments(collection, ids); // Iterate over ids requested for deletion const toDelete = []; const documents = []; for ( const id of ids ) { const doc = collection.get(id, {strict: true, invalid: true}); let documentAllowed = await doc._preDelete(options, user) ?? true; documentAllowed &&= (noHook || Hooks.call(`preDelete${type}`, doc, options, user.id)); if ( documentAllowed === false ) { console.debug(`${vtt} | ${type} deletion prevented during pre-delete`); continue; } toDelete.push(id); documents.push(doc); } operation.ids = toDelete; if ( !toDelete.length ) return; // Call final pre-operation workflow Object.assign(operation, options); // Hooks may have changed options const operationAllowed = await documentClass._preDeleteOperation(documents, operation, user); if ( operationAllowed === false ) { console.debug(`${vtt} | ${type} creation operation prevented by _preDeleteOperation`); operation.ids = []; } } /* -------------------------------------------- */ /** * Handle a SocketResponse from the server where Documents are deleted. * @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response * @returns {Promise} An Array of deleted Document instances */ async #handleDeleteDocuments(response) { const {type, operation, result, userId} = response; const documentClass = getDocumentClass(type); const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation); const collection = ClientDatabaseBackend.#getCollection(documentClass, operation); const user = game.users.get(userId); const {deleteAll, pack, parentUuid, syntheticActorUpdate, ...options} = operation; operation.ids = response.result; // Record deleted document ids back to the operation await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.ids); // Pre-operation actions const preArgs = [result, options, userId]; parent?._dispatchDescendantDocumentEvents("preDelete", collection.name, preArgs); // Perform deletions and create a callback function for each document const callbacks = []; const ids = []; for ( const id of result ) { const doc = collection.get(id, {strict: false}); if ( !doc ) continue; collection.delete(id); callbacks.push(() => { doc._onDelete(options, userId); Hooks.callAll(`delete${type}`, doc, options, userId); ids.push(id); return doc; }); } parent?.reset(); let documents = callbacks.map(fn => fn()); operation.ids = ids; // Post-operation actions const postArgs = [documents, ids, options, userId]; parent?._dispatchDescendantDocumentEvents("onDelete", collection.name, postArgs); await documentClass._onDeleteOperation(documents, operation, user); collection._onModifyContents("delete", documents, ids, operation, user); // Log and return result if ( CONFIG.debug.documents ) this._logOperation("Deleted", type, documents, {level: "info", parent, pack}); if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents); return documents; } /* -------------------------------------------- */ /* Socket Workflows */ /* -------------------------------------------- */ /** * Activate the Socket event listeners used to receive responses from events which modify database documents * @param {Socket} socket The active game socket * @internal * @ignore */ activateSocketListeners(socket) { socket.on("modifyDocument", this.#onModifyDocument.bind(this)); } /* -------------------------------------------- */ /** * Handle a socket response broadcast back from the server. * @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response */ #onModifyDocument(response) { switch ( response.action ) { case "create": this.#handleCreateDocuments(response); break; case "update": this.#handleUpdateDocuments(response); break; case "delete": this.#handleDeleteDocuments(response); break; default: throw new Error(`Invalid Document modification action ${response.action} provided`); } } /* -------------------------------------------- */ /* Helper Methods */ /* -------------------------------------------- */ /** @inheritdoc */ getFlagScopes() { if ( this.#flagScopes ) return this.#flagScopes; const scopes = ["core", "world", game.system.id]; for ( const module of game.modules ) { if ( module.active ) scopes.push(module.id); } return this.#flagScopes = scopes; } /** * A cached array of valid flag scopes which can be read and written. * @type {string[]} */ #flagScopes; /* -------------------------------------------- */ /** @inheritdoc */ getCompendiumScopes() { return Array.from(game.packs.keys()); } /* -------------------------------------------- */ /** @override */ _log(level, message) { globalThis.logger[level](`${vtt} | ${message}`); } /* -------------------------------------------- */ /** * Obtain the document collection for a given Document class and database operation. * @param {typeof foundry.abstract.Document} documentClass The Document class being operated upon * @param {object} operation The database operation being performed * @param {ClientDocument|null} operation.parent A parent Document, if applicable * @param {string|null} operation.pack A compendium pack identifier, if applicable * @returns {DocumentCollection|CompendiumCollection} The relevant collection instance for this request */ static #getCollection(documentClass, {parent, pack}) { const documentName = documentClass.documentName; if ( parent ) return parent.getEmbeddedCollection(documentName); if ( pack ) { const collection = game.packs.get(pack); return documentName === "Folder" ? collection.folders : collection; } return game.collections.get(documentName); } /* -------------------------------------------- */ /** * Structure a database operation as a web socket request. * @param {typeof foundry.abstract.Document} documentClass * @param {DatabaseAction} action * @param {DatabaseOperation} operation * @returns {DocumentSocketRequest} */ static #buildRequest(documentClass, action, operation) { const request = {type: documentClass.documentName, action, operation}; if ( operation.parent ) { // Don't send full parent data operation.parentUuid = operation.parent.uuid; ClientDatabaseBackend.#adjustActorDeltaRequest(documentClass, request); delete operation.parent; } return request; } /* -------------------------------------------- */ /** * Dispatch a document modification socket request to the server. * @param {DocumentSocketRequest} request * @returns {foundry.abstract.DocumentSocketResponse} */ static async #dispatchRequest(request) { const responseData = await SocketInterface.dispatch("modifyDocument", request); return new foundry.abstract.DocumentSocketResponse(responseData); } /* -------------------------------------------- */ /** * Ensure the given list of documents is loaded into the compendium collection so that they can be retrieved by * subsequent operations. * @param {Collection} collection The candidate collection. * @param {object[]|string[]} documents An array of update deltas, or IDs, depending on the operation. */ static async #loadCompendiumDocuments(collection, documents) { // Ensure all Documents which are update targets have been loaded if ( collection instanceof CompendiumCollection ) { const ids = documents.reduce((arr, doc) => { const id = doc._id ?? doc; if ( id && !collection.has(id) ) arr.push(id); return arr; }, []); await collection.getDocuments({_id__in: ids}); } } /* -------------------------------------------- */ /* Token and ActorDelta Special Case */ /* -------------------------------------------- */ /** * Augment a database operation with alterations needed to support ActorDelta and TokenDocuments. * @param {typeof foundry.abstract.Document} documentClass The document class being operated upon * @param {DocumentSocketRequest} request The document modification socket request */ static #adjustActorDeltaRequest(documentClass, request) { const operation = request.operation; const parent = operation.parent; // Translate updates to a token actor to the token's ActorDelta instead. if ( foundry.utils.isSubclass(documentClass, Actor) && (parent instanceof TokenDocument) ) { request.type = "ActorDelta"; if ( "updates" in operation ) operation.updates[0]._id = parent.delta.id; operation.syntheticActorUpdate = true; } // Translate operations on a token actor's embedded children to the token's ActorDelta instead. const token = ClientDatabaseBackend.#getTokenAncestor(parent); if ( token && !(parent instanceof TokenDocument) ) { const {embedded} = foundry.utils.parseUuid(parent.uuid); operation.parentUuid = [token.delta.uuid, embedded.slice(4).join(".")].filterJoin("."); } } /* -------------------------------------------- */ /** * Retrieve a Document's Token ancestor, if it exists. * @param {Document|null} parent The parent Document * @returns {TokenDocument|null} The Token ancestor, or null */ static #getTokenAncestor(parent) { if ( !parent ) return null; if ( parent instanceof TokenDocument ) return parent; return ClientDatabaseBackend.#getTokenAncestor(parent.parent); } /* -------------------------------------------- */ /** * Build a CRUD response. * @param {ActorDelta[]} documents An array of ActorDelta documents modified by a database workflow * @returns {foundry.abstract.Document[]} The modified ActorDelta documents mapped to their synthetic Actor */ static #adjustActorDeltaResponse(documents) { return documents.map(delta => delta.syntheticActor); } }