/** * DocData maintains all underlying data for a Grist document, knows how to load it, * subscribes to actions which change it, and forwards those actions to individual tables. * It also provides the interface to apply actions to data. */ import {DocComm} from 'app/client/components/DocComm'; import {MetaTableData, TableData} from 'app/client/models/TableData'; import {ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI'; import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {DocData as BaseDocData} from 'app/common/DocData'; import {SchemaTypes} from 'app/common/schema'; import {ColTypeMap} from 'app/common/TableData'; import * as bluebird from 'bluebird'; import {Emitter} from 'grainjs'; import defaults = require('lodash/defaults'); const gristNotify = (window as any).gristNotify; export class DocData extends BaseDocData { public readonly sendActionsEmitter = new Emitter(); public readonly sendActionsDoneEmitter = new Emitter(); private _bundlesPending: number = 0; // How many bundles are currently pending. private _lastBundlePromise?: Promise; // Promise for completion of the last pending bundle. private _triggerBundleFinalize?: () => void; // When a bundle is pending, trigger its finalize() callback. // When a bundle is pending and actions should be checked, the callback to check them. private _shouldIncludeInBundle?: (actions: UserAction[]) => boolean; private _nextDesc: string|null = null; // The description for the next incoming action. private _lastActionNum: number|null = null; // ActionNum of the last action in the current bundle, or null. private _bundleSender: BundleSender; /** * Constructor for DocData. * @param {Object} docComm: A map of server methods available on this document. * @param {Object} metaTableData: A map from tableId to table data, presented as an action, * equivalent to BulkAddRecord, i.e. ["TableData", tableId, rowIds, columnValues]. */ constructor(public readonly docComm: DocComm, metaTableData: {[tableId: string]: TableDataAction}) { super((tableId) => docComm.fetchTable(tableId), metaTableData); this._bundleSender = new BundleSender(this.docComm); } public createTableData(tableId: string, tableData: TableDataAction|null, colTypes: ColTypeMap): TableData { return new TableData(this, tableId, tableData, colTypes); } // Version of inherited getTable() which returns the enhance TableData type. public getTable(tableId: string): TableData|undefined { return super.getTable(tableId) as TableData; } // Version of inherited getMetaTable() which returns the enhanced TableData type. public getMetaTable(tableId: TableId): MetaTableData { return super.getMetaTable(tableId) as any; } /** * Finds up to n most likely target columns for the given values in the document. */ public async findColFromValues(values: any[], n: number, optTableId?: string): Promise { try { return await this.docComm.findColFromValues(values, n, optTableId); } catch (e) { gristNotify(`Error finding matching columns: ${e.message}`); return []; } } /** * Returns error message (traceback) for one invalid formula cell. */ public getFormulaError(tableId: string, colId: string, rowId: number): Promise { return this.docComm.getFormulaError(tableId, colId, rowId); } // Sets a bundle to collect all incoming actions. Throws an error if any actions which // do not match the verification callback are sent. public startBundlingActions(options: BundlingOptions): BundlingInfo { if (this._bundlesPending >= 2) { // We don't expect a full-blown queue of bundles or actions at any point. If a bundle is // pending, a new bundle should immediately finalize it. Here we refuse to queue up more // actions than that. (This could crop up in theory while disconnected, but is hard to // trigger to test.) throw new Error('Too many actions already pending'); } this._bundlesPending++; // Promise to allow waiting for the result of prepare() callback before it's even called. let prepareResolve!: (value: T|Promise) => void; const preparePromise = new Promise(resolve => { prepareResolve = resolve; }); // Manually-triggered promise for when finalize() should be called. It's triggered by user, // and when an unrelated action or a new bundle is started. let triggerFinalize!: () => void; const triggerFinalizePromise = new Promise(resolve => { triggerFinalize = resolve; }); const doBundleActions = async () => { if (this._lastBundlePromise) { this._triggerBundleFinalize?.(); await this._lastBundlePromise; } try { this._nextDesc = options.description; this._lastActionNum = null; this._triggerBundleFinalize = triggerFinalize; prepareResolve(options.prepare()); this._shouldIncludeInBundle = options.shouldIncludeInBundle; // If finalize is triggered, we must wait for preparePromise to fulfill before proceeding. await Promise.all([triggerFinalizePromise, preparePromise]); // Unset _shouldIncludeInBundle so that actions sent by finalize() are included in the // bundle. If they were checked and incorrectly failed the check, we'd have a deadlock. // TODO The downside is that when sending multiple unrelated actions quickly, the first // can trigger finalize, and subsequent ones can get bundled in while finalize() is // running. This changes the order of actions and may create problems (e.g. with undo). this._shouldIncludeInBundle = undefined; await options.finalize(); } finally { // In all cases, reset the bundle-specific values we set above this._shouldIncludeInBundle = undefined; this._triggerBundleFinalize = undefined; this._bundlesPending--; if (this._bundlesPending === 0) { this._lastBundlePromise = undefined; } } }; const completionPromise = this._lastBundlePromise = doBundleActions(); return {preparePromise, triggerFinalize, completionPromise}; } // Execute a callback that may send multiple actions, and bundle those actions together. The // callback may return a promise, in which case bundleActions() will wait for it to resolve. // If nestInActiveBundle is true, and there is an active bundle, then simply calls callback() // without starting a new bundle. public async bundleActions(desc: string|null, callback: () => T|Promise, options: {nestInActiveBundle?: boolean} = {}): Promise { if (options.nestInActiveBundle && this._bundlesPending) { return await callback(); } const bundlingInfo = this.startBundlingActions({ description: desc, shouldIncludeInBundle: () => true, prepare: callback, finalize: async () => undefined, }); try { return await bundlingInfo.preparePromise; } finally { bundlingInfo.triggerFinalize(); await bundlingInfo.completionPromise; } } /** * Sends actions to the server to be applied. * @param {String} optDesc: Optional description of the actions to be shown in the log. * * sendActions also emits two events: * 'sendActions': emitted before the action is sent, with { actions } object as data. * 'sendActionsDone': emitted on success, with the same data object. * Note that it allows a handler for 'sendActions' to pass along information to the handler * for the corresponding 'sendActionsDone', by tacking it onto the event data object. */ public sendActions(actions: UserAction[], optDesc?: string): Promise { // Some old code relies on this promise being a bluebird Promise. // TODO Remove bluebird and this cast. return bluebird.Promise.resolve(this._sendActionsImpl(actions, optDesc)) as unknown as Promise; } /** * Sends a single action to the server to be applied. Calls this.sendActions to manage the * optional bundle. * @param {String} optDesc: Optional description of the actions to be shown in the log. */ public sendAction(action: UserAction, optDesc?: string): Promise { return this.sendActions([action], optDesc).then((retValues) => retValues[0]); } // See documentation of sendActions(). private async _sendActionsImpl(actions: UserAction[], optDesc?: string): Promise { const eventData = {actions}; this.sendActionsEmitter.emit(eventData); const options = { desc: optDesc }; if (this._shouldIncludeInBundle && !this._shouldIncludeInBundle(actions)) { this._triggerBundleFinalize?.(); await this._lastBundlePromise; } if (this._bundlesPending) { defaults(options, { desc: this._nextDesc, linkId: this._lastActionNum, }); this._nextDesc = null; } const result: ApplyUAResult = await this._bundleSender.applyUserActions(actions, options); this._lastActionNum = result.actionNum; this.sendActionsDoneEmitter.emit(eventData); return result.retValues; } } /** * BundleSender helper class collects multiple applyUserActions() calls that happen on the same * tick, and sends them to the server all at once. */ class BundleSender { private _options = {}; private _actions: UserAction[] = []; private _sendPromise?: Promise; constructor(private _docComm: DocComm) {} public applyUserActions(actions: UserAction[], options: ApplyUAOptions): Promise { defaults(this._options, options); const start = this._actions.length; this._actions.push(...actions); const end = this._actions.length; return this._getSendPromise() .then(result => ({ actionNum: result.actionNum, retValues: result.retValues.slice(start, end), isModification: result.isModification })); } public _getSendPromise(): Promise { if (!this._sendPromise) { // Note that the first Promise.resolve() ensures that the next step (actual send) happens on // the next tick. By that time, more actions may have been added to this._actions array. this._sendPromise = Promise.resolve() .then(() => { this._sendPromise = undefined; const ret = this._docComm.applyUserActions(this._actions, this._options); this._options = {}; this._actions = []; return ret; }); } return this._sendPromise; } } /** * Options to startBundlingAction(). */ export interface BundlingOptions { // Description of the action bundle. description: string|null; // Checker for whether an action belongs in the current bundle. If not, finalize() will be // called immediately. Note that this checker is NOT applied for actions sent from prepare() // or finalize() callbacks, only those in between. shouldIncludeInBundle: (actions: UserAction[]) => boolean; // Callback to start this action bundle. prepare: () => T|Promise; // Callback to finalize this action bundle. finalize: () => Promise; } /** * Result of startBundlingActions(), to allow waiting for prepare() to complete, and to trigger * finalize() manually, and to wait for the full bundle to complete. */ export interface BundlingInfo { // Promise for when the prepare() has completed. Note that sometimes it's delayed until the // previous bundle has been finalized. preparePromise: Promise; // Ask DocData to call the finalize callback immediately. triggerFinalize: () => void; // Promise for when the bundle has been finalized. completionPromise: Promise; }