(core) Adds a UI panel for managing webhooks

Summary:
This adds a UI panel for managing webhooks. Work started by Cyprien Pindat. You can find the UI on a document's settings page. Main changes relative to Cyprien's demo:

  * Changed behavior of virtual table to be more consistent with the rest of Grist, by factoring out part of the implementation of on-demand tables.
  * Cell values that would create an error can now be denied and reverted (as for the rest of Grist).
  * Changes made by other users are integrated in a sane way.
  * Basic undo/redo support is added using the regular undo/redo stack.
  * The table list in the drop-down is now updated if schema changes.
  * Added a notification from back-end when webhook status is updated so constant polling isn't needed to support multi-user operation.
  *  Factored out webhook specific logic from general virtual table support.
  * Made a bunch of fixes to various broken behavior.
  * Added tests.

The code remains somewhat unpolished, and behavior in the presence of errors is imperfect in general but may be adequate for this case.

I assume that we'll soon be lifting the restriction on the set of domains that are supported for webhooks - otherwise we'd want to provide some friendly way to discover that list of supported domains rather than just throwing an error.

I don't actually know a lot about how the front-end works - it looks like tables/columns/fields/sections can be safely added if they have string ids that won't collide with bone fide numeric ids from the back end. Sneaky.

Contains a migration, so needs an extra reviewer for that.

Test Plan: added tests

Reviewers: jarek, dsagal

Reviewed By: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D3856
This commit is contained in:
Paul Fitzpatrick
2023-05-08 18:06:24 -04:00
parent 5e9f2e06ea
commit 603238e966
37 changed files with 1698 additions and 376 deletions

View File

@@ -19,7 +19,7 @@ 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 './ActionSummary';
import {summarizeAction} from 'app/common/ActionSummarizer';
export interface ActionGroupOptions {
// If set, inspect the action in detail in order to include a summary of

View File

@@ -1,458 +0,0 @@
import {getEnvContent, LocalActionBundle} from 'app/common/ActionBundle';
import {ActionSummary, ColumnDelta, createEmptyActionSummary,
createEmptyTableDelta, defunctTableName, LabelDelta, TableDelta} from 'app/common/ActionSummary';
import {DocAction} from 'app/common/DocActions';
import * as Action from 'app/common/DocActions';
import {arrayExtend} from 'app/common/gutil';
import {CellDelta} from 'app/common/TabularDiff';
import fromPairs = require('lodash/fromPairs');
import keyBy = require('lodash/keyBy');
import sortBy = require('lodash/sortBy');
import toPairs = require('lodash/toPairs');
import values = require('lodash/values');
/**
* The default maximum number of rows in a single bulk change that will be recorded
* individually. Bulk changes that touch more than this number of rows
* will be summarized only by the number of rows touched.
*/
const MAXIMUM_INLINE_ROWS = 10;
/**
* Options when producing an action summary.
*/
export interface ActionSummaryOptions {
maximumInlineRows?: number; // Overrides the maximum number of rows in a
// single bulk change that will be recorded individually.
alwaysPreserveColIds?: string[]; // If set, all cells in these columns are preserved
// regardless of maximumInlineRows setting.
}
class ActionSummarizer {
constructor(private _options?: ActionSummaryOptions) {}
/** add information about an action based on the forward direction */
public addForwardAction(summary: ActionSummary, act: DocAction) {
const tableId = act[1];
if (Action.isAddTable(act)) {
summary.tableRenames.push([null, tableId]);
for (const info of act[2]) {
this._forTable(summary, tableId).columnRenames.push([null, info.id]);
}
} else if (Action.isRenameTable(act)) {
this._addRename(summary.tableRenames, [tableId, act[2]]);
} else if (Action.isRenameColumn(act)) {
this._addRename(this._forTable(summary, tableId).columnRenames, [act[2], act[3]]);
} else if (Action.isAddColumn(act)) {
this._forTable(summary, tableId).columnRenames.push([null, act[2]]);
} else if (Action.isRemoveColumn(act)) {
this._forTable(summary, tableId).columnRenames.push([act[2], null]);
} else if (Action.isAddRecord(act)) {
const td = this._forTable(summary, tableId);
td.addRows.push(act[2]);
this._addRow(td, act[2], act[3], 1);
} else if (Action.isUpdateRecord(act)) {
const td = this._forTable(summary, tableId);
td.updateRows.push(act[2]);
this._addRow(td, act[2], act[3], 1);
} else if (Action.isBulkAddRecord(act)) {
const td = this._forTable(summary, tableId);
arrayExtend(td.addRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 1);
} else if (Action.isBulkUpdateRecord(act)) {
const td = this._forTable(summary, tableId);
arrayExtend(td.updateRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 1);
} else if (Action.isReplaceTableData(act)) {
const td = this._forTable(summary, tableId);
arrayExtend(td.addRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 1);
}
}
/** add information about an action based on undo information */
public addReverseAction(summary: ActionSummary, act: DocAction) {
const tableId = act[1];
if (Action.isAddTable(act)) { // undoing, so this is a table removal
summary.tableRenames.push([tableId, null]);
for (const info of act[2]) {
this._forTable(summary, tableId).columnRenames.push([info.id, null]);
}
} else if (Action.isAddRecord(act)) { // undoing, so this is a record removal
const td = this._forTable(summary, tableId);
td.removeRows.push(act[2]);
this._addRow(td, act[2], act[3], 0);
} else if (Action.isUpdateRecord(act)) { // undoing, so this is reversal of a record update
const td = this._forTable(summary, tableId);
this._addRow(td, act[2], act[3], 0);
} else if (Action.isBulkAddRecord(act)) { // undoing, this may be reversing a table delete
const td = this._forTable(summary, tableId);
arrayExtend(td.removeRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 0);
} else if (Action.isBulkUpdateRecord(act)) { // undoing, so this is reversal of a bulk record update
const td = this._forTable(summary, tableId);
arrayExtend(td.updateRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 0);
} else if (Action.isRenameTable(act)) { // undoing - sometimes renames only in undo info
this._addRename(summary.tableRenames, [act[2], tableId]);
} else if (Action.isRenameColumn(act)) { // undoing - sometimes renames only in undo info
this._addRename(this._forTable(summary, tableId).columnRenames, [act[3], act[2]]);
} else if (Action.isReplaceTableData(act)) { // undoing
const td = this._forTable(summary, tableId);
arrayExtend(td.removeRows, act[2]);
this._addRows(tableId, td, act[2], act[3], 0);
}
}
/** helper function to access summary changes for a specific table by name */
private _forTable(summary: ActionSummary, tableId: string): TableDelta {
return summary.tableDeltas[tableId] || (summary.tableDeltas[tableId] = createEmptyTableDelta());
}
/** helper function to access summary changes for a specific cell by rowId and colId */
private _forCell(td: TableDelta, rowId: number, colId: string): CellDelta {
const cd = td.columnDeltas[colId] || (td.columnDeltas[colId] = {});
return cd[rowId] || (cd[rowId] = [null, null]);
}
/**
* helper function to store detailed cell changes for a single row.
* Direction parameter is 0 if values are prior values of cells, 1 if values are new values.
*/
private _addRow(td: TableDelta, rowId: number, colValues: Action.ColValues,
direction: 0|1) {
for (const [colId, colChanges] of toPairs(colValues)) {
const cell = this._forCell(td, rowId, colId);
cell[direction] = [colChanges];
}
}
/** helper function to store detailed cell changes for a set of rows */
private _addRows(tableId: string, td: TableDelta, rowIds: number[],
colValues: Action.BulkColValues, direction: 0|1) {
const maximumInlineRows = this._options?.maximumInlineRows || MAXIMUM_INLINE_ROWS;
const limitRows: boolean = rowIds.length > maximumInlineRows && !tableId.startsWith("_grist_");
let selectedRows: Array<[number, number]> = [];
if (limitRows) {
// if many rows, just take some from start and one from end as examples
selectedRows = [...rowIds.slice(0, maximumInlineRows - 1).entries()];
selectedRows.push([rowIds.length - 1, rowIds[rowIds.length - 1]]);
}
const alwaysPreserveColIds = new Set(this._options?.alwaysPreserveColIds || []);
for (const [colId, colChanges] of toPairs(colValues)) {
const addCellToSummary = (rowId: number, idx: number) => {
const cell = this._forCell(td, rowId, colId);
cell[direction] = [colChanges[idx]];
};
if (!limitRows || alwaysPreserveColIds.has(colId)) {
rowIds.forEach(addCellToSummary);
} else {
selectedRows.forEach(([idx, rowId]) => addCellToSummary(rowId, idx));
}
}
}
/** add a rename to a list, avoiding duplicates */
private _addRename(renames: LabelDelta[], rename: LabelDelta) {
if (renames.find(r => r[0] === rename[0] && r[1] === rename[1])) { return; }
renames.push(rename);
}
}
/**
* Summarize the tabular changes that a LocalActionBundle results in, in a form
* that will be suitable for composition.
*/
export function summarizeAction(body: LocalActionBundle, options?: ActionSummaryOptions): ActionSummary {
const summarizer = new ActionSummarizer(options);
const summary = createEmptyActionSummary();
for (const act of getEnvContent(body.stored)) {
summarizer.addForwardAction(summary, act);
}
for (const act of Array.from(body.undo).reverse()) {
summarizer.addReverseAction(summary, act);
}
// Name tables consistently, by their ultimate name, now we know it.
for (const renames of summary.tableRenames) {
const pre = renames[0];
let post = renames[1];
if (pre === null) { continue; }
if (post === null) { post = defunctTableName(pre); }
if (summary.tableDeltas[pre]) {
summary.tableDeltas[post] = summary.tableDeltas[pre];
delete summary.tableDeltas[pre];
}
}
for (const td of values(summary.tableDeltas)) {
// Name columns consistently, by their ultimate name, now we know it.
for (const renames of td.columnRenames) {
const pre = renames[0];
let post = renames[1];
if (pre === null) { continue; }
if (post === null) { post = defunctTableName(pre); }
if (td.columnDeltas[pre]) {
td.columnDeltas[post] = td.columnDeltas[pre];
delete td.columnDeltas[pre];
}
}
// remove any duplicates that crept in
td.addRows = Array.from(new Set(td.addRows));
td.updateRows = Array.from(new Set(td.updateRows));
td.removeRows = Array.from(new Set(td.removeRows));
}
return summary;
}
/**
* Once we can produce an ActionSummary for each LocalActionBundle, it is useful to be able
* to compose them. Take the case of an ActionSummary pair, part 1 and part 2. NameMerge
* is an internal structure to help merging table/column name changes across two parts.
*/
interface NameMerge {
dead1: Set<string>; /** anything of this name in part 1 should be removed from merge */
dead2: Set<string>; /** anything of this name in part 2 should be removed from merge */
rename1: Map<string, string>; /** replace these names in part 1 */
rename2: Map<string, string>; /** replace these names in part 2 */
merge: LabelDelta[]; /** a merged list of adds/removes/renames for the result */
}
/**
* Looks at a pair of name change lists (could be tables or columns) and figures out what
* changes would need to be made to a data structure keyed on those names in order to key
* it consistently on final names.
*/
function planNameMerge(names1: LabelDelta[], names2: LabelDelta[]): NameMerge {
const result: NameMerge = {
dead1: new Set(),
dead2: new Set(),
rename1: new Map<string, string>(),
rename2: new Map<string, string>(),
merge: new Array<LabelDelta>(),
};
const names1ByFinalName: {[name: string]: LabelDelta} = keyBy(names1, p => p[1]!);
const names2ByInitialName: {[name: string]: LabelDelta} = keyBy(names2, p => p[0]!);
for (const [before1, after1] of names1) {
if (!after1) {
if (!before1) { throw new Error("invalid name change found"); }
// Table/column was deleted in part 1.
result.dead1.add(before1);
result.merge.push([before1, null]);
continue;
}
// At this point, we know the table/column existed at end of part 1.
const pair2 = names2ByInitialName[after1];
if (!pair2) {
// Table/column's name was stable in part 2, so only change was in part 1.
result.merge.push([before1, after1]);
continue;
}
const after2 = pair2[1];
if (!after2) {
// Table/column was deleted in part 2.
result.dead2.add(after1);
if (before1) {
// Table/column existed prior to part 1, so we need to expose its history.
result.dead1.add(before1);
result.merge.push([before1, null]);
} else {
// Table/column did not exist prior to part 1, so we erase it from history.
result.dead1.add(after1);
result.dead2.add(defunctTableName(after1));
}
continue;
}
// It we made it this far, our table/column exists after part 2. Any information
// keyed to its name in part 1 will need to be rekeyed to its final name.
result.rename1.set(after1, after2);
result.merge.push([before1, after2]);
}
// Look through part 2 for any changes not already covered.
for (const [before2, after2] of names2) {
if (!before2 && !after2) { throw new Error("invalid name change found"); }
if (before2 && names1ByFinalName[before2]) { continue; } // Already handled
result.merge.push([before2, after2]);
// If table/column is renamed in part 2, and name was stable in part 1,
// rekey any information about it in part 1.
if (before2 && after2) { result.rename1.set(before2, after2); }
}
// For neatness, sort the merge order. Not essential.
result.merge = sortBy(result.merge, ([a, b]) => [a || "", b || ""]);
return result;
}
/**
* Re-key nested data to match name changes / removals. Needs to be done a little carefully
* since it is perfectly possible for names to be swapped or shuffled.
*
* Entries may be TableDeltas in the case of table renames or ColumnDeltas for column renames.
*
* @param entries: a dictionary of nested data - TableDeltas for tables, ColumnDeltas for columns.
* @param dead: a set of keys to remove from the dictionary.
* @param rename: changes of names to apply to the dictionary.
*/
function renameAndDelete<T>(entries: {[name: string]: T}, dead: Set<string>,
rename: Map<string, string>) {
// Remove all entries marked as dead.
for (const key of dead) { delete entries[key]; }
// Move all entries that are going to be renamed out to a cache temporarily.
const cache: {[name: string]: any} = {};
for (const key of rename.keys()) {
if (entries[key]) {
cache[key] = entries[key];
delete entries[key];
}
}
// Move all renamed entries back in with their new names.
for (const [key, val] of rename.entries()) { if (cache[key]) { entries[val] = cache[key]; } }
}
/**
* Apply planned name changes to a pair of entries, and return a merged entry incorporating
* their composition.
*
* @param names: the planned name changes as calculated by planNameMerge()
* @param entries1: the first dictionary of nested data keyed on the names
* @param entries2: test second dictionary of nested data keyed on the names
* @param mergeEntry: a function to apply any further corrections needed to the entries
*
*/
function mergeNames<T>(names: NameMerge,
entries1: {[name: string]: T},
entries2: {[name: string]: T},
mergeEntry: (e1: T, e2: T) => T): {[name: string]: T} {
// Update the keys of the entries1 and entries2 dictionaries to be consistent.
renameAndDelete(entries1, names.dead1, names.rename1);
renameAndDelete(entries2, names.dead2, names.rename2);
// Prepare the composition of the two dictionaries.
const entries = entries2; // Start with the second dictionary.
for (const key of Object.keys(entries1)) { // Add material from the first.
const e1 = entries1[key];
if (!entries[key]) { entries[key] = e1; continue; } // No overlap - just add and move on.
entries[key] = mergeEntry(e1, entries[key]); // Recursive merge if overlap.
}
return entries;
}
/**
* Track whether a specific row was added, removed or updated.
*/
interface RowChange {
added: boolean;
removed: boolean;
updated: boolean;
}
/** RowChange for each row in a table */
export interface RowChanges {
[rowId: number]: RowChange;
}
/**
* This is used when we hit a cell that we know has changed but don't know its
* value due to it being part of a bulk input. This produces a cell that
* represents the unknowns.
*/
function bulkCellFor(rc: RowChange|undefined): CellDelta|undefined {
if (!rc) { return undefined; }
const result: CellDelta = [null, null];
if (rc.removed || rc.updated) { result[0] = '?'; }
if (rc.added || rc.updated) { result[1] = '?'; }
return result;
}
/**
* Merge changes that apply to a particular column.
*
* @param present1: affected rows in part 1
* @param present2: affected rows in part 2
* @param e1: cached cell values for the column in part 1
* @param e2: cached cell values for the column in part 2
*/
function mergeColumn(present1: RowChanges, present2: RowChanges,
e1: ColumnDelta, e2: ColumnDelta): ColumnDelta {
for (const key of (Object.keys(present1) as unknown as number[])) {
let v1 = e1[key];
let v2 = e2[key];
if (!v1 && !v2) { continue; }
v1 = v1 || bulkCellFor(present1[key]);
v2 = v2 || bulkCellFor(present2[key]);
if (!v2) { e2[key] = e1[key]; continue; }
if (!v1[1]) { continue; } // Deleted row.
e2[key] = [v1[0], v2[1]]; // Change is from initial value in e1 to final value in e2.
}
return e2;
}
/** Put list of numbers in ascending order, with duplicates removed. */
function uniqueAndSorted(lst: number[]) {
return [...new Set(lst)].sort((a, b) => a - b);
}
/** For each row changed, figure out whether it was added/removed/updated */
/** TODO: need for this method suggests maybe a better core representation for this info */
function getRowChanges(e: TableDelta): RowChanges {
const all = new Set([...e.addRows, ...e.removeRows, ...e.updateRows]);
const added = new Set(e.addRows);
const removed = new Set(e.removeRows);
const updated = new Set(e.updateRows);
return fromPairs([...all].map(x => {
return [x, {added: added.has(x),
removed: removed.has(x),
updated: updated.has(x)}] as [number, RowChange];
}));
}
/**
* Merge changes that apply to a particular table. For updating addRows and removeRows, care is
* needed, since it is fine to remove and add the same rowId within a single summary -- this is just
* rowId reuse. It needs to be tracked so we know lifetime of rows though.
*/
function mergeTable(e1: TableDelta, e2: TableDelta): TableDelta {
// First, sort out any changes to names of columns.
const names = planNameMerge(e1.columnRenames, e2.columnRenames);
mergeNames(names, e1.columnDeltas, e2.columnDeltas,
mergeColumn.bind(null,
getRowChanges(e1),
getRowChanges(e2)));
e2.columnRenames = names.merge;
// All the columnar data is now merged. What remains is to merge the summary lists of rowIds
// that we maintain.
const addRows1 = new Set(e1.addRows); // Non-transient rows we have clearly added.
const removeRows2 = new Set(e2.removeRows); // Non-transient rows we have clearly removed.
const transients = e1.addRows.filter(x => removeRows2.has(x));
e2.addRows = uniqueAndSorted([...e2.addRows, ...e1.addRows.filter(x => !removeRows2.has(x))]);
e2.removeRows = uniqueAndSorted([...e2.removeRows.filter(x => !addRows1.has(x)), ...e1.removeRows]);
e2.updateRows = uniqueAndSorted([...e1.updateRows.filter(x => !removeRows2.has(x)),
...e2.updateRows.filter(x => !addRows1.has(x))]);
// Remove all traces of transients (rows that were created and destroyed) from history.
for (const cols of values(e2.columnDeltas)) {
for (const key of transients) { delete cols[key]; }
}
return e2;
}
/** Finally, merge a pair of summaries. */
export function concatenateSummaryPair(sum1: ActionSummary, sum2: ActionSummary): ActionSummary {
const names = planNameMerge(sum1.tableRenames, sum2.tableRenames);
const rowChanges = mergeNames(names, sum1.tableDeltas, sum2.tableDeltas, mergeTable);
const sum: ActionSummary = {
tableRenames: names.merge,
tableDeltas: rowChanges
};
return sum;
}
/** Generalize to merging a list of summaries. */
export function concatenateSummaries(sums: ActionSummary[]): ActionSummary {
if (sums.length === 0) { return createEmptyActionSummary(); }
let result = sums[0];
for (let i = 1; i < sums.length; i++) {
result = concatenateSummaryPair(result, sums[i]);
}
return result;
}

View File

@@ -1566,7 +1566,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
docSession: OptDocSession|null,
userActions: UserAction[]
): Promise<SandboxActionBundle> {
const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions);
const [normalActions, onDemandActions] = this._onDemandActions.splitByStorage(userActions);
let sandboxActionBundle: SandboxActionBundle;
if (normalActions.length > 0) {
@@ -1768,6 +1768,18 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
return this._triggers.summary();
}
/**
* Send a message to clients connected to the document that something
* webhook-related has happened (a change in configuration, or a
* delivery, or an error). There is room to give details in future,
* if that proves useful, but for now no details are needed.
*/
public async sendWebhookNotification() {
await this.docClients.broadcastDocMessage(null, 'docChatter', {
webhooks: {},
});
}
public logTelemetryEvent(
docSession: OptDocSession | null,
eventName: TelemetryEventName,

View File

@@ -28,7 +28,7 @@ import {
TableOperationsImpl,
TableOperationsPlatform
} from 'app/plugin/TableOperationsImpl';
import {concatenateSummaries, summarizeAction} from "app/server/lib/ActionSummary";
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
import {
assertAccess,
@@ -117,20 +117,20 @@ const {
*/
function validate(checker: Checker): RequestHandler {
return (req, res, next) => {
try {
checker.check(req.body);
} catch(err) {
log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);
res.status(400).json({
error : "Invalid payload",
details: String(err)
}).end();
return;
}
validateCore(checker, req, req.body);
next();
};
}
function validateCore(checker: Checker, req: Request, body: any) {
try {
checker.check(body);
} catch(err) {
log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);
throw new ApiError('Invalid payload', 400, {userError: String(err)});
}
}
export class DocWorkerApi {
// Map from docId to number of requests currently being handled for that doc
private _currentUsage = new Map<string, number>();
@@ -237,6 +237,59 @@ export class DocWorkerApi {
activeDoc.fetchMetaTables(docSessionFromRequest(req)));
}
async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin, webhookId: string|null) {
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, isReadyColumn, name} = req.body;
const tableId = req.params.tableId || req.body.tableId;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly change the ready column to the new col
// id, null or empty string being a special case that unsets it.
if (isReadyColumn !== null && isReadyColumn !== '') {
if (!currentTableId) {
throw new ApiError(`Cannot find column "${isReadyColumn}" because table is not known`, 404);
}
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's unset the ready column
fields.isReadyColRef = 0;
}
// assign other field properties
Object.assign(fields, _.pick(req.body, ['enabled', 'memo']));
if (name) {
fields.label = name;
}
return {
fields,
url,
trigger,
};
}
// Get the columns of the specified table in recordish format
this._app.get('/api/docs/:docId/tables/:tableId/columns', canView,
withDoc(async (activeDoc, req, res) => {
@@ -358,9 +411,27 @@ export class DocWorkerApi {
// Adds records given in a record oriented format,
// returns in the same format as GET /records but without the fields object for now
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit, validate(RecordsPost),
// WARNING: The `req.body` object is modified in place.
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit,
withDoc(async (activeDoc, req, res) => {
const body = req.body as Types.RecordsPost;
let body = req.body;
if (isAffirmative(req.query.flat)) {
if (!body.records && Array.isArray(body)) {
for (const [i, rec] of body.entries()) {
if (!rec.fields) {
// If ids arrive in a loosely formatted flat payload,
// remove them since we cannot honor them. If not loosely
// formatted, throw an error later. TODO: would be useful
// to have a way to exclude or rename fields via query
// parameters.
if (rec.id) { delete rec.id; }
body[i] = {fields: rec};
}
}
body = {records: body};
}
}
validateCore(RecordsPost, req, body);
const ops = getTableOperations(req, activeDoc);
const records = await ops.create(body.records);
res.json({records});
@@ -550,22 +621,15 @@ export class DocWorkerApi {
// Add a new webhook and trigger
this._app.post('/api/docs/:docId/tables/:tableId/_subscribe', isOwner, validate(WebhookSubscribe),
withDoc(async (activeDoc, req, res) => {
const {isReadyColumn, eventTypes, url} = req.body;
if (!eventTypes.length) {
const {fields, url} = await getWebhookSettings(activeDoc, req, null);
if (!fields.eventTypes?.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
if (!isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
const tableId = req.params.tableId;
const metaTables = await getMetaTables(activeDoc, req);
const tableRef = tableIdToRef(metaTables, tableId);
let isReadyColRef = 0;
if (isReadyColumn) {
isReadyColRef = colIdToReference(metaTables, tableId, isReadyColumn);
if (!fields.tableRef) {
throw new ApiError(`tableId is required`, 400);
}
const unsubscribeKey = uuidv4();
@@ -579,9 +643,8 @@ export class DocWorkerApi {
const sandboxRes = await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions(
docSessionFromRequest(req),
[['AddRecord', "_grist_Triggers", null, {
eventTypes: [GristObjCode.List, ...eventTypes],
isReadyColRef,
tableRef,
enabled: true,
...fields,
actions: JSON.stringify([webhookAction])
}]]));
@@ -596,6 +659,8 @@ export class DocWorkerApi {
// remove webhook
await this._dbManager.removeWebhook(webhookId, activeDoc.docName, '', false);
throw err;
} finally {
await activeDoc.sendWebhookNotification();
}
})
);
@@ -622,56 +687,19 @@ export class DocWorkerApi {
docSessionFromRequest(req),
[['RemoveRecord', "_grist_Triggers", triggerRowId]]));
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
// Update a webhoook
// Update a webhook
this._app.patch(
'/api/docs/:docId/webhooks/:webhookId', isOwner, validate(WebhookPatch), withDoc(async (activeDoc, req, res) => {
const docId = activeDoc.docName;
const webhookId = req.params.webhookId;
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = activeDoc.triggers.getWebhookTriggerRecord(webhookId);
let currentTableId = tablesTable.getValue(trigger.tableRef, 'tableId')!;
const {url, eventTypes, isReadyColumn, tableId} = req.body;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
// TODO: remove redundancy with same validation in _subscribe endpoint
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
// TODO: remove redundancy with same validation in _subscribe endpoint
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly changes the ready column to the new col
// id, null being a special case that unsets it.
if (isReadyColumn !== null) {
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's implicitely unset the ready column
fields.isReadyColRef = 0;
}
// assign other fields properties
Object.assign(fields, _.pick(req.body, ['enabled']));
const {fields, trigger, url} = await getWebhookSettings(activeDoc, req, webhookId);
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
@@ -694,6 +722,8 @@ export class DocWorkerApi {
}
});
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
@@ -702,6 +732,7 @@ export class DocWorkerApi {
this._app.delete('/api/docs/:docId/webhooks/queue', isOwner,
withDoc(async (activeDoc, req, res) => {
await activeDoc.clearWebhookQueue();
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);

View File

@@ -1,32 +1,23 @@
import {BulkColValues, ColValues, DocAction, isSchemaAction, TableDataAction, UserAction} from 'app/common/DocActions';
import {AlternateActions, AlternateStorage} from 'app/common/AlternateActions';
import {DocData} from 'app/common/DocData';
import {TableData} from 'app/common/TableData';
import {IndexColumns} from 'app/server/lib/DocStorage';
const ACTION_TYPES = new Set(['AddRecord', 'BulkAddRecord', 'UpdateRecord', 'BulkUpdateRecord',
'RemoveRecord', 'BulkRemoveRecord']);
export interface ProcessedAction {
stored: DocAction[];
undo: DocAction[];
retValues: any;
}
export interface OnDemandStorage {
getNextRowId(tableId: string): Promise<number>;
fetchActionData(tableId: string, rowIds: number[], colIds?: string[]): Promise<TableDataAction>;
}
export type {ProcessedAction} from 'app/common/AlternateActions';
export type OnDemandStorage = AlternateStorage;
/**
* Handle converting UserActions to DocActions for onDemand tables.
*/
export class OnDemandActions {
export class OnDemandActions extends AlternateActions {
private _tablesMeta: TableData = this._docData.getMetaTable('_grist_Tables');
private _columnsMeta: TableData = this._docData.getMetaTable('_grist_Tables_column');
constructor(private _storage: OnDemandStorage, private _docData: DocData,
private _forceOnDemand: boolean = false) {}
constructor(_storage: OnDemandStorage, private _docData: DocData,
private _forceOnDemand: boolean = false) {
super(_storage);
}
// TODO: Ideally a faster data structure like an index by tableId would be used to decide whether
// the table is onDemand.
@@ -37,51 +28,8 @@ export class OnDemandActions {
return tableRef ? Boolean(this._tablesMeta.getValue(tableRef, 'onDemand')) : false;
}
/**
* Convert a UserAction into stored and undo DocActions as well as return values.
*/
public processUserAction(action: UserAction): Promise<ProcessedAction> {
const a = action.map(item => item as any);
switch (a[0]) {
case "ApplyUndoActions": return this._doApplyUndoActions(a[1]);
case "AddRecord": return this._doAddRecord (a[1], a[2], a[3]);
case "BulkAddRecord": return this._doBulkAddRecord (a[1], a[2], a[3]);
case "UpdateRecord": return this._doUpdateRecord (a[1], a[2], a[3]);
case "BulkUpdateRecord": return this._doBulkUpdateRecord(a[1], a[2], a[3]);
case "RemoveRecord": return this._doRemoveRecord (a[1], a[2]);
case "BulkRemoveRecord": return this._doBulkRemoveRecord(a[1], a[2]);
default: throw new Error(`Received unknown action ${action[0]}`);
}
}
/**
* Splits an array of UserActions into two separate arrays of normal and onDemand actions.
*/
public splitByOnDemand(actions: UserAction[]): [UserAction[], UserAction[]] {
const normal: UserAction[] = [];
const onDemand: UserAction[] = [];
actions.forEach(a => {
// Check that the actionType can be applied without the sandbox and also that the action
// is on a data table.
const isOnDemandAction = ACTION_TYPES.has(a[0] as string);
const isDataTableAction = typeof a[1] === 'string' && !a[1].startsWith('_grist_');
if (a[0] === 'ApplyUndoActions') {
// Split actions inside the undo action array.
const [undoNormal, undoOnDemand] = this.splitByOnDemand(a[1] as UserAction[]);
if (undoNormal.length > 0) {
normal.push(['ApplyUndoActions', undoNormal]);
}
if (undoOnDemand.length > 0) {
onDemand.push(['ApplyUndoActions', undoOnDemand]);
}
} else if (isDataTableAction && isOnDemandAction && this.isOnDemand(a[1] as string)) {
// Check whether the tableId belongs to an onDemand table.
onDemand.push(a);
} else {
normal.push(a);
}
});
return [normal, onDemand];
public usesAlternateStorage(tableId: string): boolean {
return this.isOnDemand(tableId);
}
/**
@@ -97,112 +45,4 @@ export class OnDemandActions {
}
return desiredIndexes;
}
/**
* Check if an action represents a schema change on an onDemand table.
*/
public isSchemaAction(docAction: DocAction): boolean {
return isSchemaAction(docAction) && this.isOnDemand(docAction[1]);
}
private async _doApplyUndoActions(actions: DocAction[]) {
const undo: DocAction[] = [];
for (const a of actions) {
const converted = await this.processUserAction(a);
undo.concat(converted.undo);
}
return {
stored: actions,
undo,
retValues: null
};
}
private async _doAddRecord(
tableId: string,
rowId: number|null,
colValues: ColValues
): Promise<ProcessedAction> {
if (rowId === null) {
rowId = await this._storage.getNextRowId(tableId);
}
// Set the manualSort to be the same as the rowId. This forces new rows to always be added
// at the end of the table.
colValues.manualSort = rowId;
return {
stored: [['AddRecord', tableId, rowId, colValues]],
undo: [['RemoveRecord', tableId, rowId]],
retValues: rowId
};
}
private async _doBulkAddRecord(
tableId: string,
rowIds: Array<number|null>,
colValues: BulkColValues
): Promise<ProcessedAction> {
// When unset, we will set the rowId values to count up from the greatest
// values already in the table.
if (rowIds[0] === null) {
const nextRowId = await this._storage.getNextRowId(tableId);
for (let i = 0; i < rowIds.length; i++) {
rowIds[i] = nextRowId + i;
}
}
// Set the manualSort values to be the same as the rowIds. This forces new rows to always be
// added at the end of the table.
colValues.manualSort = rowIds;
return {
stored: [['BulkAddRecord', tableId, rowIds as number[], colValues]],
undo: [['BulkRemoveRecord', tableId, rowIds as number[]]],
retValues: rowIds
};
}
private async _doUpdateRecord(
tableId: string,
rowId: number,
colValues: ColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, [rowId], Object.keys(colValues));
return {
stored: [['UpdateRecord', tableId, rowId, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkUpdateRecord(
tableId: string,
rowIds: number[],
colValues: BulkColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, rowIds, Object.keys(colValues));
return {
stored: [['BulkUpdateRecord', tableId, rowIds, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doRemoveRecord(tableId: string, rowId: number): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, [rowId]);
return {
stored: [['RemoveRecord', tableId, rowId]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkRemoveRecord(tableId: string, rowIds: number[]): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, rowIds);
return {
stored: [['BulkRemoveRecord', tableId, rowIds]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
}

View File

@@ -1,6 +1,6 @@
import {ActionSummary, ColumnDelta, createEmptyActionSummary, createEmptyTableDelta} from 'app/common/ActionSummary';
import {CellDelta} from 'app/common/TabularDiff';
import {concatenateSummaries} from 'app/server/lib/ActionSummary';
import {concatenateSummaries} from 'app/common/ActionSummarizer';
import {ISQLiteDB, quoteIdent, ResultRow} from 'app/server/lib/SQLiteDB';
import keyBy = require('lodash/keyBy');
import matches = require('lodash/matches');

View File

@@ -6,8 +6,9 @@ import {fromTableDataAction, RowRecord, TableColValues, TableDataAction} from 'a
import {StringUnion} from 'app/common/StringUnion';
import {MetaRowRecord} from 'app/common/TableData';
import {CellDelta} from 'app/common/TabularDiff';
import {WebhookBatchStatus, WebhookStatus, WebhookSummary, WebhookUsage} from 'app/common/Triggers';
import {decodeObject} from 'app/plugin/objtypes';
import {summarizeAction} from 'app/server/lib/ActionSummary';
import {summarizeAction} from 'app/common/ActionSummarizer';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import log from 'app/server/lib/log';
@@ -37,44 +38,6 @@ type RecordDeltas = Map<number, RecordDelta>;
// Union discriminated by type
type TriggerAction = WebhookAction | PythonAction;
type WebhookBatchStatus = 'success'|'failure'|'rejected';
type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'invalid';
export interface WebhookSummary {
id: string;
fields: {
url: string;
unsubscribeKey: string;
eventTypes: string[];
isReadyColumn?: string|null;
tableId: string;
enabled: boolean;
},
usage: WebhookUsage|null,
}
interface WebhookUsage {
// As minimum we need number of waiting events and status (by default pending).
numWaiting: number,
status: WebhookStatus;
updatedTime?: number|null;
lastSuccessTime?: number|null;
lastFailureTime?: number|null;
lastErrorMessage?: string|null;
lastHttpStatus?: number|null;
lastEventBatch?: null | {
size: number;
errorMessage: string|null;
httpStatus: number|null;
status: WebhookBatchStatus;
attempts: number;
},
numSuccess?: {
pastHour: number;
past24Hours: number;
},
}
export interface WebhookAction {
type: "webhook";
id: string;
@@ -171,7 +134,7 @@ export class DocTriggers {
// to quit it afterwards and avoid keeping a client open for documents without triggers.
this._getRedisQueuePromise = this._getRedisQueue(createClient(redisUrl));
}
this._stats = new WebhookStatistics(this._docId, () => this._redisClient ?? null);
this._stats = new WebhookStatistics(this._docId, _activeDoc, () => this._redisClient ?? null);
}
public shutdown() {
@@ -316,7 +279,9 @@ export class DocTriggers {
isReadyColumn: getColId(t.isReadyColRef) ?? null,
tableId: getTableId(t.tableRef) ?? null,
// For future use - for now every webhook is enabled.
enabled: true,
enabled: t.enabled,
name: t.label,
memo: t.memo,
},
// Create some statics and status info.
usage: await this._stats.getUsage(act.id, this._webHookEventQueue),
@@ -346,6 +311,10 @@ export class DocTriggers {
public webhookDeleted(id: string) {
// We can't do much about that as the loop might be in progress and it is not safe to modify the queue.
// But we can clear the webHook cache, so that the next time we check the webhook url it will be gone.
this.clearWebhookCache(id);
}
public clearWebhookCache(id: string) {
this._webhookCache.delete(id);
}
@@ -527,7 +496,9 @@ export class DocTriggers {
tableDelta: TableDelta,
): boolean {
let readyBefore: boolean;
if (!trigger.isReadyColRef) {
if (!trigger.enabled) {
return false;
} else if (!trigger.isReadyColRef) {
// User hasn't configured a column, so all records are considered ready immediately
readyBefore = recordDelta.existedBefore;
} else {
@@ -821,6 +792,7 @@ class PersistedStore<Keys> {
constructor(
docId: string,
private _activeDoc: ActiveDoc,
private _redisClientDep: () => RedisClient | null
) {
this._redisKey = `webhooks:${docId}:statistics`;
@@ -833,6 +805,10 @@ class PersistedStore<Keys> {
}
}
protected async markChange() {
await this._activeDoc.sendWebhookNotification();
}
protected async set(id: string, keyValues: [Keys, string][]) {
if (this._redisClient) {
const multi = this._redisClient.multi();
@@ -939,6 +915,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
stats.push(['errorMessage', '']);
}
await this.set(id, stats);
await this.markChange();
}
public async logInvalid(id: string, errorMessage: string) {
@@ -946,6 +923,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
await this.set(id, [
['errorMessage', errorMessage]
]);
await this.markChange();
}
/**
@@ -995,6 +973,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
batchSummary.push([`lastHttpStatus`, (stats.httpStatus || '').toString()]);
}
await this.set(id, batchStats.concat(batchSummary));
await this.markChange();
}
}

View File

@@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY,
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
@@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@@ -75,7 +75,7 @@ INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');