/** @odoo-module */ import { LoadingDataError } from "../o_spreadsheet/errors"; /** * @param {T[]} array * @returns {T[]} * @template T */ function removeDuplicates(array) { return [...new Set(array.map((el) => JSON.stringify(el)))].map((el) => JSON.parse(el)); } export class Request { /** * @param {string} resModel * @param {string} method * @param {unknown[]} args */ constructor(resModel, method, args) { this.resModel = resModel; this.method = method; this.args = args; this.key = `${resModel}/${method}(${JSON.stringify(args)})`; } } /** * A batch request consists of multiple requests which are combined into a single RPC. * * The batch responsibility is to combine individual requests into a single RPC payload * and to split the response back for individual requests. * * The server method must have the following API: * - The input is a list of arguments. Each list item being the arguments of a single request. * - The output is a list of results, ordered according to the input list * * ``` * [result1, result2] = self.env['my.model'].my_batched_method([request_1_args, request_2_args]) * ``` */ class ListRequestBatch { /** * @param {string} resModel * @param {string} method * @param {Request[]} requests */ constructor(resModel, method, requests = []) { this.resModel = resModel; this.method = method; this.requests = requests; } get payload() { const payload = removeDuplicates(this.requests.map((request) => request.args).flat()); return [payload]; } /** * @param {Request} request */ add(request) { if (request.resModel !== this.resModel || request.method !== this.method) { throw new Error( `Request ${request.resModel}/${request.method} cannot be added to the batch ${this.resModel}/${this.method}` ); } this.requests.push(request); } /** * Split the batched RPC response into single request results * * @param {T[]} results * @returns {Map} * @template T */ splitResponse(results) { const split = new Map(); for (let i = 0; i < this.requests.length; i++) { split.set(this.requests[i], results[i]); } return split; } } export class ServerData { /** * @param {any} orm * @param {object} params * @param {function} params.whenDataIsFetched */ constructor(orm, { whenDataIsFetched }) { this.orm = orm; this.dataFetchedCallback = whenDataIsFetched; /** @type {Record}*/ this.cache = {}; /** @type {Record>}*/ this.asyncCache = {}; this.batchEndpoints = {}; } /** * @returns {{get: (resModel:string, method: string, args: unknown) => any}} */ get batch() { return { get: (resModel, method, args) => this._getBatchItem(resModel, method, args) }; } /** * @private * @param {string} resModel * @param {string} method * @param {unknown} args * @returns {any} */ _getBatchItem(resModel, method, args) { const request = new Request(resModel, method, [args]); if (!(request.key in this.cache)) { const error = new LoadingDataError(); this.cache[request.key] = error; this._batch(request); throw error; } return this._getOrThrowCachedResponse(request); } /** * @param {string} resModel * @param {string} method * @param {unknown[]} args * @returns {any}} */ get(resModel, method, args) { const request = new Request(resModel, method, args); if (!(request.key in this.cache)) { const error = new LoadingDataError(); this.cache[request.key] = error; this.orm .call(resModel, method, args) .then((result) => (this.cache[request.key] = result)) .catch((error) => (this.cache[request.key] = error)) .finally(() => this.dataFetchedCallback()); throw error; } return this._getOrThrowCachedResponse(request); } /** * Returns the request result if cached or the associated promise * @param {string} resModel * @param {string} method * @param {unknown[]} [args] * @returns {Promise} */ async fetch(resModel, method, args) { const request = new Request(resModel, method, args); if (!(request.key in this.asyncCache)) { this.asyncCache[request.key] = this.orm.call(resModel, method, args); } return this.asyncCache[request.key]; } /** * @private * @param {Request} request * @returns {void} */ _batch(request) { const endpoint = this._getBatchEndPoint(request.resModel, request.method); endpoint.call(request); } /** * @private * @param {Request} request * @return {unknown} */ _getOrThrowCachedResponse(request) { const data = this.cache[request.key]; if (data instanceof Error) { throw data; } return data; } /** * @private * @param {string} resModel * @param {string} method */ _getBatchEndPoint(resModel, method) { if (!this.batchEndpoints[resModel] || !this.batchEndpoints[resModel][method]) { this.batchEndpoints[resModel] = { ...this.batchEndpoints[resModel], [method]: this._createBatchEndpoint(resModel, method), }; } return this.batchEndpoints[resModel][method]; } /** * @private * @param {string} resModel * @param {string} method */ _createBatchEndpoint(resModel, method) { return new BatchEndpoint(this.orm, resModel, method, { whenDataIsFetched: () => this.dataFetchedCallback(), successCallback: (request, result) => (this.cache[request.key] = result), failureCallback: (request, error) => (this.cache[request.key] = error), }); } } /** * Collect multiple requests into a single batch. */ export default class BatchEndpoint { /** * @param {object} orm * @param {string} resModel * @param {string} method * @param {object} callbacks * @param {function} callbacks.successCallback * @param {function} callbacks.failureCallback * @param {function} callbacks.whenDataIsFetched */ constructor(orm, resModel, method, { successCallback, failureCallback, whenDataIsFetched }) { this.orm = orm; this.resModel = resModel; this.method = method; this.successCallback = successCallback; this.failureCallback = failureCallback; this.batchedFetchedCallback = whenDataIsFetched; this._isScheduled = false; this._pendingBatch = new ListRequestBatch(resModel, method); } /** * @param {Request} request */ call(request) { this._pendingBatch.add(request); this._scheduleNextBatch(); } /** * @param {Map} batchResult * @private */ _notifyResults(batchResult) { for (const [request, result] of batchResult) { if (result instanceof Error) { this.failureCallback(request, result); } else { this.successCallback(request, result); } } } /** * @private */ _scheduleNextBatch() { if (this._isScheduled || this._pendingBatch.requests.length === 0) { return; } this._isScheduled = true; queueMicrotask(async () => { try { this._isScheduled = false; const batch = this._pendingBatch; const { resModel, method } = batch; this._pendingBatch = new ListRequestBatch(resModel, method); await this.orm .call(resModel, method, batch.payload) .then((result) => batch.splitResponse(result)) .catch(() => this._retryOneByOne(batch)) .then((batchResults) => this._notifyResults(batchResults)); } finally { this.batchedFetchedCallback(); } }); } /** * @private * @param {ListRequestBatch} batch * @returns {Promise>} */ async _retryOneByOne(batch) { const mergedResults = new Map(); const { resModel, method } = batch; const singleRequestBatches = batch.requests.map( (request) => new ListRequestBatch(resModel, method, [request]) ); const proms = []; for (const batch of singleRequestBatches) { const request = batch.requests[0]; const prom = this.orm .call(resModel, method, batch.payload) .then((result) => mergedResults.set(request, batch.splitResponse(result).get(request)) ) .catch((error) => mergedResults.set(request, error)); proms.push(prom); } await Promise.allSettled(proms); return mergedResults; } }