mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +01:00
Add document update validation and revert
This commit is contained in:
@@ -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 &&
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
Reference in New Issue
Block a user