fix: make server guid consistent (#4341)
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
OnApplicationBootstrap,
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -13,6 +14,7 @@ import { Config } from '../../config';
|
|||||||
import { Metrics } from '../../metrics/metrics';
|
import { Metrics } from '../../metrics/metrics';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
||||||
|
import { DocID } from '../../utils/doc';
|
||||||
|
|
||||||
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||||
if (yBinary.equals(jwstBinary)) {
|
if (yBinary.equals(jwstBinary)) {
|
||||||
@@ -42,7 +44,9 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
|
|||||||
* along side all the updates that have not been applies to that snapshot(timestamp).
|
* along side all the updates that have not been applies to that snapshot(timestamp).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocManager implements OnModuleInit, OnModuleDestroy {
|
export class DocManager
|
||||||
|
implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap
|
||||||
|
{
|
||||||
protected logger = new Logger(DocManager.name);
|
protected logger = new Logger(DocManager.name);
|
||||||
private job: NodeJS.Timeout | null = null;
|
private job: NodeJS.Timeout | null = null;
|
||||||
private seqMap = new Map<string, number>();
|
private seqMap = new Map<string, number>();
|
||||||
@@ -56,6 +60,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
protected readonly metrics: Metrics
|
protected readonly metrics: Metrics
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
if (!this.config.node.test) {
|
||||||
|
await this.refreshDocGuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (this.automation) {
|
if (this.automation) {
|
||||||
this.logger.log('Use Database');
|
this.logger.log('Use Database');
|
||||||
@@ -239,7 +249,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* get the snapshot of the doc we've seen.
|
* get the snapshot of the doc we've seen.
|
||||||
*/
|
*/
|
||||||
protected async getSnapshot(workspaceId: string, guid: string) {
|
async getSnapshot(workspaceId: string, guid: string) {
|
||||||
return this.db.snapshot.findUnique({
|
return this.db.snapshot.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id_workspaceId: {
|
id_workspaceId: {
|
||||||
@@ -253,7 +263,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* get pending updates
|
* get pending updates
|
||||||
*/
|
*/
|
||||||
protected async getUpdates(workspaceId: string, guid: string) {
|
async getUpdates(workspaceId: string, guid: string) {
|
||||||
return this.db.update.findMany({
|
return this.db.update.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -411,4 +421,46 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
return last + 1;
|
return last + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deal with old records that has wrong guid format
|
||||||
|
* correct guid with `${non-wsId}:${variant}:${subId}` to `${subId}`
|
||||||
|
*
|
||||||
|
* @TODO delete in next release
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
private async refreshDocGuid() {
|
||||||
|
let turn = 0;
|
||||||
|
let lastTurnCount = 100;
|
||||||
|
while (lastTurnCount === 100) {
|
||||||
|
const docs = await this.db.snapshot.findMany({
|
||||||
|
skip: turn * 100,
|
||||||
|
take: 100,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lastTurnCount = docs.length;
|
||||||
|
for (const doc of docs) {
|
||||||
|
const docId = new DocID(doc.id, doc.workspaceId);
|
||||||
|
|
||||||
|
if (docId && !docId.isWorkspace && docId.guid !== doc.id) {
|
||||||
|
await this.db.snapshot.update({
|
||||||
|
where: {
|
||||||
|
id_workspaceId: {
|
||||||
|
id: doc.id,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
id: docId.guid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Redis from 'ioredis';
|
|||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { Metrics } from '../../metrics/metrics';
|
import { Metrics } from '../../metrics/metrics';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { DocID } from '../../utils/doc';
|
||||||
import { DocManager } from './manager';
|
import { DocManager } from './manager';
|
||||||
|
|
||||||
function makeKey(prefix: string) {
|
function makeKey(prefix: string) {
|
||||||
@@ -62,11 +63,9 @@ export class RedisDocManager extends DocManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docId = new DocID(pendingDoc);
|
||||||
const updateKey = updates`${pendingDoc}`;
|
const updateKey = updates`${pendingDoc}`;
|
||||||
const lockKey = lock`${pendingDoc}`;
|
const lockKey = lock`${pendingDoc}`;
|
||||||
const splitAt = pendingDoc.indexOf(':');
|
|
||||||
const workspaceId = pendingDoc.substring(0, splitAt);
|
|
||||||
const id = pendingDoc.substring(splitAt + 1);
|
|
||||||
|
|
||||||
// acquire the lock
|
// acquire the lock
|
||||||
const lockResult = await this.redis
|
const lockResult = await this.redis
|
||||||
@@ -98,18 +97,18 @@ export class RedisDocManager extends DocManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
|
`applying ${updates.length} updates for workspace: ${docId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await this.getSnapshot(workspaceId, id);
|
const snapshot = await this.getSnapshot(docId.workspace, docId.guid);
|
||||||
|
|
||||||
// merge
|
// merge
|
||||||
const doc = snapshot
|
const doc = snapshot
|
||||||
? this.applyUpdates(id, snapshot.blob, ...updates)
|
? this.applyUpdates(docId.full, snapshot.blob, ...updates)
|
||||||
: this.applyUpdates(id, ...updates);
|
: this.applyUpdates(docId.full, ...updates);
|
||||||
|
|
||||||
// update snapshot
|
// update snapshot
|
||||||
await this.upsert(workspaceId, id, doc, snapshot?.seq);
|
await this.upsert(docId.workspace, docId.guid, doc, snapshot?.seq);
|
||||||
|
|
||||||
// delete merged updates
|
// delete merged updates
|
||||||
await this.redis
|
await this.redis
|
||||||
@@ -120,9 +119,9 @@ export class RedisDocManager extends DocManager {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to merge updates with snapshot for ${pendingDoc}: ${e}`
|
`Failed to merge updates with snapshot for ${docId}: ${e}`
|
||||||
);
|
);
|
||||||
await this.redis.sadd(pending, `${workspaceId}:${id}`).catch(() => null); // safe
|
await this.redis.sadd(pending, docId.toString()).catch(() => null); // safe
|
||||||
} finally {
|
} finally {
|
||||||
await this.redis.del(lockKey);
|
await this.redis.del(lockKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||||
|
|
||||||
import { Metrics } from '../../../metrics/metrics';
|
import { Metrics } from '../../../metrics/metrics';
|
||||||
import { trimGuid } from '../../../utils/doc';
|
import { DocID } from '../../../utils/doc';
|
||||||
import { Auth, CurrentUser } from '../../auth';
|
import { Auth, CurrentUser } from '../../auth';
|
||||||
import { DocManager } from '../../doc';
|
import { DocManager } from '../../doc';
|
||||||
import { UserType } from '../../users';
|
import { UserType } from '../../users';
|
||||||
@@ -85,7 +85,11 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@SubscribeMessage('client-update')
|
@SubscribeMessage('client-update')
|
||||||
async handleClientUpdate(
|
async handleClientUpdate(
|
||||||
@MessageBody()
|
@MessageBody()
|
||||||
message: {
|
{
|
||||||
|
workspaceId,
|
||||||
|
guid,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
guid: string;
|
guid: string;
|
||||||
update: string;
|
update: string;
|
||||||
@@ -94,51 +98,54 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
) {
|
) {
|
||||||
this.metric.socketIOEventCounter(1, { event: 'client-update' });
|
this.metric.socketIOEventCounter(1, { event: 'client-update' });
|
||||||
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
|
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
|
||||||
if (!client.rooms.has(message.workspaceId)) {
|
|
||||||
|
if (!client.rooms.has(workspaceId)) {
|
||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
`Client ${client.id} tried to push update to workspace ${message.workspaceId} without joining it first`
|
`Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first`
|
||||||
);
|
);
|
||||||
endTimer();
|
endTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = Buffer.from(message.update, 'base64');
|
const docId = new DocID(guid, workspaceId);
|
||||||
client.to(message.workspaceId).emit('server-update', message);
|
client
|
||||||
|
.to(docId.workspace)
|
||||||
|
.emit('server-update', { workspaceId, guid, update });
|
||||||
|
|
||||||
const guid = trimGuid(message.workspaceId, message.guid);
|
const buf = Buffer.from(update, 'base64');
|
||||||
await this.docManager.push(message.workspaceId, guid, update);
|
|
||||||
|
|
||||||
|
await this.docManager.push(docId.workspace, docId.guid, buf);
|
||||||
endTimer();
|
endTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Auth()
|
@Auth()
|
||||||
@SubscribeMessage('doc-load')
|
@SubscribeMessage('doc-load')
|
||||||
async loadDoc(
|
async loadDoc(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
@CurrentUser() user: UserType,
|
@CurrentUser() user: UserType,
|
||||||
@MessageBody()
|
@MessageBody()
|
||||||
message: {
|
{
|
||||||
|
workspaceId,
|
||||||
|
guid,
|
||||||
|
stateVector,
|
||||||
|
}: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
guid: string;
|
guid: string;
|
||||||
stateVector?: string;
|
stateVector?: string;
|
||||||
targetClientId?: number;
|
}
|
||||||
},
|
|
||||||
@ConnectedSocket() client: Socket
|
|
||||||
): Promise<{ missing: string; state?: string } | false> {
|
): Promise<{ missing: string; state?: string } | false> {
|
||||||
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
|
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
|
||||||
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
|
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
|
||||||
if (!client.rooms.has(message.workspaceId)) {
|
if (!client.rooms.has(workspaceId)) {
|
||||||
const canRead = await this.permissions.tryCheck(
|
const canRead = await this.permissions.tryCheck(workspaceId, user.id);
|
||||||
message.workspaceId,
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
endTimer();
|
endTimer();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const guid = trimGuid(message.workspaceId, message.guid);
|
const docId = new DocID(guid, workspaceId);
|
||||||
const doc = await this.docManager.get(message.workspaceId, guid);
|
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
endTimer();
|
endTimer();
|
||||||
@@ -148,9 +155,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
const missing = Buffer.from(
|
const missing = Buffer.from(
|
||||||
encodeStateAsUpdate(
|
encodeStateAsUpdate(
|
||||||
doc,
|
doc,
|
||||||
message.stateVector
|
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
||||||
? Buffer.from(message.stateVector, 'base64')
|
|
||||||
: undefined
|
|
||||||
)
|
)
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { Response } from 'express';
|
|||||||
import format from 'pretty-time';
|
import format from 'pretty-time';
|
||||||
|
|
||||||
import { StorageProvide } from '../../storage';
|
import { StorageProvide } from '../../storage';
|
||||||
import { trimGuid } from '../../utils/doc';
|
import { DocID } from '../../utils/doc';
|
||||||
import { Auth, CurrentUser, Publicable } from '../auth';
|
import { Auth, CurrentUser, Publicable } from '../auth';
|
||||||
import { DocManager } from '../doc';
|
import { DocManager } from '../doc';
|
||||||
import { UserType } from '../users';
|
import { UserType } from '../users';
|
||||||
@@ -64,15 +64,19 @@ export class WorkspacesController {
|
|||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
) {
|
) {
|
||||||
const start = process.hrtime();
|
const start = process.hrtime();
|
||||||
const id = trimGuid(ws, guid);
|
const docId = new DocID(guid, ws);
|
||||||
if (
|
if (
|
||||||
// if a user has the permission
|
// if a user has the permission
|
||||||
!(await this.permission.isAccessible(ws, id, user?.id))
|
!(await this.permission.isAccessible(
|
||||||
|
docId.workspace,
|
||||||
|
docId.guid,
|
||||||
|
user?.id
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new ForbiddenException('Permission denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = await this.docManager.getBinary(ws, id);
|
const update = await this.docManager.getBinary(docId.workspace, docId.guid);
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
throw new NotFoundException('Doc not found');
|
throw new NotFoundException('Doc not found');
|
||||||
|
|||||||
@@ -74,11 +74,7 @@ export class PermissionService {
|
|||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// check if this is a public subpage
|
// check if this is a public subpage
|
||||||
|
return subpages.some(subpage => id === subpage);
|
||||||
// why use `endsWith`?
|
|
||||||
// because there might have `${wsId}:space:${subpageId}`,
|
|
||||||
// but subpages only have `${subpageId}`
|
|
||||||
return subpages.some(subpage => id.endsWith(subpage));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PrismaService } from '../../prisma';
|
|||||||
import { StorageProvide } from '../../storage';
|
import { StorageProvide } from '../../storage';
|
||||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||||
import type { FileUpload } from '../../types';
|
import type { FileUpload } from '../../types';
|
||||||
|
import { DocID } from '../../utils/doc';
|
||||||
import { Auth, CurrentUser, Public } from '../auth';
|
import { Auth, CurrentUser, Public } from '../auth';
|
||||||
import { MailService } from '../auth/mailer';
|
import { MailService } from '../auth/mailer';
|
||||||
import { AuthService } from '../auth/service';
|
import { AuthService } from '../auth/service';
|
||||||
@@ -662,16 +663,24 @@ export class WorkspaceResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('pageId') pageId: string
|
@Args('pageId') pageId: string
|
||||||
) {
|
) {
|
||||||
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
|
if (docId.isWorkspace) {
|
||||||
|
throw new ForbiddenException('Expect page not to be workspace');
|
||||||
|
}
|
||||||
|
|
||||||
const userWorkspace = await this.prisma.userWorkspacePermission.findFirst({
|
const userWorkspace = await this.prisma.userWorkspacePermission.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
workspaceId,
|
workspaceId: docId.workspace,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userWorkspace?.accepted) {
|
if (!userWorkspace?.accepted) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new ForbiddenException('Permission denied');
|
||||||
}
|
}
|
||||||
return this.permissions.grantPage(workspaceId, pageId);
|
|
||||||
|
return this.permissions.grantPage(docId.workspace, docId.guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -680,9 +689,15 @@ export class WorkspaceResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('pageId') pageId: string
|
@Args('pageId') pageId: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.check(workspaceId, user.id, Permission.Admin);
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
return this.permissions.revokePage(workspaceId, pageId);
|
if (docId.isWorkspace) {
|
||||||
|
throw new ForbiddenException('Expect page not to be workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.permissions.check(docId.workspace, user.id, Permission.Admin);
|
||||||
|
|
||||||
|
return this.permissions.revokePage(docId.workspace, docId.guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => [String], {
|
@Query(() => [String], {
|
||||||
|
|||||||
72
apps/server/src/utils/__tests__/doc.spec.ts
Normal file
72
apps/server/src/utils/__tests__/doc.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import test from 'ava';
|
||||||
|
|
||||||
|
import { DocID, DocVariant } from '../doc';
|
||||||
|
|
||||||
|
test('can parse', t => {
|
||||||
|
// workspace only
|
||||||
|
let id = new DocID('ws');
|
||||||
|
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.assert(id.isWorkspace);
|
||||||
|
|
||||||
|
// full id
|
||||||
|
id = new DocID('ws:space:sub');
|
||||||
|
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.variant, DocVariant.Space);
|
||||||
|
t.is(id.guid, 'sub');
|
||||||
|
|
||||||
|
// variant only
|
||||||
|
id = new DocID('space:sub', 'ws');
|
||||||
|
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.variant, DocVariant.Space);
|
||||||
|
t.is(id.guid, 'sub');
|
||||||
|
|
||||||
|
// sub id only
|
||||||
|
id = new DocID('sub', 'ws');
|
||||||
|
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.variant, DocVariant.Unknown);
|
||||||
|
t.is(id.guid, 'sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fail', t => {
|
||||||
|
t.throws(() => new DocID('a:b:c:d'), {
|
||||||
|
message: 'Invalid format of Doc ID: a:b:c:d',
|
||||||
|
});
|
||||||
|
t.throws(() => new DocID(':space:sub'), { message: 'Workspace is required' });
|
||||||
|
t.throws(() => new DocID('space:sub'), { message: 'Workspace is required' });
|
||||||
|
t.throws(() => new DocID('ws:any:sub'), {
|
||||||
|
message: 'Invalid ID variant: any',
|
||||||
|
});
|
||||||
|
t.throws(() => new DocID('ws:space:'), {
|
||||||
|
message: 'ID is required for non-workspace doc',
|
||||||
|
});
|
||||||
|
t.throws(() => new DocID('ws::space'), {
|
||||||
|
message: 'Variant is required for non-workspace doc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fix', t => {
|
||||||
|
let id = new DocID('ws');
|
||||||
|
// can't fix because the doc variant is [Workspace]
|
||||||
|
id.fixWorkspace('ws2');
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.toString(), 'ws');
|
||||||
|
|
||||||
|
id = new DocID('ws:space:sub');
|
||||||
|
|
||||||
|
id.fixWorkspace('ws2');
|
||||||
|
|
||||||
|
t.is(id.workspace, 'ws2');
|
||||||
|
t.is(id.toString(), 'ws2:space:sub');
|
||||||
|
|
||||||
|
id = new DocID('space:sub', 'ws');
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.toString(), 'ws:space:sub');
|
||||||
|
|
||||||
|
id = new DocID('ws2:space:sub', 'ws');
|
||||||
|
t.is(id.workspace, 'ws');
|
||||||
|
t.is(id.toString(), 'ws:space:sub');
|
||||||
|
});
|
||||||
@@ -1,7 +1,115 @@
|
|||||||
export function trimGuid(ws: string, guid: string) {
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
if (guid.startsWith(`${ws}:space:`)) {
|
|
||||||
return guid.substring(ws.length + 1);
|
export enum DocVariant {
|
||||||
|
Workspace = 'workspace',
|
||||||
|
Space = 'space',
|
||||||
|
Settings = 'settings',
|
||||||
|
Unknown = 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(DocVariant, {
|
||||||
|
name: 'DocVariant',
|
||||||
|
});
|
||||||
|
|
||||||
|
export class DocID {
|
||||||
|
raw: string;
|
||||||
|
workspace: string;
|
||||||
|
variant: DocVariant;
|
||||||
|
private sub: string | null;
|
||||||
|
|
||||||
|
static parse(raw: string): DocID | null {
|
||||||
|
try {
|
||||||
|
return new DocID(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return guid;
|
/**
|
||||||
|
* pure guid for workspace and subdoc without any prefix
|
||||||
|
*/
|
||||||
|
get guid(): string {
|
||||||
|
return this.variant === DocVariant.Workspace
|
||||||
|
? this.workspace
|
||||||
|
: // sub is always truthy when variant is not workspace
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
this.sub!;
|
||||||
|
}
|
||||||
|
|
||||||
|
get full(): string {
|
||||||
|
return this.variant === DocVariant.Workspace
|
||||||
|
? this.workspace
|
||||||
|
: `${this.workspace}:${this.variant}:${this.sub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isWorkspace(): boolean {
|
||||||
|
return this.variant === DocVariant.Workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(raw: string, workspaceId?: string) {
|
||||||
|
if (!raw.length) {
|
||||||
|
throw new Error('Invalid Empty Doc ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = raw.split(':');
|
||||||
|
|
||||||
|
if (parts.length > 3) {
|
||||||
|
throw new Error(`Invalid format of Doc ID: ${raw}`);
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
// `${variant}:${guid}`
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error('Workspace is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.unshift(workspaceId);
|
||||||
|
} else if (parts.length === 1) {
|
||||||
|
// ${ws} or ${pageId}
|
||||||
|
if (workspaceId && parts[0] !== workspaceId) {
|
||||||
|
parts = [workspaceId, DocVariant.Unknown, parts[0]];
|
||||||
|
} else {
|
||||||
|
// parts:[ws] equals [workspaceId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace = parts.at(0);
|
||||||
|
|
||||||
|
// fix for `${non-workspaceId}:${variant}:${guid}`
|
||||||
|
if (workspaceId) {
|
||||||
|
workspace = workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = parts.at(1);
|
||||||
|
const docId = parts.at(2);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error('Workspace is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant) {
|
||||||
|
if (!Object.values(DocVariant).includes(variant as any)) {
|
||||||
|
throw new Error(`Invalid ID variant: ${variant}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docId) {
|
||||||
|
throw new Error('ID is required for non-workspace doc');
|
||||||
|
}
|
||||||
|
} else if (docId) {
|
||||||
|
throw new Error('Variant is required for non-workspace doc');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.raw = raw;
|
||||||
|
this.workspace = workspace;
|
||||||
|
this.variant = (variant as DocVariant | undefined) ?? DocVariant.Workspace;
|
||||||
|
this.sub = docId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.full;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixWorkspace(workspaceId: string) {
|
||||||
|
if (!this.isWorkspace && this.workspace !== workspaceId) {
|
||||||
|
this.workspace = workspaceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ test('should have sequential update number', async t => {
|
|||||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
||||||
|
|
||||||
// [1,2,3]
|
// [1,2,3]
|
||||||
// @ts-expect-error private method
|
|
||||||
let records = await manager.getUpdates('2', '2');
|
let records = await manager.getUpdates('2', '2');
|
||||||
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
@@ -208,21 +207,18 @@ test('should have sequential update number', async t => {
|
|||||||
|
|
||||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
||||||
|
|
||||||
// @ts-expect-error private method
|
|
||||||
records = await manager.getUpdates('2', '2');
|
records = await manager.getUpdates('2', '2');
|
||||||
|
|
||||||
// push a new update with new seq num
|
// push a new update with new seq num
|
||||||
await manager.push('2', '2', updates[0]);
|
await manager.push('2', '2', updates[0]);
|
||||||
|
|
||||||
// let the manager ignore update with the new seq num
|
// let the manager ignore update with the new seq num
|
||||||
// @ts-expect-error private method
|
|
||||||
const stub = Sinon.stub(manager, 'getUpdates').resolves(records);
|
const stub = Sinon.stub(manager, 'getUpdates').resolves(records);
|
||||||
|
|
||||||
// @ts-expect-error private method
|
// @ts-expect-error private method
|
||||||
await manager.autoSquash();
|
await manager.autoSquash();
|
||||||
stub.restore();
|
stub.restore();
|
||||||
|
|
||||||
// @ts-expect-error private method
|
|
||||||
records = await manager.getUpdates('2', '2');
|
records = await manager.getUpdates('2', '2');
|
||||||
|
|
||||||
// should not merge in one run
|
// should not merge in one run
|
||||||
|
|||||||
@@ -415,28 +415,6 @@ mutation sendVerifyChangeEmail($token: String!, $email: String!, $callbackUrl: S
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setRevokePageMutation = {
|
|
||||||
id: 'setRevokePageMutation' as const,
|
|
||||||
operationName: 'setRevokePage',
|
|
||||||
definitionName: 'revokePage',
|
|
||||||
containsFile: false,
|
|
||||||
query: `
|
|
||||||
mutation setRevokePage($workspaceId: String!, $pageId: String!) {
|
|
||||||
revokePage(workspaceId: $workspaceId, pageId: $pageId)
|
|
||||||
}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setSharePageMutation = {
|
|
||||||
id: 'setSharePageMutation' as const,
|
|
||||||
operationName: 'setSharePage',
|
|
||||||
definitionName: 'sharePage',
|
|
||||||
containsFile: false,
|
|
||||||
query: `
|
|
||||||
mutation setSharePage($workspaceId: String!, $pageId: String!) {
|
|
||||||
sharePage(workspaceId: $workspaceId, pageId: $pageId)
|
|
||||||
}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setWorkspacePublicByIdMutation = {
|
export const setWorkspacePublicByIdMutation = {
|
||||||
id: 'setWorkspacePublicByIdMutation' as const,
|
id: 'setWorkspacePublicByIdMutation' as const,
|
||||||
operationName: 'setWorkspacePublicById',
|
operationName: 'setWorkspacePublicById',
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
mutation setRevokePage($workspaceId: String!, $pageId: String!) {
|
|
||||||
revokePage(workspaceId: $workspaceId, pageId: $pageId)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
mutation setSharePage($workspaceId: String!, $pageId: String!) {
|
|
||||||
sharePage(workspaceId: $workspaceId, pageId: $pageId)
|
|
||||||
}
|
|
||||||
@@ -376,26 +376,6 @@ export type SendVerifyChangeEmailMutation = {
|
|||||||
sendVerifyChangeEmail: boolean;
|
sendVerifyChangeEmail: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetRevokePageMutationVariables = Exact<{
|
|
||||||
workspaceId: Scalars['String']['input'];
|
|
||||||
pageId: Scalars['String']['input'];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type SetRevokePageMutation = {
|
|
||||||
__typename?: 'Mutation';
|
|
||||||
revokePage: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SetSharePageMutationVariables = Exact<{
|
|
||||||
workspaceId: Scalars['String']['input'];
|
|
||||||
pageId: Scalars['String']['input'];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type SetSharePageMutation = {
|
|
||||||
__typename?: 'Mutation';
|
|
||||||
sharePage: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SetWorkspacePublicByIdMutationVariables = Exact<{
|
export type SetWorkspacePublicByIdMutationVariables = Exact<{
|
||||||
id: Scalars['ID']['input'];
|
id: Scalars['ID']['input'];
|
||||||
public: Scalars['Boolean']['input'];
|
public: Scalars['Boolean']['input'];
|
||||||
@@ -637,16 +617,6 @@ export type Mutations =
|
|||||||
variables: SendVerifyChangeEmailMutationVariables;
|
variables: SendVerifyChangeEmailMutationVariables;
|
||||||
response: SendVerifyChangeEmailMutation;
|
response: SendVerifyChangeEmailMutation;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
name: 'setRevokePageMutation';
|
|
||||||
variables: SetRevokePageMutationVariables;
|
|
||||||
response: SetRevokePageMutation;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
name: 'setSharePageMutation';
|
|
||||||
variables: SetSharePageMutationVariables;
|
|
||||||
response: SetSharePageMutation;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
name: 'setWorkspacePublicByIdMutation';
|
name: 'setWorkspacePublicByIdMutation';
|
||||||
variables: SetWorkspacePublicByIdMutationVariables;
|
variables: SetWorkspacePublicByIdMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user