diff --git a/app/common/DocActions.ts b/app/common/DocActions.ts index 879f274a..78603888 100644 --- a/app/common/DocActions.ts +++ b/app/common/DocActions.ts @@ -82,7 +82,7 @@ const SCHEMA_ACTIONS = new Set(['AddTable', 'RemoveTable', 'RenameTable', 'AddCo /** * Determines whether a given action is a schema action or not. */ -export function isSchemaAction(action: DocAction): boolean { +export function isSchemaAction(action: DocAction): action is AddTable | RemoveTable | RenameTable | AddColumn | RemoveColumn | RenameColumn | ModifyColumn { return SCHEMA_ACTIONS.has(action[0]); } diff --git a/app/common/DocData.ts b/app/common/DocData.ts index 6086e7bf..9b7da29e 100644 --- a/app/common/DocData.ts +++ b/app/common/DocData.ts @@ -16,8 +16,14 @@ type FetchTableFunc = (tableId: string) => Promise; export class DocData extends ActionDispatcher { private _tables: Map = new Map(); - constructor(private _fetchTableFunc: FetchTableFunc, metaTableData: {[tableId: string]: TableDataAction}) { + /** + * If metaTableData is not supplied, then any tables needed should be loaded manually, + * using syncTable(). All column types will be set to Any, which will affect default + * values. + */ + constructor(private _fetchTableFunc: FetchTableFunc, metaTableData: {[tableId: string]: TableDataAction} | null) { super(); + if (metaTableData === null) { return; } // Create all meta tables, and populate data we already have. for (const tableId in schema) { if (schema.hasOwnProperty(tableId)) { @@ -67,6 +73,17 @@ export class DocData extends ActionDispatcher { return (!table.isLoaded || force) ? table.fetchData(this._fetchTableFunc) : Promise.resolve(); } + /** + * Fetches the data for tableId unconditionally, and without knowledge of its metadata. + * Columns will be assumed to have type 'Any'. + */ + public async syncTable(tableId: string): Promise { + const tableData = await this._fetchTableFunc(tableId); + const colTypes = fromPairs(Object.keys(tableData[3]).map(c => [c, 'Any'])); + colTypes.id = 'Any'; + this._tables.set(tableId, this.createTableData(tableId, tableData, colTypes)); + } + /** * Handles an action received from the server, by forwarding it to the appropriate TableData * object. diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index cad3bcbd..6d5f4d36 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -304,6 +304,7 @@ export interface DocAPI { getRows(tableId: string): Promise; updateRows(tableId: string, changes: TableColValues): Promise; addRows(tableId: string, additions: BulkColValues): Promise; + removeRows(tableId: string, removals: number[]): Promise; replace(source: DocReplacementOptions): Promise; getSnapshots(): Promise; forceReload(): Promise; @@ -690,6 +691,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { }); } + public async removeRows(tableId: string, removals: number[]): Promise { + return this.requestJson(`${this._url}/tables/${tableId}/data/delete`, { + body: JSON.stringify(removals), + method: 'POST' + }); + } + public async replace(source: DocReplacementOptions): Promise { return this.requestJson(`${this._url}/replace`, { body: JSON.stringify(source), diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 7ca80ef4..0881b6ec 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -396,7 +396,7 @@ export class ActiveDoc extends EventEmitter { await this._actionHistory.initialize(); this._granularAccess = new GranularAccess(this.docData, (query) => { - return this.fetchQuery(makeExceptionalDocSession('system'), query, true); + return this._fetchQueryFromDB(query, false); }); await this._granularAccess.update(); this._sharing = new Sharing(this, this._actionHistory); @@ -960,6 +960,14 @@ export class ActiveDoc extends EventEmitter { return this._docManager.makeAccessId(userId); } + public async beforeBroadcast(docActions: DocAction[], undo: DocAction[]) { + await this._granularAccess.beforeBroadcast(docActions, undo); + } + + public async afterBroadcast() { + await this._granularAccess.afterBroadcast(); + } + /** * Broadcast document changes to all the document's clients. Doesn't involve * ActiveDoc directly, but placed here to facilitate future work on granular @@ -1258,14 +1266,14 @@ export class ActiveDoc extends EventEmitter { * This filters a message being broadcast to all clients to be appropriate for one * particular client, if that client may need some material filtered out. */ - private _filterDocUpdate(docSession: OptDocSession, message: { + private async _filterDocUpdate(docSession: OptDocSession, message: { actionGroup: ActionGroup, docActions: DocAction[] }) { if (this._granularAccess.canReadEverything(docSession)) { return message; } const result = { actionGroup: this._granularAccess.filterActionGroup(docSession, message.actionGroup), - docActions: this._granularAccess.filterOutgoingDocActions(docSession, message.docActions), + docActions: await this._granularAccess.filterOutgoingDocActions(docSession, message.docActions), }; if (result.docActions.length === 0) { return null; } return result; diff --git a/app/server/lib/DocClients.ts b/app/server/lib/DocClients.ts index 6f2adaab..d42596a4 100644 --- a/app/server/lib/DocClients.ts +++ b/app/server/lib/DocClients.ts @@ -77,7 +77,7 @@ export class DocClients { */ public async broadcastDocMessage(client: Client|null, type: string, messageData: any, filterMessage?: (docSession: OptDocSession, - messageData: any) => any): Promise { + messageData: any) => Promise): Promise { await Promise.all(this._docSessions.map(async curr => { const fromSelf = (curr.client === client); try { @@ -87,7 +87,7 @@ export class DocClients { sendDocMessage(curr.client, curr.fd, type, messageData, fromSelf); } else { try { - const filteredMessageData = filterMessage(curr, messageData); + const filteredMessageData = await filterMessage(curr, messageData); if (filteredMessageData) { sendDocMessage(curr.client, curr.fd, type, filteredMessageData, fromSelf); } else { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 4c902dc6..b9979105 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -4,7 +4,8 @@ import { emptyPermissionSet, parsePermissions, toMixed } from 'app/common/ACLPer import { ActionGroup } from 'app/common/ActionGroup'; import { createEmptyActionSummary } from 'app/common/ActionSummary'; import { Query } from 'app/common/ActiveDocAPI'; -import { BulkColValues, CellValue, ColValues, DocAction } from 'app/common/DocActions'; +import { AsyncCreate } from 'app/common/AsyncCreate'; +import { BulkAddRecord, BulkColValues, BulkRemoveRecord, CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions'; import { TableDataAction, UserAction } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; @@ -14,6 +15,7 @@ import { getSetMapValue } from 'app/common/gutil'; import { canView } from 'app/common/roles'; import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; +import { getRowIdsFromDocAction, getRelatedRows } from 'app/server/lib/RowAccess'; import * as log from 'app/server/lib/log'; import cloneDeep = require('lodash/cloneDeep'); import get = require('lodash/get'); @@ -115,8 +117,12 @@ export class GranularAccess { // both to be garbage-collected once docSession is no longer in use. private _permissionInfoMap = new WeakMap(); + // When broadcasting a sequence of DocAction[]s, this contains the state of + // affected rows for the relevant table before and after each DocAction. It + // may contain some unaffected rows as well. + private _rowSnapshots: AsyncCreate>|null; - public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise) { + public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise) { } // Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column. @@ -216,12 +222,77 @@ export class GranularAccess { return pset.read !== 'deny'; } + /** + * This should be called after each action bundle has been applied to the database, + * but before the actions are broadcast to clients. It will set us up to be able + * to efficiently filter those broadcasts. + * + * We expect actions bundles for a document to be applied+broadcast serially (the + * broadcasts can be parallelized, but should complete before moving on to further + * document mutation). + */ + public async beforeBroadcast(docActions: DocAction[], undo: DocAction[]) { + if (!this._haveRules) { return; } + + // Prepare to compute row snapshots if it turns out we need them. + // If we never need them, they will never be computed. + this._rowSnapshots = new AsyncCreate(async () => { + // If we arrive here, the actions have been applied to the database. + // For row access work, we'll need to know the state of affected rows before and + // after the actions. One way to get that is to apply the undo actions to the + // affected part of the database. + // NOTE: the approach may need tweaking once row access control can be applied to + // incoming actions, not just outgoing ones -- in that case, it may be that some + // calculations are done earlier that could be reused here. + + // First figure out what rows in which tables are touched during the undo actions. + const rows = new Map(getRelatedRows(undo)); + // Populate a minimal in-memory version of the database with these rows. + const docData = new DocData( + (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), + null, + ); + await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId))); + // Now apply the undo actions. + for (const docAction of undo) { docData.receiveAction(docAction); } + + // Now step forward, storing the before and after state for the table + // involved in each action. We'll use this to compute row access changes. + // For simple changes, the rows will be just the minimal set needed. + // This could definitely be optimized. E.g. for pure table updates, these + // states could be extracted while applying undo actions, with no need for + // a forward pass. And for a series of updates to the same table, there'll + // be duplicated before/after states that could be optimized. + const rowSnapshots = new Array<[TableDataAction, TableDataAction]>(); + for (const docAction of docActions) { + const tableId = getTableId(docAction); + const tableData = docData.getTable(tableId)!; + const before = cloneDeep(tableData.getTableDataAction()); + docData.receiveAction(docAction); + // If table is deleted, state afterwards doesn't matter. + const after = docData.getTable(tableId) ? cloneDeep(tableData.getTableDataAction()) : before; + rowSnapshots.push([before, after]); + } + return rowSnapshots; + }); + } + + /** + * This should be called once an action bundle has been broadcast to all clients. + * It will clean up any temporary state cached for filtering those broadcasts. + */ + public async afterBroadcast() { + if (this._rowSnapshots) { this._rowSnapshots.clear(); } + this._rowSnapshots = null; + } + /** * Filter DocActions to be sent to a client. */ - public filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): DocAction[] { - return docActions.map(action => this.pruneOutgoingDocAction(docSession, action)) - .filter(_docActions => _docActions !== null) as DocAction[]; + public async filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): Promise { + const actions = await Promise.all( + docActions.map((action, idx) => this.pruneOutgoingDocAction(docSession, action, idx))); + return ([] as DocAction[]).concat(...actions); } /** @@ -292,43 +363,21 @@ export class GranularAccess { * Cut out any rows/columns not accessible to the user. May throw a NEED_RELOAD * exception if the information needed to achieve the desired pruning is not available. * Returns null if the action is entirely pruned. The action passed in is never modified. + * The idx parameter is a record of which action in the bundle this action is, and can + * be used to access information in this._rowSnapshots if needed. */ - public pruneOutgoingDocAction(docSession: OptDocSession, a: DocAction): DocAction|null { - const tableId = a[1] as string; + public async pruneOutgoingDocAction(docSession: OptDocSession, a: DocAction, idx: number): Promise { + const tableId = getTableId(a); const permInfo = this._getAccess(docSession); const tableAccess = permInfo.getTableAccess(tableId); - if (tableAccess.read === 'deny') { return null; } - if (tableAccess.read === 'allow') { return a; } - - if (tableAccess.read === 'mixed') { - // For now, trigger a reload, since we don't have the - // information we need to filter rows. Reloads would be very - // annoying if user is working on something, but at least data - // won't be stale. TODO: improve! - throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); + if (tableAccess.read === 'deny') { return []; } + if (tableAccess.read === 'allow') { return [a]; } + if (tableAccess.read === 'mixedColumns') { + return [this._pruneColumns(a, permInfo, tableId)].filter(isObject); } - - if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { - return a; - } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] === 'UpdateRecord' || - a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') { - const na = cloneDeep(a); - this._filterColumns(na[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); - if (Object.keys(na[3]).length === 0) { return null; } - return na; - } else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' || - a[0] === 'ModifyColumn') { - const na = cloneDeep(a); - const colId: string = na[2]; - if (permInfo.getColumnAccess(tableId, colId).read === 'deny') { return null; } - throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); - } else { - // Remaining cases of AddTable, RemoveTable, RenameTable should have - // been handled at the table level. - } - // TODO: handle access to changes in metadata (trigger a reload at least, if - // all else fails). - return a; + // The remainder is the mixed condition. + const revisedDocActions = await this._pruneRows(docSession, a, idx); + return revisedDocActions.map(na => this._pruneColumns(na, permInfo, tableId)).filter(isObject); } /** @@ -487,9 +536,9 @@ export class GranularAccess { */ public filterData(docSession: OptDocSession, data: TableDataAction) { const permInfo = this._getAccess(docSession); - const tableId = data[1] as string; + const tableId = getTableId(data); if (permInfo.getTableAccess(tableId).read === 'mixed') { - this.filterRows(docSession, data); + this._filterRowsAndCells(docSession, data, data); } // Filter columns, omitting any to which the user has no access, regardless of rows. @@ -497,17 +546,155 @@ export class GranularAccess { } /** - * Modify table data in place, removing any rows and scrubbing any cells to which access - * is not granted. + * Strip out any denied columns from an action. Returns null if nothing is left. */ - public filterRows(docSession: OptDocSession, data: TableDataAction) { + private _pruneColumns(a: DocAction, permInfo: PermissionInfo, tableId: string): DocAction|null { + if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { + return a; + } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] === 'UpdateRecord' || + a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') { + const na = cloneDeep(a); + this._filterColumns(na[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); + if (Object.keys(na[3]).length === 0) { return null; } + return na; + } else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' || + a[0] === 'ModifyColumn') { + const na = cloneDeep(a); + const colId: string = na[2]; + if (permInfo.getColumnAccess(tableId, colId).read === 'deny') { return null; } + throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); + } else { + // Remaining cases of AddTable, RemoveTable, RenameTable should have + // been handled at the table level. + } + // TODO: handle access to changes in metadata (trigger a reload at least, if + // all else fails). + return a; + } + + /** + * Strip out any denied rows from an action. The action may be rewritten if rows + * become allowed or denied during the action. An action to add newly-allowed + * rows may be included, or an action to remove newly-forbidden rows. The result + * is a list rather than a single action. It may be the empty list. + */ + private async _pruneRows(docSession: OptDocSession, a: DocAction, idx: number): Promise { + // For the moment, only deal with Record-related actions. + // TODO: process table/column schema changes more carefully. + if (isSchemaAction(a)) { return [a]; } + + // Get before/after state for this action. Broadcasts to other users can make use of the + // same state, so we share it (and only compute it if needed). + if (!this._rowSnapshots) { throw new Error('Actions not available'); } + const allRowSnapshots = await this._rowSnapshots.get(); + const [rowsBefore, rowsAfter] = allRowSnapshots[idx]; + + // Figure out which rows were forbidden to this session before this action vs + // after this action. We need to know both so that we can infer the state of the + // client and send the correct change. + const ids = new Set(getRowIdsFromDocAction(a)); + const forbiddenBefores = new Set(this._getForbiddenRows(docSession, rowsBefore, ids)); + const forbiddenAfters = new Set(this._getForbiddenRows(docSession, rowsAfter, ids)); + + /** + * For rows forbidden before and after: just remove them. + * For rows allowed before and after: just leave them unchanged. + * For rows that were allowed before and are now forbidden: + * - strip them from the current action. + * - add a BulkRemoveRecord for them. + * For rows that were forbidden before and are now allowed: + * - remove them from the current action. + * - add a BulkAddRecord for them. + */ + + const removals = new Set(); // rows to remove from current action. + const forceAdds = new Set(); // rows to add, that were previously stripped. + const forceRemoves = new Set(); // rows to remove, that have become forbidden. + for (const id of ids) { + const forbiddenBefore = forbiddenBefores.has(id); + const forbiddenAfter = forbiddenAfters.has(id); + if (!forbiddenBefore && !forbiddenAfter) { continue; } + if (forbiddenBefore && forbiddenAfter) { + removals.add(id); + continue; + } + // If we reach here, then access right to the row changed and we have fancy footwork to do. + if (forbiddenBefore) { + // The row was forbidden and now is allowed. That's trivial if the row was just added. + if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || + a[0] === 'ReplaceTableData' || a[0] === 'TableData') { + continue; + } + // Otherwise, strip the row from the current action. + removals.add(id); + if (a[0] === 'UpdateRecord' || a[0] === 'BulkUpdateRecord') { + // For updates, we need to send the entire row as an add, since the client + // doesn't know anything about it yet. + forceAdds.add(id); + } else { + // Remaining cases are [Bulk]RemoveRecord. + } + } else { + // The row was allowed and now is forbidden. + // If the action is a removal, that is just right. + if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { continue; } + // Otherwise, strip the row from the current action. + removals.add(id); + if (a[0] === 'UpdateRecord' || a[0] === 'BulkUpdateRecord') { + // For updates, we need to remove the entire row. + forceRemoves.add(id); + } else { + // Remaining cases are add-like actions. + } + } + } + + // Execute our cunning plans for DocAction revisions. + const revisedDocActions = [ + this._makeAdditions(rowsAfter, forceAdds), + this._removeRows(a, removals), + this._makeRemovals(rowsAfter, forceRemoves), + ].filter(isObject); + + // Return the results, also applying any cell-level access control. + for (const docAction of revisedDocActions) { + this._filterRowsAndCells(docSession, rowsAfter, docAction); + } + return revisedDocActions; + } + + /** + * Modify action in place, scrubbing any rows and cells to which access is not granted. + */ + private _filterRowsAndCells(docSession: OptDocSession, data: TableDataAction, docAction: DocAction) { + if (docAction && isSchemaAction(docAction)) { + // TODO should filter out metadata about an unavailable column, probably. + return []; + } + const rowCursor = new RecordView(data, 0); const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor}; - const [, tableId, rowIds, colValues] = data; + const [, tableId, , colValues] = docAction; + if (colValues === undefined) { return []; } + const rowIds = getRowIdsFromDocAction(docAction); const toRemove: number[] = []; + + let censorAt: (colId: string, idx: number) => void; + if (Array.isArray(docAction[2])) { + censorAt = (colId, idx) => (colValues as BulkColValues)[colId][idx] = 'CENSORED'; // TODO Pick a suitable value + } else { + censorAt = (colId) => (colValues as ColValues)[colId] = 'CENSORED'; // TODO Pick a suitable value + } + + let getDataIndex: (idx: number) => number = (idx) => idx; + if (docAction !== data) { + const indexes = new Map(data[2].map((rowId, idx) => [rowId, idx])); + getDataIndex = (idx) => indexes.get(rowIds[idx])!; + } + for (let idx = 0; idx < rowIds.length; idx++) { - rowCursor.index = idx; + rowCursor.index = getDataIndex(idx); const rowPermInfo = new PermissionInfo(this, input); // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess. @@ -519,16 +706,54 @@ export class GranularAccess { for (const colId of Object.keys(colValues)) { const colAccess = rowPermInfo.getColumnAccess(tableId, colId); if (colAccess.read !== 'allow') { - colValues[colId][idx] = 'CENSORED'; // TODO Pick a suitable value + censorAt(colId, idx); } } } } + if (toRemove.length > 0) { + if (data === docAction) { + this._removeRowsAt(toRemove, data[2], data[3]); + } else { + // If there are still rows to remove, we must have a logic error. + throw new Error('Unexpected row removal'); + } + } + } + + // Compute which of the row ids supplied are for rows forbidden for this session. + private _getForbiddenRows(docSession: OptDocSession, data: TableDataAction, ids: Set): number[] { + const rowCursor = new RecordView(data, 0); + const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor}; + + const [, tableId, rowIds,] = data; + const toRemove: number[] = []; + for (let idx = 0; idx < rowIds.length; idx++) { + rowCursor.index = idx; + if (!ids.has(rowIds[idx])) { continue; } + + const rowPermInfo = new PermissionInfo(this, input); + // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess. + const rowAccess = rowPermInfo.getTableAccess(tableId); + if (rowAccess.read === 'deny') { + toRemove.push(rowIds[idx]); + } + } + return toRemove; + } + + /** + * Removes the toRemove rows (indexes, not row ids) from the rowIds list and from + * the colValues structure. + */ + private _removeRowsAt(toRemove: number[], rowIds: number[], colValues: BulkColValues|undefined) { if (toRemove.length > 0) { pullAt(rowIds, toRemove); - for (const values of Object.values(colValues)) { - pullAt(values, toRemove); + if (colValues) { + for (const values of Object.values(colValues)) { + pullAt(values, toRemove); + } } } } @@ -588,7 +813,7 @@ export class GranularAccess { if (this._characteristicTables.get(clause.name)) { throw new Error(`User attribute ${clause.name} ignored: duplicate name`); } - const data = await this._fetchQuery({tableId: clause.tableId, filters: {}}); + const data = await this._fetchQueryFromDB({tableId: clause.tableId, filters: {}}); const rowNums = new Map(); const matches = data[3][clause.lookupColId]; for (let i = 0; i < matches.length; i++) { @@ -648,6 +873,45 @@ export class GranularAccess { } return user; } + + /** + * Remove a set of rows from a DocAction. If the DocAction ends up empty, null is returned. + * If the DocAction needs modification, it is copied first - the original is never + * changed. + */ + private _removeRows(a: DocAction, rowIds: Set): DocAction|null { + // If there are no rows, there's nothing to do. + if (isSchemaAction(a)) { return a; } + if (a[0] === 'AddRecord' || a[0] === 'UpdateRecord' || a[0] === 'RemoveRecord') { + return rowIds.has(a[2]) ? null : a; + } + const na = cloneDeep(a); + const [, , oldIds, bulkColValues] = na; + const mask = oldIds.map((id, idx) => rowIds.has(id) && idx || -1).filter(v => v !== -1); + this._removeRowsAt(mask, oldIds, bulkColValues); + if (oldIds.length === 0) { return null; } + return na; + } + + /** + * Make a BulkAddRecord for a set of rows. + */ + private _makeAdditions(data: TableDataAction, rowIds: Set): BulkAddRecord|null { + if (rowIds.size === 0) { return null; } + // TODO: optimize implementation, this does an unnecessary clone. + const notAdded = data[2].filter(id => !rowIds.has(id)); + const partialData = this._removeRows(data, new Set(notAdded)) as TableDataAction|null; + if (partialData === null) { return partialData; } + return ['BulkAddRecord', partialData[1], partialData[2], partialData[3]]; + } + + /** + * Make a BulkRemoveRecord for a set of rows. + */ + private _makeRemovals(data: TableDataAction, rowIds: Set): BulkRemoveRecord|null { + if (rowIds.size === 0) { return null; } + return ['BulkRemoveRecord', getTableId(data), [...rowIds]]; + } } /** @@ -785,3 +1049,7 @@ interface CharacteristicTable { rowNums: Map; data: TableDataAction; } + +function isObject(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/app/server/lib/RowAccess.ts b/app/server/lib/RowAccess.ts new file mode 100644 index 00000000..32e0e6cb --- /dev/null +++ b/app/server/lib/RowAccess.ts @@ -0,0 +1,63 @@ +import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord, DocAction, getTableId, + RemoveRecord, ReplaceTableData, TableDataAction, UpdateRecord } from "app/common/DocActions"; +import { getSetMapValue } from "app/common/gutil"; + +/** + * A little class for tracking pre-existing rows touched by a sequence of DocActions for + * a given table. + */ +class RowIdTracker { + public blockedIds = new Set(); // row ids minted within the DocActions (so NOT pre-existing). + public blocked: boolean = false; // set if all pre-existing rows are wiped/ + public ids = new Set(); // set of pre-existing rows touched. +} + +/** + * This gets a list of pre-existing rows that the DocActions may touch. Returns + * a list of form [tableId, Set{rowId1, rowId2, ...}]. + */ +export function getRelatedRows(docActions: DocAction[]): ReadonlyArray]> { + // Relate tableIds for tables with what they were before the actions, if renamed. + const tableIds = new Map(); + const rowIds = new Map(); + for (const docAction of docActions) { + const currentTableId = getTableId(docAction); + const tableId = tableIds.get(currentTableId) || currentTableId; + if (docAction[0] === 'RenameTable') { + tableIds.delete(currentTableId); + tableIds.set(docAction[2], tableId); + continue; + } + + // tableId will now be that prior to docActions, regardless of renames. + const tracker = getSetMapValue(rowIds, tableId, () => new RowIdTracker()); + + if (docAction[0] === 'RemoveRecord' || docAction[0] === 'BulkRemoveRecord' || + docAction[0] === 'UpdateRecord' || docAction[0] === 'BulkUpdateRecord') { + // All row ids mentioned are external, unless created within this set of DocActions. + if (!tracker.blocked) { + for (const id of getRowIdsFromDocAction(docAction)) { + if (!tracker.blockedIds.has(id)) { tracker.ids.add(id); } + } + } + } else if (docAction[0] === 'AddRecord' || docAction[0] === 'BulkAddRecord') { + // All row ids mentioned are created within this set of DocActions, and are not external. + for (const id of getRowIdsFromDocAction(docAction)) { tracker.blockedIds.add(id); } + } else if (docAction[0] === 'ReplaceTableData' || docAction[0] === 'TableData') { + // No pre-existing rows can be referred to for this table from now on. + tracker.blocked = true; + } + } + + return [...rowIds.entries()].map(([tableId, tracker]) => [tableId, tracker.ids] as const); +} + +/** + * Tiny helper to get the row ids mentioned in a record-related DocAction as a list + * (even if the action is not a bulk action). + */ +export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord | + BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | TableDataAction) { + const ids = docActions[2]; + return (typeof ids === 'number') ? [ids] : ids; +} diff --git a/app/server/lib/Sharing.ts b/app/server/lib/Sharing.ts index 4d30d588..1274b3ac 100644 --- a/app/server/lib/Sharing.ts +++ b/app/server/lib/Sharing.ts @@ -222,6 +222,7 @@ export class Sharing { (branch === Branch.Shared ? this._actionHistory.getNextHubActionNum() : this._actionHistory.getNextLocalActionNum()); + const undo = getEnvContent(sandboxActionBundle.undo); const localActionBundle: LocalActionBundle = { actionNum, // The ActionInfo should go into the envelope that includes all recipients. @@ -236,6 +237,9 @@ export class Sharing { }; this._logActionBundle(`doApplyUserActions (${Branch[branch]})`, localActionBundle); + const docActions = getEnvContent(localActionBundle.stored).concat( + getEnvContent(localActionBundle.calc)); + // TODO Note that the sandbox may produce actions which are not addressed to us (e.g. when we // have EDIT permission without VIEW). These are not sent to the browser or the database. But // today they are reflected in the sandbox. Should we (or the sandbox) immediately undo the @@ -281,11 +285,15 @@ export class Sharing { // date and other changes from external values may count as internal. internal: isCalculate, }); - await this._activeDoc.broadcastDocUpdate(client || null, 'docUserAction', { - actionGroup, - docActions: getEnvContent(localActionBundle.stored).concat( - getEnvContent(localActionBundle.calc)) - }); + await this._activeDoc.beforeBroadcast(docActions, undo); + try { + await this._activeDoc.broadcastDocUpdate(client || null, 'docUserAction', { + actionGroup, + docActions, + }); + } finally { + await this._activeDoc.afterBroadcast(); + } return { actionNum: localActionBundle.actionNum, retValues: sandboxActionBundle.retValues,