/** * TODO For now, this is just a placeholder for an actual ActionHistory implementation that should * replace today's ActionLog. It defines all the methods that are expected from it by Sharing.ts. * * In addition, it will need to support some methods to show action history to the user, which is * the main purpose of ActionLog today. And it will need to allow querying a subset of history (at * least by table or record). * * The main difference with today's ActionLog is that it needs to mark actions either with labels, * or more likely with Git-like branches, so that we can distinguish shared, local-sent, and * local-unsent actions. And it needs to work on LocalActionBundles, which include more * information than what ActionLog stores. On the other hand, it can probably store actions as * blobs, which can simplify the database storage. */ import {LocalActionBundle} from 'app/common/ActionBundle'; import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup'; import {createEmptyActionSummary} from 'app/common/ActionSummary'; import {getSelectionDesc, UserAction} from 'app/common/DocActions'; import {DocState} from 'app/common/UserAPI'; import toPairs = require('lodash/toPairs'); import {summarizeAction} from 'app/common/ActionSummarizer'; export interface ActionGroupOptions { // If set, inspect the action in detail in order to include a summary of // changes made within the action. Otherwise, the actionSummary returned is empty. summarize?: boolean; // The client for which the action group is being prepared, if known. clientId?: string; // Values returned by the action, if known. retValues?: any[]; // Set the 'internal' flag on the created actions, as inappropriate to undo. internal?: boolean; } /** * Metadata about an action that is needed for undo/redo stack. */ export interface ActionHistoryUndoInfoWithoutClient { otherId: number; linkId: number; rowIdHint: number; isUndo: boolean; } export interface ActionHistoryUndoInfo extends ActionHistoryUndoInfoWithoutClient { clientId: string; } export abstract class ActionHistory { /** * Initialize the ActionLog by reading the database. No other methods may be used until the * initialization completes. If used, their behavior is undefined. */ public abstract initialize(): Promise; public abstract isInitialized(): boolean; /** Returns the actionNum of the next action we expect from the hub. */ public abstract getNextHubActionNum(): number; /** Returns the actionNum of the next local action should have. */ public abstract getNextLocalActionNum(): number; /** * Act as if we have already seen actionNum. getNextHubActionNum will return 1 plus this. * Only suitable for use if there are no unshared local actions. */ public abstract skipActionNum(actionNum: number): Promise; /** Returns whether we have local unsent actions. */ public abstract haveLocalUnsent(): boolean; /** Returns whether we have any local actions that have been sent to the hub. */ public abstract haveLocalSent(): boolean; /** Returns whether we have any locally-applied actions. */ public abstract haveLocalActions(): boolean; /** Fetches and returns an array of all local unsent actions. */ public abstract fetchAllLocalUnsent(): Promise; /** Fetches and returns an array of all local actions (sent and unsent). */ public abstract fetchAllLocal(): Promise; /** Deletes all local-only actions, and resets the affected branch pointers. */ // TODO Should we actually delete, or be more git-like, only reset local branch pointer, and let // cleanup of unreferenced actions happen in a separate step? public abstract clearLocalActions(): Promise; /** * Marks all actions returned from fetchAllLocalUnsent() as sent. Actions must be consecutive * starting with the the first local unsent action. */ public abstract markAsSent(actions: LocalActionBundle[]): Promise; /** * Matches the action from the hub against the first sent local action. If it's the same action, * marks our action as "shared", i.e. accepted by the hub, and returns true. Else returns false. * If actionHash is null, accepts unconditionally. */ public abstract acceptNextSharedAction(actionHash: string|null): Promise; /** Records a new local unsent action, after setting action.actionNum appropriately. */ public abstract recordNextLocalUnsent(action: LocalActionBundle): Promise; /** Records a new action received from the hub, after setting action.actionNum appropriately. */ public abstract recordNextShared(action: LocalActionBundle): Promise; /** * Get the most recent actions from the history. Results are ordered by * earliest actions first, later actions later. If `maxActions` is supplied, * at most that number of actions are returned. * * This method should be avoid in production, since it may convert and keep in memory many large * actions. (It has in the past led to exhausting memory and crashing node.) */ public abstract getRecentActions(maxActions?: number): Promise; /** * Same as getRecentActions, but converts each to an ActionGroup using asActionGroup with the * supplied options. */ public abstract getRecentActionGroups(maxActions: number, options: ActionGroupOptions): Promise; public abstract getRecentMinimalActionGroups(maxActions: number, clientId?: string): Promise; /** * Get the most recent states from the history. States are just * actions without any content. Results are ordered by most recent * states first (careful, this is the opposite to getRecentActions). * If `maxStates` is supplied, at most that number of actions are * returned. */ public abstract getRecentStates(maxStates?: number): Promise; /** * Get a list of actions, identified by their actionNum. Any actions that could not be * found are returned as undefined. */ public abstract getActions(actionNums: number[]): Promise>; /** * Associates an action with a client. This association is expected to be transient, rather * than persistent. It should survive a client-side reload but not a server-side restart. */ public abstract setActionUndoInfo(actionHash: string, undoInfo: ActionHistoryUndoInfo): void; /** Check for any client associated with an action, identified by checksum */ public abstract getActionUndoInfo(actionHash: string): ActionHistoryUndoInfo | undefined; /** * Remove all stored actions except the last keepN and run the VACUUM command * to reduce the size of the SQLite file. * * @param {Int} keepN - The number of most recent actions to keep. The value must be at least 1, and * will default to 1 if not given. */ public abstract deleteActions(keepN: number): Promise; } /** * Old helper to display the actionGroup in a human-readable way. Being maintained * to avoid having to change too much at once. */ export function humanDescription(actions: UserAction[]): string { const action = actions[0]; if (!action) { return ""; } let output = ''; // Common names for various action parameters const name = action[0]; const table = action[1]; const rows = action[2]; const colId = action[2]; const columns: any = action[3]; // TODO - better typing - but code may evaporate switch (name) { case 'UpdateRecord': case 'BulkUpdateRecord': case 'AddRecord': case 'BulkAddRecord': output = name + ' ' + getSelectionDesc(action, columns); break; case 'ApplyUndoActions': // Currently cannot display information about what action was undone, as the action comes // with the description of the "undo" message, which might be very different // Also, cannot currently properly log redos as they are not distinguished from others in any way // TODO: make an ApplyRedoActions type for redoing actions output = 'Undo Previous Action'; break; case 'InitNewDoc': output = 'Initialized new Document'; break; case 'AddColumn': output = 'Added column ' + colId + ' to ' + table; break; case 'RemoveColumn': output = 'Removed column ' + colId + ' from ' + table; break; case 'RemoveRecord': case 'BulkRemoveRecord': output = 'Removed record(s) ' + rows + ' from ' + table; break; case 'EvalCode': output = 'Evaluated Code ' + action[1]; break; case 'AddTable': output = 'Added table ' + table; break; case 'RemoveTable': output = 'Removed table ' + table; break; case 'ModifyColumn': // TODO: The Action Log currently only logs user actions, // But ModifyColumn/Rename Column are almost always triggered from the client // through a meta-table UpdateRecord. // so, this is a case where making use of explicit sandbox engine 'looged' actions // may be useful output = 'Modify column ' + colId + ", "; for (const [col, val] of toPairs(columns)) { output += col + ": " + val + ", "; } output += ' in table ' + table; break; case 'RenameColumn': { const newColId = action[3]; output = 'Renamed Column ' + colId + ' to ' + newColId + ' in ' + table; break; } default: output = name + ' [No Description]'; } // A period for good grammar output += '.'; return output; } /** * Convert an ActionBundle into an ActionGroup. ActionGroups are the representation of * actions on the client. * @param history: interface to action history * @param act: action to convert * @param options: options to construct the ActionGroup; see its documentation above. */ export function asActionGroup(history: ActionHistory, act: LocalActionBundle, options: ActionGroupOptions): ActionGroup { const {summarize, clientId} = options; const info = act.info[1]; const fromSelf = (act.actionHash && clientId) ? (history.getActionUndoInfo(act.actionHash)?.clientId === clientId) : false; const {extra: {primaryAction}, minimal: {rowIdHint, isUndo}} = getActionUndoInfoWithoutClient(act, options.retValues); return { actionNum: act.actionNum, actionHash: act.actionHash || "", desc: info.desc || humanDescription(act.userActions), actionSummary: summarize ? summarizeAction(act) : createEmptyActionSummary(), fromSelf, linkId: info.linkId, otherId: info.otherId, time: info.time, user: info.user, rowIdHint, primaryAction, isUndo, internal: options.internal || false, }; } export function asMinimalActionGroup(history: ActionHistory, act: {actionHash: string, actionNum: number}, clientId?: string): MinimalActionGroup { const undoInfo = act.actionHash ? history.getActionUndoInfo(act.actionHash) : undefined; const fromSelf = clientId ? (undoInfo?.clientId === clientId) : false; return { actionNum: act.actionNum, actionHash: act.actionHash || "", fromSelf, linkId: undoInfo?.linkId || 0, otherId: undoInfo?.otherId || 0, rowIdHint: undoInfo?.rowIdHint || 0, isUndo: undoInfo?.isUndo || false, }; } export function getActionUndoInfo(act: LocalActionBundle, clientId: string, retValues: any[]): ActionHistoryUndoInfo { return { ...getActionUndoInfoWithoutClient(act, retValues).minimal, clientId, }; } /** * Compute undo information from an action bundle and return values if available. * Results are returned as {minimal, extra} where core has information needed for minimal * action groups, and extra has information only needed for full action groups. */ function getActionUndoInfoWithoutClient(act: LocalActionBundle, retValues?: any[]) { let rowIdHint = 0; if (retValues) { // A hint for cursor position. This logic used to live on the client, but now trying to // limit how much the client looks at the internals of userActions. // In case of AddRecord, the returned value is rowId, which is the best cursorPos for Redo. for (let i = 0; i < act.userActions.length; i++) { const name = act.userActions[i][0]; const retValue = retValues[i]; if (name === 'AddRecord') { rowIdHint = retValue; break; } else if (name === 'BulkAddRecord') { rowIdHint = retValue[0]; break; } } } const info = act.info[1]; const primaryAction: string = String((act.userActions[0] || [""])[0]); const isUndo = primaryAction === 'ApplyUndoActions'; return { minimal: { rowIdHint, otherId: info.otherId, linkId: info.linkId, isUndo, }, extra: { primaryAction, }, }; }