(core) Persist forks in home db

Summary:
Adds information about forks to the home db. This will be used
later by the UI to list forks of documents.

Test Plan: Browser and server tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3772
This commit is contained in:
George Gevoian
2023-02-19 21:51:40 -05:00
parent 3aba7f6208
commit 1ac4931c22
14 changed files with 673 additions and 40 deletions

View File

@@ -1,7 +1,8 @@
import {ApiError} from 'app/common/ApiError';
import {DocumentUsage} from 'app/common/DocUsage';
import {Role} from 'app/common/roles';
import {DocumentOptions, DocumentProperties, documentPropertyKeys, NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
DocumentType, NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {nativeValues} from 'app/gen-server/lib/values';
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
import {AclRuleDoc} from "./AclRule";
@@ -69,6 +70,25 @@ export class Document extends Resource {
@Column({name: 'usage', type: nativeValues.jsonEntityType, nullable: true})
public usage: DocumentUsage | null;
@Column({name: 'created_by', type: 'integer', nullable: true})
public createdBy: number|null;
@Column({name: 'trunk_id', type: 'text', nullable: true})
public trunkId: string|null;
// Property set for forks, containing the URL ID of the trunk.
public trunkUrlId?: string|null;
@ManyToOne(_type => Document, document => document.forks)
@JoinColumn({name: 'trunk_id'})
public trunk: Document|null;
@OneToMany(_type => Document, document => document.trunk)
public forks: Document[];
@Column({name: 'type', type: 'text', nullable: true})
public type: DocumentType|null;
public checkProperties(props: any): props is Partial<DocumentProperties> {
return super.checkProperties(props, documentPropertyKeys);
}
@@ -82,6 +102,7 @@ export class Document extends Resource {
}
this.urlId = props.urlId;
}
if (props.type !== undefined) { this.type = props.type; }
if (props.options !== undefined) {
// Options are merged over the existing state - unless options
// object is set to "null", in which case the state is wiped

View File

@@ -1213,6 +1213,9 @@ export class HomeDBManager extends EventEmitter {
(doc.workspace as any).owner = doc.workspace.org.owner;
}
if (forkId || snapshotId) {
doc.trunkId = doc.id;
doc.trunkUrlId = doc.urlId;
// Fix up our reply to be correct for the fork, rather than the trunk.
// The "id" and "urlId" fields need updating.
doc.id = buildUrlId({trunkId: doc.id, forkId, forkUserId, snapshotId});
@@ -1294,6 +1297,20 @@ export class HomeDBManager extends EventEmitter {
return this._single(await this._verifyAclPermissions(qb));
}
/**
* Gets a list of all forks whose trunk is `docId`.
*
* NOTE: This is not a part of the API. It should only be called by the DocApi when
* deleting a document.
*/
public async getDocForks(docId: string): Promise<Document[]> {
return this._connection.createQueryBuilder()
.select('forks')
.from(Document, 'forks')
.where('forks.trunk_id = :docId', {docId})
.getMany();
}
/**
*
* Adds an org with the given name. Returns a query result with the id of the added org.
@@ -1778,6 +1795,7 @@ export class HomeDBManager extends EventEmitter {
doc.aliases = [];
}
doc.workspace = workspace;
doc.createdBy = scope.userId;
// Create the special initial permission groups for the new workspace.
const groupMap = this._createGroups(workspace, scope.userId);
doc.aclRules = this.defaultCommonGroups.map(_grpDesc => {
@@ -1872,7 +1890,7 @@ export class HomeDBManager extends EventEmitter {
const queryResult = await verifyIsPermitted(docQuery);
if (queryResult.status !== 200) {
// If the query for the workspace failed, return the failure result.
// If the query for the doc failed, return the failure result.
return queryResult;
}
// Update the name and save.
@@ -1945,6 +1963,30 @@ export class HomeDBManager extends EventEmitter {
return this._setDocumentRemovedAt(scope, null);
}
/**
* Like `deleteDocument`, but for deleting a fork.
*
* NOTE: This is not a part of the API. It should only be called by the DocApi when
* deleting a fork.
*/
public async deleteFork(scope: DocScope): Promise<QueryResult<number>> {
return await this._connection.transaction(async manager => {
const forkQuery = this._doc(scope, {
manager,
allowSpecialPermit: true
});
const result = await forkQuery.getRawAndEntities();
if (result.entities.length === 0) {
return {
status: 404,
errMessage: 'fork not found'
};
}
await manager.remove(result.entities[0]);
return {status: 200};
});
}
// Fetches and provides a callback with the billingAccount so it may be updated within
// a transaction. The billingAccount is saved after any changes applied in the callback.
// Will throw an error if the user does not have access to the org's billingAccount.
@@ -2540,6 +2582,31 @@ export class HomeDBManager extends EventEmitter {
});
}
/**
* Creates a fork of `doc`, using the specified `forkId`.
*
* NOTE: This is not a part of the API. It should only be called by the ActiveDoc when
* a new fork is initiated.
*/
public async forkDoc(
userId: number,
doc: Document,
forkId: string,
): Promise<QueryResult<string>> {
return await this._connection.transaction(async manager => {
const fork = new Document();
fork.id = forkId;
fork.name = doc.name;
fork.createdBy = userId;
fork.trunkId = doc.trunkId || doc.id;
const result = await manager.save([fork]);
return {
status: 200,
data: result[0].id,
};
});
}
/**
* Updates the updatedAt and usage values for several docs. Takes a map where each entry maps
* a docId to a metadata object containing the updatedAt and/or usage values. This is not a part
@@ -2803,6 +2870,9 @@ export class HomeDBManager extends EventEmitter {
let query = this.org(scope, org, options)
.leftJoinAndSelect('orgs.workspaces', 'workspaces')
.leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope))
.leftJoin('docs.forks', 'forks', this._onFork())
.addSelect(['forks.id', 'forks.trunkId', 'forks.createdBy', 'forks.updatedAt'])
.setParameter('anonId', this.getAnonymousUserId())
.leftJoin('orgs.billingAccount', 'account')
.leftJoin('account.product', 'product')
.addSelect('product.features')
@@ -3421,6 +3491,13 @@ export class HomeDBManager extends EventEmitter {
}
}
/**
* Like _onDoc, but for joining forks.
*/
private _onFork() {
return 'forks.created_by = :userId AND forks.created_by <> :anonId';
}
/**
* Construct a QueryBuilder for a select query on a specific workspace given by
* wsId. Provides options for running in a transaction and adding permission info.

View File

@@ -1,4 +1,5 @@
import { ApiError } from 'app/common/ApiError';
import { buildUrlId } from 'app/common/gristUrls';
import { Document } from 'app/gen-server/entity/Document';
import { Workspace } from 'app/gen-server/entity/Workspace';
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
@@ -119,6 +120,29 @@ export class Housekeeper {
};
await this._dbManager.deleteWorkspace(scope, workspace.id);
}
// Delete old forks
const forks = await this._getForksToDelete();
for (const fork of forks) {
const docId = buildUrlId({trunkId: fork.trunkId!, forkId: fork.id, forkUserId: fork.createdBy!});
const permitKey = await this._permitStore.setPermit({docId});
try {
const result = await fetch(
await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}`),
{
method: 'DELETE',
headers: {
Permit: permitKey,
},
}
);
if (result.status !== 200) {
log.error(`failed to delete fork ${docId}: error status ${result.status}`);
}
} finally {
await this._permitStore.removePermit(permitKey);
}
}
}
public addEndpoints(app: express.Application) {
@@ -202,7 +226,7 @@ export class Housekeeper {
}
private async _getWorkspacesToDelete() {
const docs = await this._dbManager.connection.createQueryBuilder()
const workspaces = await this._dbManager.connection.createQueryBuilder()
.select('workspaces')
.from(Workspace, 'workspaces')
.leftJoin('workspaces.docs', 'docs')
@@ -212,7 +236,17 @@ export class Housekeeper {
// wait for workspace to be empty
.andWhere('docs.id IS NULL')
.getMany();
return docs;
return workspaces;
}
private async _getForksToDelete() {
const forks = await this._dbManager.connection.createQueryBuilder()
.select('forks')
.from(Document, 'forks')
.where('forks.trunk_id IS NOT NULL')
.andWhere(`forks.updated_at <= ${this._getThreshold()}`)
.getMany();
return forks;
}
/**

View File

@@ -0,0 +1,42 @@
import {MigrationInterface, QueryRunner, TableColumn, TableForeignKey} from "typeorm";
export class Forks1673051005072 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns("docs", [
new TableColumn({
name: "created_by",
type: "integer",
isNullable: true,
}),
new TableColumn({
name: "trunk_id",
type: "text",
isNullable: true,
}),
new TableColumn({
name: "type",
type: "text",
isNullable: true,
}),
]);
await queryRunner.createForeignKeys("docs", [
new TableForeignKey({
columnNames: ["created_by"],
referencedTableName: "users",
referencedColumnNames: ["id"],
onDelete: "CASCADE",
}),
new TableForeignKey({
columnNames: ["trunk_id"],
referencedTableName: "docs",
referencedColumnNames: ["id"],
onDelete: "CASCADE",
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumns("docs", ["created_by", "trunk_id", "type"]);
}
}