2021-07-01 15:15:43 +00:00
|
|
|
import {
|
|
|
|
ActionBundle,
|
|
|
|
ActionInfo,
|
|
|
|
Envelope,
|
|
|
|
getEnvContent,
|
|
|
|
LocalActionBundle,
|
2022-11-24 18:49:45 +00:00
|
|
|
SandboxActionBundle,
|
2021-07-01 15:15:43 +00:00
|
|
|
UserActionBundle
|
|
|
|
} from 'app/common/ActionBundle';
|
2022-11-15 14:37:48 +00:00
|
|
|
import {ApplyUAExtendedOptions} from 'app/common/ActiveDocAPI';
|
2023-11-13 20:59:23 +00:00
|
|
|
import {DocAction, getNumRows, SYSTEM_ACTIONS, UserAction} from 'app/common/DocActions';
|
2021-07-01 15:15:43 +00:00
|
|
|
import {allToken} from 'app/common/sharing';
|
2022-11-24 18:49:45 +00:00
|
|
|
import {GranularAccessForBundle} from 'app/server/lib/GranularAccess';
|
2022-07-04 14:14:55 +00:00
|
|
|
import log from 'app/server/lib/log';
|
2021-10-25 13:29:06 +00:00
|
|
|
import {LogMethods} from "app/server/lib/LogMethods";
|
2020-07-21 13:20:51 +00:00
|
|
|
import {shortDesc} from 'app/server/lib/shortDesc';
|
2022-07-04 14:14:55 +00:00
|
|
|
import assert from 'assert';
|
2020-12-07 21:15:58 +00:00
|
|
|
import {Mutex} from 'async-mutex';
|
2022-07-04 14:14:55 +00:00
|
|
|
import Deque from 'double-ended-queue';
|
2022-11-24 18:49:45 +00:00
|
|
|
import isEqual = require('lodash/isEqual');
|
2021-11-10 19:14:23 +00:00
|
|
|
import {ActionHistory, asActionGroup, getActionUndoInfo} from './ActionHistory';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {ActiveDoc} from './ActiveDoc';
|
2020-12-07 21:15:58 +00:00
|
|
|
import {makeExceptionalDocSession, OptDocSession} from './DocSession';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {WorkCoordinator} from './WorkCoordinator';
|
|
|
|
|
|
|
|
// Describes the request to apply a UserActionBundle. It includes a Client (so that broadcast
|
|
|
|
// message can set `.fromSelf` property), and methods to resolve or reject the promise for when
|
|
|
|
// the action is applied. Note that it may not be immediate in case we are in the middle of
|
|
|
|
// processing hub actions or rebasing.
|
|
|
|
interface UserRequest {
|
|
|
|
action: UserActionBundle;
|
2020-12-15 04:19:38 +00:00
|
|
|
docSession: OptDocSession|null;
|
2020-07-21 13:20:51 +00:00
|
|
|
resolve(result: UserResult): void;
|
|
|
|
reject(err: Error): void;
|
|
|
|
}
|
|
|
|
|
|
|
|
// The result of applying a UserRequest, used to resolve the promise. It includes the retValues
|
|
|
|
// (one for each UserAction in the bundle) and the actionNum of the applied LocalActionBundle.
|
|
|
|
interface UserResult {
|
|
|
|
actionNum: number;
|
|
|
|
retValues: any[];
|
|
|
|
isModification: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Internally-used enum to distinguish if applied actions should be logged as local or shared.
|
|
|
|
enum Branch { Local, Shared }
|
|
|
|
|
2021-10-25 13:29:06 +00:00
|
|
|
// Don't log details of action bundles in production.
|
|
|
|
const LOG_ACTION_BUNDLE = (process.env.NODE_ENV !== 'production');
|
|
|
|
|
2022-11-24 18:49:45 +00:00
|
|
|
interface ApplyResult {
|
|
|
|
/**
|
|
|
|
* Access denied exception if the user does not have permission to apply the action.
|
|
|
|
*/
|
|
|
|
failure?: Error,
|
|
|
|
/**
|
|
|
|
* Result of applying user actions. If there is a failure, it contains result of reverting
|
|
|
|
* those actions that should be persisted (probably extra actions caused by nondeterministic
|
|
|
|
* functions).
|
|
|
|
*/
|
|
|
|
result?: {
|
|
|
|
accessControl: GranularAccessForBundle,
|
|
|
|
bundle: SandboxActionBundle,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
export class Sharing {
|
|
|
|
protected _activeDoc: ActiveDoc;
|
|
|
|
protected _actionHistory: ActionHistory;
|
|
|
|
protected _hubQueue: Deque<ActionBundle> = new Deque();
|
|
|
|
protected _pendingQueue: Deque<UserRequest> = new Deque();
|
|
|
|
protected _workCoordinator: WorkCoordinator;
|
|
|
|
|
2021-10-25 13:29:06 +00:00
|
|
|
private _log = new LogMethods('Sharing ', (s: OptDocSession|null) => this._activeDoc.getLogMeta(s));
|
|
|
|
|
2020-12-07 21:15:58 +00:00
|
|
|
constructor(activeDoc: ActiveDoc, actionHistory: ActionHistory, private _modificationLock: Mutex) {
|
2020-07-21 13:20:51 +00:00
|
|
|
// TODO actionHistory is currently unused (we use activeDoc.actionLog).
|
|
|
|
assert(actionHistory.isInitialized());
|
|
|
|
|
|
|
|
this._activeDoc = activeDoc;
|
|
|
|
this._actionHistory = actionHistory;
|
|
|
|
this._workCoordinator = new WorkCoordinator(() => this._doNextStep());
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns whether this doc is shared. It's shared if and only if HubDocClient is set (though it
|
|
|
|
* may be disconnected).
|
|
|
|
*/
|
|
|
|
public isShared(): boolean { return false; }
|
|
|
|
|
|
|
|
public isSharingActivated(): boolean { return false; }
|
|
|
|
|
|
|
|
/** Returns the instanceId if the doc is shared or null otherwise. */
|
|
|
|
public get instanceId(): string|null { return null; }
|
|
|
|
|
|
|
|
public isOwnEnvelope(recipients: string[]): boolean { return true; }
|
|
|
|
|
|
|
|
public async sendLocalAction(): Promise<void> {
|
|
|
|
throw new Error('sendLocalAction not implemented');
|
|
|
|
}
|
|
|
|
|
|
|
|
public async removeInstanceFromDoc(): Promise<string> {
|
|
|
|
throw new Error('removeInstanceFromDoc not implemented');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The only public interface. This may be called at any time, including while rebasing.
|
|
|
|
* WorkCoordinator ensures that actual work will only happen once other work finishes.
|
|
|
|
*/
|
|
|
|
public addUserAction(userRequest: UserRequest) {
|
|
|
|
this._pendingQueue.push(userRequest);
|
|
|
|
this._workCoordinator.ping();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a promise if there is some work happening, or null if there isn't.
|
|
|
|
private _doNextStep(): Promise<void>|null {
|
|
|
|
if (this._hubQueue.isEmpty()) {
|
|
|
|
if (!this._pendingQueue.isEmpty()) {
|
|
|
|
return this._applyLocalAction();
|
|
|
|
} else if (this.isSharingActivated() && this._actionHistory.haveLocalUnsent()) {
|
|
|
|
return this.sendLocalAction();
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (!this._actionHistory.haveLocalActions()) {
|
|
|
|
return this._applyHubAction();
|
|
|
|
} else {
|
|
|
|
return this._mergeInHubAction();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _applyLocalAction(): Promise<void> {
|
|
|
|
assert(this._hubQueue.isEmpty() && !this._pendingQueue.isEmpty());
|
|
|
|
const userRequest: UserRequest = this._pendingQueue.shift()!;
|
|
|
|
try {
|
2021-05-23 17:43:11 +00:00
|
|
|
const ret = await this._doApplyUserActionBundle(userRequest.action, userRequest.docSession);
|
2020-07-21 13:20:51 +00:00
|
|
|
userRequest.resolve(ret);
|
|
|
|
} catch (e) {
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.warn(userRequest.docSession, "Unable to apply action...", e);
|
2020-07-21 13:20:51 +00:00
|
|
|
userRequest.reject(e);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _applyHubAction(): Promise<void> {
|
|
|
|
assert(!this._hubQueue.isEmpty() && !this._actionHistory.haveLocalActions());
|
|
|
|
const action: ActionBundle = this._hubQueue.shift()!;
|
|
|
|
try {
|
2021-05-23 17:43:11 +00:00
|
|
|
await this._doApplySharedActionBundle(action);
|
2020-07-21 13:20:51 +00:00
|
|
|
} catch (e) {
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.error(null, "Unable to apply hub action... skipping");
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _mergeInHubAction(): Promise<void> {
|
|
|
|
assert(!this._hubQueue.isEmpty() && this._actionHistory.haveLocalActions());
|
|
|
|
|
|
|
|
const action: ActionBundle = this._hubQueue.peekFront()!;
|
|
|
|
try {
|
|
|
|
const accepted = await this._actionHistory.acceptNextSharedAction(action.actionHash);
|
|
|
|
if (accepted) {
|
|
|
|
this._hubQueue.shift();
|
|
|
|
} else {
|
|
|
|
await this._rebaseLocalActions();
|
|
|
|
}
|
|
|
|
} catch (e) {
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.error(null, "Unable to apply hub action... skipping");
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _rebaseLocalActions(): Promise<void> {
|
|
|
|
const rebaseQueue: Deque<UserActionBundle> = new Deque<UserActionBundle>();
|
|
|
|
try {
|
2021-05-23 17:43:11 +00:00
|
|
|
this._createCheckpoint();
|
2020-07-21 13:20:51 +00:00
|
|
|
const actions: LocalActionBundle[] = await this._actionHistory.fetchAllLocal();
|
|
|
|
assert(actions.length > 0);
|
2021-05-23 17:43:11 +00:00
|
|
|
await this._doApplyUserActionBundle(this._createUndo(actions), null);
|
2020-07-21 13:20:51 +00:00
|
|
|
rebaseQueue.push(...actions.map((a) => getUserActionBundle(a)));
|
|
|
|
await this._actionHistory.clearLocalActions();
|
|
|
|
} catch (e) {
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.error(null, "Can't undo local actions; sharing is off");
|
2021-05-23 17:43:11 +00:00
|
|
|
this._rollbackToCheckpoint();
|
2020-07-21 13:20:51 +00:00
|
|
|
// TODO this.disconnect();
|
|
|
|
// TODO errorState = true;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
assert(!this._actionHistory.haveLocalActions());
|
|
|
|
|
|
|
|
while (!this._hubQueue.isEmpty()) {
|
|
|
|
await this._applyHubAction();
|
|
|
|
}
|
|
|
|
const rebaseFailures: Array<[UserActionBundle, UserActionBundle]> = [];
|
|
|
|
while (!rebaseQueue.isEmpty()) {
|
|
|
|
const action: UserActionBundle = rebaseQueue.shift()!;
|
|
|
|
const adjusted: UserActionBundle = this._mergeAdjust(action);
|
|
|
|
try {
|
2021-05-23 17:43:11 +00:00
|
|
|
await this._doApplyUserActionBundle(adjusted, null);
|
2020-07-21 13:20:51 +00:00
|
|
|
} catch (e) {
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.warn(null, "Unable to apply rebased action...");
|
2020-07-21 13:20:51 +00:00
|
|
|
rebaseFailures.push([action, adjusted]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (rebaseFailures.length > 0) {
|
2021-05-23 17:43:11 +00:00
|
|
|
this._createBackupAtCheckpoint();
|
2020-07-21 13:20:51 +00:00
|
|
|
// TODO we should notify the user too.
|
2021-10-25 13:29:06 +00:00
|
|
|
this._log.error(null, 'Rebase failed to reapply some of your actions, backup of local at...');
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2021-05-23 17:43:11 +00:00
|
|
|
this._releaseCheckpoint();
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// ======================================================================
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
private _doApplySharedActionBundle(action: ActionBundle): Promise<UserResult> {
|
2020-07-21 13:20:51 +00:00
|
|
|
const userActions: UserAction[] = [
|
|
|
|
['ApplyDocActions', action.stored.map(envContent => envContent[1])]
|
|
|
|
];
|
2022-11-15 14:37:48 +00:00
|
|
|
return this._doApplyUserActions(action.info[1], userActions, Branch.Shared, null, null);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
private _doApplyUserActionBundle(action: UserActionBundle, docSession: OptDocSession|null): Promise<UserResult> {
|
2022-11-15 14:37:48 +00:00
|
|
|
return this._doApplyUserActions(action.info, action.userActions, Branch.Local, docSession, action.options || null);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2021-05-23 17:43:11 +00:00
|
|
|
private async _doApplyUserActions(info: ActionInfo, userActions: UserAction[],
|
2022-11-15 14:37:48 +00:00
|
|
|
branch: Branch, docSession: OptDocSession|null,
|
|
|
|
options: ApplyUAExtendedOptions|null): Promise<UserResult> {
|
2020-12-07 21:15:58 +00:00
|
|
|
const client = docSession && docSession.client;
|
|
|
|
|
2021-01-27 23:03:30 +00:00
|
|
|
if (docSession?.linkId) {
|
|
|
|
info.linkId = docSession.linkId;
|
|
|
|
}
|
|
|
|
|
2022-11-24 18:49:45 +00:00
|
|
|
const {result, failure} =
|
2022-11-15 14:37:48 +00:00
|
|
|
await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions, options));
|
2020-12-07 21:15:58 +00:00
|
|
|
|
2022-11-24 18:49:45 +00:00
|
|
|
// ACL check failed, and we don't have anything to save. Just rethrow the error.
|
|
|
|
if (failure && !result) {
|
|
|
|
throw failure;
|
|
|
|
}
|
|
|
|
|
|
|
|
assert(result, "result should be defined if failure is not");
|
|
|
|
const sandboxActionBundle = result.bundle;
|
|
|
|
const accessControl = result.accessControl;
|
|
|
|
const undo = getEnvContent(result.bundle.undo);
|
|
|
|
|
2020-11-30 15:50:00 +00:00
|
|
|
try {
|
2021-04-15 15:50:00 +00:00
|
|
|
|
2023-11-13 20:59:23 +00:00
|
|
|
const isCalculate = (userActions.length === 1 && SYSTEM_ACTIONS.has(userActions[0][0] as string));
|
2022-04-22 13:21:25 +00:00
|
|
|
// `internal` is true if users shouldn't be able to undo the actions. Applies to:
|
2022-04-25 20:31:23 +00:00
|
|
|
// - Calculate/UpdateCurrentTime because it's not considered as performed by a particular client.
|
2022-04-22 13:21:25 +00:00
|
|
|
// - Adding attachment metadata when uploading attachments,
|
|
|
|
// because then the attachment file may get hard-deleted and redo won't work properly.
|
2022-11-24 18:49:45 +00:00
|
|
|
// - Action was rejected but it had some side effects (e.g. NOW() or UUID() formulas).
|
|
|
|
const internal =
|
|
|
|
isCalculate ||
|
|
|
|
userActions.every(a => a[0] === "AddRecord" && a[1] === "_grist_Attachments") ||
|
|
|
|
!!failure;
|
2022-04-22 13:21:25 +00:00
|
|
|
|
|
|
|
// A trivial action does not merit allocating an actionNum,
|
|
|
|
// logging, and sharing. It's best not to log the
|
|
|
|
// action that calculates formula values when the document is opened cold
|
|
|
|
// (without cached ActiveDoc) if it doesn't change anything - otherwise we'll end up with spam
|
|
|
|
// log entries for each time the document is opened cold.
|
|
|
|
const trivial = internal && sandboxActionBundle.stored.length === 0;
|
2021-04-15 15:50:00 +00:00
|
|
|
|
|
|
|
const actionNum = trivial ? 0 :
|
|
|
|
(branch === Branch.Shared ? this._actionHistory.getNextHubActionNum() :
|
|
|
|
this._actionHistory.getNextLocalActionNum());
|
|
|
|
|
|
|
|
const localActionBundle: LocalActionBundle = {
|
|
|
|
actionNum,
|
|
|
|
// The ActionInfo should go into the envelope that includes all recipients.
|
|
|
|
info: [findOrAddAllEnvelope(sandboxActionBundle.envelopes), info],
|
|
|
|
envelopes: sandboxActionBundle.envelopes,
|
|
|
|
stored: sandboxActionBundle.stored,
|
|
|
|
calc: sandboxActionBundle.calc,
|
|
|
|
undo,
|
|
|
|
userActions,
|
|
|
|
actionHash: null, // Gets set below by _actionHistory.recordNext...
|
|
|
|
parentActionHash: null, // Gets set below by _actionHistory.recordNext...
|
|
|
|
};
|
2021-10-25 13:29:06 +00:00
|
|
|
|
2022-04-08 18:00:43 +00:00
|
|
|
const altSessionId = client?.getAltSessionId();
|
2021-10-25 13:29:06 +00:00
|
|
|
const logMeta = {
|
|
|
|
actionNum,
|
|
|
|
linkId: info.linkId,
|
|
|
|
otherId: info.otherId,
|
|
|
|
numDocActions: localActionBundle.stored.length,
|
|
|
|
numRows: localActionBundle.stored.reduce((n, env) => n + getNumRows(env[1]), 0),
|
|
|
|
author: info.user,
|
2022-04-08 18:00:43 +00:00
|
|
|
...(altSessionId ? {session: altSessionId}: {}),
|
2021-10-25 13:29:06 +00:00
|
|
|
};
|
|
|
|
this._log.rawLog('debug', docSession, '_doApplyUserActions', logMeta);
|
|
|
|
if (LOG_ACTION_BUNDLE) {
|
|
|
|
this._logActionBundle(`_doApplyUserActions (${Branch[branch]})`, localActionBundle);
|
|
|
|
}
|
2021-04-15 15:50:00 +00:00
|
|
|
|
|
|
|
// 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
|
|
|
|
// full change, and then redo only the actions addressed to ourselves? Let's cross that bridge
|
|
|
|
// when we come to it. For now we only log skipped envelopes as "alien" in _logActionBundle().
|
|
|
|
const ownActionBundle: LocalActionBundle = this._filterOwnActions(localActionBundle);
|
|
|
|
|
2021-09-15 23:35:21 +00:00
|
|
|
// If the document has shut down in the meantime, and this was just a "Calculate" action,
|
|
|
|
// return a trivial result. This is just to reduce noisy warnings in migration tests.
|
|
|
|
if (this._activeDoc.isShuttingDown && isCalculate) {
|
|
|
|
return {
|
|
|
|
actionNum: localActionBundle.actionNum,
|
|
|
|
retValues: [],
|
|
|
|
isModification: false
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-04-15 15:50:00 +00:00
|
|
|
// Apply the action to the database, and record in the action log.
|
|
|
|
if (!trivial) {
|
|
|
|
await this._activeDoc.docStorage.execTransaction(async () => {
|
(core) add a `yarn run cli` tool, and add a `sqlite gristify` option
Summary:
This adds rudimentary support for opening certain SQLite files in Grist.
If you have a file such as `landing.db` in Grist, you can convert it to Grist format by doing (either in monorepo or grist-core):
```
yarn run cli -h
yarn run cli sqlite -h
yarn run cli sqlite gristify landing.db
```
The file is now openable by Grist. To actually do so with the regular Grist server, you'll need to either import it, or convert some doc you don't care about in the `samples/` directory to be a soft link to it (and then force a reload).
This implementation is a rudimentary experiment. Here are some awkwardnesses:
* Only tables that happen to have a column called `id`, and where the column happens to be an integer, can be opened directly with Grist as it is today. That could be generalized, but it looked more than a Gristathon's worth of work, so I instead used SQLite views.
* Grist will handle tables that start with an uncapitalized letter a bit erratically. You can successfully add columns, for example, but removing them will cause sadness - Grist will rename the table in a confused way.
* I didn't attempt to deal with column names with spaces etc (though views could deal with those).
* I haven't tried to do any fancy type mapping.
* Columns with constraints can make adding new rows impossible in Grist, since Grist requires that a row can be added with just a single cell set.
Test Plan: added small test
Reviewers: georgegevoian
Reviewed By: georgegevoian
Differential Revision: https://phab.getgrist.com/D3502
2022-07-14 09:32:06 +00:00
|
|
|
await this._activeDoc.applyStoredActionsToDocStorage(getEnvContent(ownActionBundle.stored));
|
2021-04-15 15:50:00 +00:00
|
|
|
if (this.isShared() && branch === Branch.Local) {
|
|
|
|
// this call will compute an actionHash for localActionBundle
|
|
|
|
await this._actionHistory.recordNextLocalUnsent(localActionBundle);
|
|
|
|
} else {
|
|
|
|
// Before sharing is enabled, actions are immediately marked as "shared" (as if accepted
|
|
|
|
// by the hub). The alternative of keeping actions on the "local" branch until sharing is
|
|
|
|
// enabled is less suitable, because such actions could have empty envelopes, and cannot
|
|
|
|
// be shared. Once sharing is enabled, we would share a snapshot at that time.
|
|
|
|
await this._actionHistory.recordNextShared(localActionBundle);
|
|
|
|
}
|
2022-04-22 13:21:25 +00:00
|
|
|
if (client && client.clientId && !internal) {
|
2021-09-29 13:57:55 +00:00
|
|
|
this._actionHistory.setActionUndoInfo(
|
|
|
|
localActionBundle.actionHash!,
|
|
|
|
getActionUndoInfo(localActionBundle, client.clientId, sandboxActionBundle.retValues));
|
2021-04-15 15:50:00 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
await this._activeDoc.processActionBundle(ownActionBundle);
|
|
|
|
|
2021-11-10 19:14:23 +00:00
|
|
|
const actionSummary = await this._activeDoc.handleTriggers(localActionBundle);
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
|
2022-03-30 11:45:37 +00:00
|
|
|
await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession);
|
|
|
|
|
2021-04-15 15:50:00 +00:00
|
|
|
// Broadcast the action to connected browsers.
|
|
|
|
const actionGroup = asActionGroup(this._actionHistory, localActionBundle, {
|
2021-09-29 13:57:55 +00:00
|
|
|
clientId: client?.clientId,
|
2021-04-15 15:50:00 +00:00
|
|
|
retValues: sandboxActionBundle.retValues,
|
2022-04-22 13:21:25 +00:00
|
|
|
internal,
|
2021-04-15 15:50:00 +00:00
|
|
|
});
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
actionGroup.actionSummary = actionSummary;
|
2021-03-01 16:51:30 +00:00
|
|
|
await accessControl.appliedBundle();
|
2022-05-16 17:41:12 +00:00
|
|
|
await accessControl.sendDocUpdateForBundle(actionGroup, this._activeDoc.getDocUsageSummary());
|
2022-11-24 18:49:45 +00:00
|
|
|
// If the action was rejected, throw an exception, by this point data-engine should be in
|
|
|
|
// sync with the database, and everyone should have the same view of the document.
|
|
|
|
if (failure) {
|
|
|
|
throw failure;
|
|
|
|
}
|
2021-04-15 15:50:00 +00:00
|
|
|
if (docSession) {
|
|
|
|
docSession.linkId = docSession.shouldBundleActions ? localActionBundle.actionNum : 0;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
actionNum: localActionBundle.actionNum,
|
|
|
|
retValues: sandboxActionBundle.retValues,
|
|
|
|
isModification: sandboxActionBundle.stored.length > 0
|
|
|
|
};
|
2020-11-30 15:50:00 +00:00
|
|
|
} finally {
|
2022-02-19 09:46:49 +00:00
|
|
|
// Make sure the bundle is marked as complete, even if some miscellaneous error occurred.
|
2021-03-01 16:51:30 +00:00
|
|
|
await accessControl.finishedBundle();
|
2020-11-30 15:50:00 +00:00
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _mergeAdjust(action: UserActionBundle): UserActionBundle {
|
|
|
|
// TODO: This is where we adjust actions after rebase, e.g. add delta to rowIds and such.
|
|
|
|
return action;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a UserActionBundle with a single 'ApplyUndoActions' action, which combines the undo
|
|
|
|
* actions addressed to ourselves from all of the passed-in LocalActionBundles.
|
|
|
|
*/
|
|
|
|
private _createUndo(localActions: LocalActionBundle[]): UserActionBundle {
|
|
|
|
assert(localActions.length > 0);
|
|
|
|
const undo: DocAction[] = [];
|
|
|
|
for (const local of localActions) {
|
|
|
|
undo.push(...local.undo);
|
|
|
|
}
|
|
|
|
const first = localActions[0];
|
|
|
|
return {
|
|
|
|
info: {
|
|
|
|
time: Date.now(),
|
|
|
|
user: first.info[1].user,
|
|
|
|
inst: first.info[1].inst,
|
|
|
|
desc: "UNDO BEFORE REBASE",
|
|
|
|
otherId: 0,
|
|
|
|
linkId: 0,
|
|
|
|
},
|
|
|
|
userActions: [['ApplyUndoActions', undo]]
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Our beautiful little checkpointing interface, used to handle errors during rebase.
|
2021-05-23 17:43:11 +00:00
|
|
|
private _createCheckpoint() { /* TODO */ }
|
|
|
|
private _releaseCheckpoint() { /* TODO */ }
|
|
|
|
private _rollbackToCheckpoint() { /* TODO */ }
|
|
|
|
private _createBackupAtCheckpoint() { /* TODO */ }
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Reduces a LocalActionBundle down to only those actions addressed to ourselves.
|
|
|
|
*/
|
|
|
|
private _filterOwnActions(localActionBundle: LocalActionBundle): LocalActionBundle {
|
|
|
|
const includeEnv: boolean[] = localActionBundle.envelopes.map(
|
|
|
|
(e) => this.isOwnEnvelope(e.recipients));
|
|
|
|
|
|
|
|
return Object.assign({}, localActionBundle, {
|
|
|
|
stored: localActionBundle.stored.filter((ea) => includeEnv[ea[0]]),
|
|
|
|
calc: localActionBundle.calc.filter((ea) => includeEnv[ea[0]]),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Log an action bundle to the debug log. */
|
|
|
|
private _logActionBundle(prefix: string, actionBundle: ActionBundle) {
|
|
|
|
const includeEnv = actionBundle.envelopes.map((e) => this.isOwnEnvelope(e.recipients));
|
|
|
|
actionBundle.stored.forEach((envAction, i) =>
|
|
|
|
log.debug("%s: stored #%s [%s%s]: %s", prefix, i, envAction[0],
|
|
|
|
(includeEnv[envAction[0]] ? "" : " alien"),
|
|
|
|
shortDesc(envAction[1])));
|
|
|
|
actionBundle.calc.forEach((envAction, i) =>
|
|
|
|
log.debug("%s: calc #%s [%s%s]: %s", prefix, i, envAction[0],
|
|
|
|
(includeEnv[envAction[0]] ? "" : " alien"),
|
|
|
|
shortDesc(envAction[1])));
|
|
|
|
}
|
2020-12-07 21:15:58 +00:00
|
|
|
|
2022-11-24 18:49:45 +00:00
|
|
|
private async _applyActionsToDataEngine(
|
|
|
|
docSession: OptDocSession|null,
|
|
|
|
userActions: UserAction[],
|
|
|
|
options: ApplyUAExtendedOptions|null): Promise<ApplyResult> {
|
|
|
|
const applyResult = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);
|
|
|
|
let accessControl = this._startGranularAccessForBundle(docSession, applyResult, userActions, options);
|
2020-12-07 21:15:58 +00:00
|
|
|
try {
|
|
|
|
// TODO: see if any of the code paths that have no docSession are relevant outside
|
|
|
|
// of tests.
|
2021-03-01 16:51:30 +00:00
|
|
|
await accessControl.canApplyBundle();
|
2022-11-24 18:49:45 +00:00
|
|
|
return { result : {bundle: applyResult, accessControl}};
|
|
|
|
} catch (applyExc) {
|
2021-04-15 15:50:00 +00:00
|
|
|
try {
|
2022-11-24 18:49:45 +00:00
|
|
|
// We can't apply those actions, so we need to revert them.
|
|
|
|
const undoResult = await this._activeDoc.applyActionsToDataEngine(docSession, [
|
|
|
|
['ApplyUndoActions', getEnvContent(applyResult.undo)]
|
|
|
|
]);
|
|
|
|
|
|
|
|
// We managed to reject and undo actions in the data-engine. Now we need to calculate if we have any extra
|
|
|
|
// actions generated by the undo (it can happen for nondeterministic formulas). If we have them, we will need to
|
|
|
|
// test if they pass ACL check and persist them in the database in order to keep the data engine in sync with
|
|
|
|
// the database. If we have any extra actions, we will simulate that only those actions were applied and return
|
|
|
|
// fake bundle together with the access failure. If we don't have any extra actions, we will just return the
|
|
|
|
// failure.
|
|
|
|
const extraBundle = this._createExtraBundle(undoResult, getEnvContent(applyResult.undo));
|
|
|
|
|
|
|
|
// If we have the same number of actions and they are equal, we can assume that the data-engine is in sync.
|
|
|
|
if (!extraBundle) {
|
|
|
|
// We stored what we send, we don't have any extra actions to save, we can just return the failure.
|
|
|
|
await accessControl.finishedBundle();
|
|
|
|
return { failure: applyExc };
|
|
|
|
}
|
|
|
|
|
|
|
|
// We have some extra actions, so we need to prepare a fake bundle (only with the extra actions) and
|
|
|
|
// return the failure, so the caller can persist the extra actions and report the failure.
|
|
|
|
// Finish the access control for the origBundle.
|
2021-04-15 15:50:00 +00:00
|
|
|
await accessControl.finishedBundle();
|
2022-11-24 18:49:45 +00:00
|
|
|
// Start a new one. We assume that all actions are indirect, so this is basically a no-op, but we are doing it
|
|
|
|
// nevertheless to make sure they pass access control.
|
|
|
|
// NOTE: we assume that docActions can be used as userActions here. This is not always the case (as we might
|
|
|
|
// have a special logic that targets UserActions directly), but in this scenario, the extra bundle should
|
|
|
|
// contain only indirect data actions (mostly UpdateRecord) that are produced by comparing UserTables in the
|
|
|
|
// data-engine.
|
|
|
|
accessControl = this._startGranularAccessForBundle(docSession, extraBundle, extraBundle.stored, options);
|
|
|
|
// Check if the extra bundle is allowed.
|
|
|
|
await accessControl.canApplyBundle();
|
|
|
|
// We are ok, we can store extra actions and report back the exception.
|
|
|
|
return {result: {bundle: extraBundle, accessControl}, failure: applyExc};
|
|
|
|
} catch(rollbackExc) {
|
|
|
|
this._log.error(docSession, "Failed to apply undo of rejected action", rollbackExc.message);
|
|
|
|
await accessControl.finishedBundle();
|
2023-04-12 16:18:48 +00:00
|
|
|
this._log.debug(docSession, "Sharing._applyActionsToDataEngine starting ActiveDoc.shutdown");
|
2022-11-24 18:49:45 +00:00
|
|
|
await this._activeDoc.shutdown();
|
|
|
|
throw rollbackExc;
|
2021-04-15 15:50:00 +00:00
|
|
|
}
|
2020-12-07 21:15:58 +00:00
|
|
|
}
|
2022-11-24 18:49:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _startGranularAccessForBundle(
|
|
|
|
docSession: OptDocSession|null,
|
|
|
|
bundle: SandboxActionBundle,
|
|
|
|
userActions: UserAction[],
|
|
|
|
options: ApplyUAExtendedOptions|null
|
|
|
|
) {
|
|
|
|
const undo = getEnvContent(bundle.undo);
|
|
|
|
const docActions = getEnvContent(bundle.stored).concat(getEnvContent(bundle.calc));
|
|
|
|
const isDirect = getEnvContent(bundle.direct);
|
|
|
|
return this._activeDoc.getGranularAccessForBundle(
|
|
|
|
docSession || makeExceptionalDocSession('share'),
|
|
|
|
docActions,
|
|
|
|
undo,
|
|
|
|
userActions,
|
|
|
|
isDirect,
|
|
|
|
options
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Calculates the extra bundle that effectively was applied to the data engine.
|
|
|
|
* @param undoResult Result of applying undo actions to the data engine.
|
|
|
|
* @param undoSource Actions that were sent to perform the undo.
|
|
|
|
* @returns A bundle with extra actions that were applied to the data engine or null if there are no extra actions.
|
|
|
|
*/
|
|
|
|
private _createExtraBundle(undoResult: SandboxActionBundle, undoSource: DocAction[]): SandboxActionBundle|null {
|
|
|
|
// First check that what we sent is what we stored, since those are undo actions, they should be identical. We
|
|
|
|
// need to reverse the order of undo actions (they are reversed in data-engine by ApplyUndoActions)
|
|
|
|
const sent = undoSource.slice().reverse();
|
|
|
|
const storedHead = getEnvContent(undoResult.stored).slice(0, sent.length);
|
|
|
|
// If we have less actions or they are not equal, we need need to fail immediately, this was not expected.
|
|
|
|
if (undoResult.stored.length < undoSource.length) {
|
|
|
|
throw new Error("There are less actions stored then expected");
|
|
|
|
}
|
|
|
|
if (!storedHead.every((action, i) => isEqual(action, sent[i]))) {
|
|
|
|
throw new Error("Stored actions differ from sent actions");
|
|
|
|
}
|
|
|
|
// If we have the same number of actions and they are equal there is nothing to return.
|
|
|
|
if (undoResult.stored.length === undoSource.length) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
// Create a fake bundle simulating only those extra actions that were applied.
|
|
|
|
return {
|
|
|
|
envelopes: undoResult.envelopes, // Envelops are not supported, so we can use the first one (which is always #ALL)
|
|
|
|
stored: undoResult.stored.slice(undoSource.length),
|
|
|
|
// All actions are treated as direct, we want to perform ACL check on them.
|
|
|
|
direct: undoResult.direct.slice(undoSource.length),
|
|
|
|
calc: [], // Calc actions are also not used anymore.
|
|
|
|
undo: [], // We won't allow to undo this one.
|
|
|
|
retValues: undoResult.retValues.slice(undoSource.length),
|
|
|
|
rowCount: undoResult.rowCount
|
|
|
|
};
|
2020-12-07 21:15:58 +00:00
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2022-11-24 18:49:45 +00:00
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Returns the index of the envelope containing the '#ALL' recipient, adding such an envelope to
|
|
|
|
* the provided array if it wasn't already there.
|
|
|
|
*/
|
|
|
|
export function findOrAddAllEnvelope(envelopes: Envelope[]): number {
|
|
|
|
const i = envelopes.findIndex(e => e.recipients.includes(allToken));
|
|
|
|
if (i >= 0) { return i; }
|
|
|
|
envelopes.push({recipients: [allToken]});
|
|
|
|
return envelopes.length - 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Extract a UserActionBundle from a LocalActionBundle, which contains a superset of data.
|
|
|
|
*/
|
|
|
|
function getUserActionBundle(localAction: LocalActionBundle): UserActionBundle {
|
|
|
|
return {
|
|
|
|
info: localAction.info[1],
|
|
|
|
userActions: localAction.userActions
|
|
|
|
};
|
|
|
|
}
|