Files
2025-01-04 00:34:03 +01:00

608 lines
25 KiB
JavaScript

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<ClientDocument[]>} 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<ClientDocument[]>} 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<ClientDocument[]>} 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);
}
}