Add document update validation and revert

This commit is contained in:
Hakan Shehu
2025-02-18 11:23:36 +01:00
parent 99b3189fc3
commit 18b15e09c3
6 changed files with 113 additions and 36 deletions

View File

@@ -105,6 +105,19 @@ export class DocumentGetQueryHandler
}; };
} }
if (
event.type === 'document_update_deleted' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.documentId === input.documentId
) {
const newOutput = await this.handleQuery(input);
return {
hasChanges: true,
result: newOutput,
};
}
if ( if (
event.type === 'node_deleted' && event.type === 'node_deleted' &&
event.accountId === input.accountId && event.accountId === input.accountId &&

View File

@@ -1,5 +1,7 @@
import { import {
CanUpdateDocumentContext,
createDebugger, createDebugger,
DocumentContent,
generateId, generateId,
getNodeModel, getNodeModel,
IdType, IdType,
@@ -10,6 +12,8 @@ import { encodeState, YDoc } from '@colanode/crdt';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { fetchNodeTree } from '@/main/lib/utils';
import { mapNode } from '@/main/lib/mappers';
const UPDATE_RETRIES_LIMIT = 10; const UPDATE_RETRIES_LIMIT = 10;
@@ -22,12 +26,12 @@ export class DocumentService {
} }
public async updateDocument(id: string, update: Uint8Array) { public async updateDocument(id: string, update: Uint8Array) {
const node = await this.workspace.database const tree = await fetchNodeTree(this.workspace.database, id);
.selectFrom('nodes') if (!tree) {
.selectAll() throw new Error('Node not found');
.where('id', '=', id) }
.executeTakeFirst();
const node = tree[tree.length - 1];
if (!node) { if (!node) {
throw new Error('Node not found'); throw new Error('Node not found');
} }
@@ -37,6 +41,21 @@ export class DocumentService {
throw new Error('Node does not have a document schema'); throw new Error('Node does not have a document schema');
} }
const context: CanUpdateDocumentContext = {
user: {
id: this.workspace.userId,
role: this.workspace.role,
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
},
node: mapNode(node),
tree: tree.map((node) => mapNode(node)),
};
if (!model.canUpdateDocument(context)) {
throw new Error('User does not have permission to update document');
}
const document = await this.workspace.database const document = await this.workspace.database
.selectFrom('documents') .selectFrom('documents')
.selectAll() .selectAll()
@@ -57,6 +76,11 @@ export class DocumentService {
ydoc.applyUpdate(update); ydoc.applyUpdate(update);
const content = ydoc.getObject<DocumentContent>();
if (!model.documentSchema.safeParse(content).success) {
throw new Error('Invalid document state');
}
const updateId = generateId(IdType.Update); const updateId = generateId(IdType.Update);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
@@ -122,6 +146,35 @@ export class DocumentService {
} }
} }
public async revertDocumentUpdate(data: UpdateDocumentMutationData) {
const update = await this.workspace.database
.selectFrom('document_updates')
.selectAll()
.where('id', '=', data.updateId)
.executeTakeFirst();
if (!update) {
return;
}
const deletedUpdate = await this.workspace.database
.deleteFrom('document_updates')
.where('id', '=', update.id)
.executeTakeFirst();
if (!deletedUpdate) {
return;
}
eventBus.publish({
type: 'document_update_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
documentId: data.documentId,
updateId: data.updateId,
});
}
public async syncServerDocumentUpdate(data: SyncDocumentUpdateData) { public async syncServerDocumentUpdate(data: SyncDocumentUpdateData) {
for (let count = 0; count < UPDATE_RETRIES_LIMIT; count++) { for (let count = 0; count < UPDATE_RETRIES_LIMIT; count++) {
try { try {

View File

@@ -157,6 +157,8 @@ export class MutationService {
await this.workspace.nodeReactions.revertNodeReactionDelete( await this.workspace.nodeReactions.revertNodeReactionDelete(
mutation.data mutation.data
); );
} else if (mutation.type === 'update_document') {
await this.workspace.documents.revertDocumentUpdate(mutation.data);
} }
} }
@@ -250,11 +252,6 @@ export class MutationService {
previousMutation.data.nodeId === mutation.data.id previousMutation.data.nodeId === mutation.data.id
) { ) {
deletedMutationIds.add(previousMutation.id); deletedMutationIds.add(previousMutation.id);
} else if (
previousMutation.type === 'create_file' &&
previousMutation.data.id === mutation.data.id
) {
deletedMutationIds.add(previousMutation.id);
} }
} }
} else if (mutation.type === 'delete_node_reaction') { } else if (mutation.type === 'delete_node_reaction') {

View File

@@ -203,6 +203,14 @@ export type DocumentUpdateCreatedEvent = {
updateId: string; updateId: string;
}; };
export type DocumentUpdateDeletedEvent = {
type: 'document_update_deleted';
accountId: string;
workspaceId: string;
documentId: string;
updateId: string;
};
export type Event = export type Event =
| UserCreatedEvent | UserCreatedEvent
| UserUpdatedEvent | UserUpdatedEvent
@@ -235,4 +243,5 @@ export type Event =
| WorkspaceMetadataUpdatedEvent | WorkspaceMetadataUpdatedEvent
| WorkspaceMetadataDeletedEvent | WorkspaceMetadataDeletedEvent
| DocumentUpdatedEvent | DocumentUpdatedEvent
| DocumentUpdateCreatedEvent; | DocumentUpdateCreatedEvent
| DocumentUpdateDeletedEvent;

View File

@@ -1,6 +1,8 @@
import { import {
CanUpdateDocumentContext,
createDebugger, createDebugger,
DocumentContent, DocumentContent,
getNodeModel,
UpdateDocumentMutationData, UpdateDocumentMutationData,
} from '@colanode/core'; } from '@colanode/core';
import { decodeState, YDoc } from '@colanode/crdt'; import { decodeState, YDoc } from '@colanode/crdt';
@@ -9,7 +11,7 @@ import { database } from '@/data/database';
import { SelectUser } from '@/data/schema'; import { SelectUser } from '@/data/schema';
import { ConcurrentUpdateResult, UpdateDocumentOutput } from '@/types/nodes'; import { ConcurrentUpdateResult, UpdateDocumentOutput } from '@/types/nodes';
import { eventBus } from '@/lib/event-bus'; import { eventBus } from '@/lib/event-bus';
import { fetchNode } from '@/lib/nodes'; import { fetchNodeTree, mapNode } from '@/lib/nodes';
const debug = createDebugger('server:lib:documents'); const debug = createDebugger('server:lib:documents');
@@ -38,13 +40,33 @@ const tryUpdateDocumentFromMutation = async (
user: SelectUser, user: SelectUser,
mutation: UpdateDocumentMutationData mutation: UpdateDocumentMutationData
): Promise<ConcurrentUpdateResult<UpdateDocumentOutput>> => { ): Promise<ConcurrentUpdateResult<UpdateDocumentOutput>> => {
const node = await fetchNode(mutation.documentId); const tree = await fetchNodeTree(mutation.documentId);
if (!tree) {
return { type: 'error', output: null };
}
const node = tree[tree.length - 1];
if (!node) { if (!node) {
return { type: 'error', output: null }; return { type: 'error', output: null };
} }
const root = await fetchNode(node.root_id); const model = getNodeModel(node.type);
if (!root) { if (!model.documentSchema) {
return { type: 'error', output: null };
}
const context: CanUpdateDocumentContext = {
user: {
id: user.id,
role: user.role,
workspaceId: user.workspace_id,
accountId: user.account_id,
},
node: mapNode(node),
tree: tree.map((node) => mapNode(node)),
};
if (!model.canUpdateDocument(context)) {
return { type: 'error', output: null }; return { type: 'error', output: null };
} }
@@ -68,6 +90,10 @@ const tryUpdateDocumentFromMutation = async (
ydoc.applyUpdate(mutation.data); ydoc.applyUpdate(mutation.data);
const content = ydoc.getObject<DocumentContent>(); const content = ydoc.getObject<DocumentContent>();
if (!model.documentSchema.safeParse(content).success) {
return { type: 'error', output: null };
}
try { try {
const { updatedDocument, createdDocumentUpdate } = await database const { updatedDocument, createdDocumentUpdate } = await database
.transaction() .transaction()

View File

@@ -1,5 +1,3 @@
import { FileSubtype } from './files';
export type SyncMutationsInput = { export type SyncMutationsInput = {
mutations: Mutation[]; mutations: Mutation[];
}; };
@@ -105,24 +103,6 @@ export type MarkNodeOpenedMutation = MutationBase & {
data: MarkNodeOpenedMutationData; data: MarkNodeOpenedMutationData;
}; };
export type CreateFileMutationData = {
id: string;
type: FileSubtype;
parentId: string;
rootId: string;
name: string;
originalName: string;
extension: string;
mimeType: string;
size: number;
createdAt: string;
};
export type CreateFileMutation = MutationBase & {
type: 'create_file';
data: CreateFileMutationData;
};
export type UpdateDocumentMutationData = { export type UpdateDocumentMutationData = {
documentId: string; documentId: string;
updateId: string; updateId: string;
@@ -143,7 +123,6 @@ export type Mutation =
| DeleteNodeReactionMutation | DeleteNodeReactionMutation
| MarkNodeSeenMutation | MarkNodeSeenMutation
| MarkNodeOpenedMutation | MarkNodeOpenedMutation
| CreateFileMutation
| UpdateDocumentMutation; | UpdateDocumentMutation;
export type MutationType = Mutation['type']; export type MutationType = Mutation['type'];