From dcfaae86fb12bfe14cce0c8966a43c51f68375b4 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Tue, 25 Aug 2026 20:52:04 +0300 Subject: [PATCH 1/2] fix(server): make Yjs sole writer of Document.content REST update/create could overwrite the Yjs-derived content mirror, silently discarding collaborative edits (last-writer-wins). Strip content from both endpoints; also write snapshot + mirror atomically in dbPersistence.store so they cannot drift on partial failure. Closes #47 --- server/src/controllers/document.controller.ts | 25 +++------- server/src/lib/dbPersistence.ts | 49 ++++++++++--------- server/test/document.test.ts | 18 +++++++ 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index f554568..8db7d83 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -10,7 +10,7 @@ import { getClientInfo } from '@/utils/getClientInfo'; export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => { const clientInfo = getClientInfo(req); const userId = req.user?.userId; - const { title, content = '', isPublic = false } = req.body; + const { title, isPublic = false } = req.body; logger.debug('Document creation attempt', { action: 'CREATE_DOCUMENT_ATTEMPT', @@ -18,14 +18,15 @@ export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res userId, title, isPublic, - contentLength: content.length, }); try { + // `content` starts empty and is owned by Yjs sync afterwards (issue #47); + // accepting it here would store text no reader ever sees. const newDoc = await prisma.document.create({ data: { title, - content, + content: '', isPublic, authorId: req.user?.userId, }, @@ -190,18 +191,7 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res const clientInfo = getClientInfo(req); const userId = req.user?.userId; const documentId = req.params.id; - const { title, content, isPublic } = req.body; - - // logger.info('Document update attempt', { - // action: 'UPDATE_DOCUMENT_ATTEMPT', - // ...clientInfo, - // userId, - // documentId, - // title, - // isPublic, - // contentLength: content?.length, - // }); - + const { title, isPublic } = req.body; try { const doc = await prisma.document.findFirst({ where: { id: documentId }, @@ -227,11 +217,12 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res return; } + // `content` is intentionally not writable here: it is a mirror of the + // Yjs document state maintained by dbPersistence.store (issue #47). const updatedDoc = await prisma.document.update({ where: { id: doc.id }, - data: { title, content, isPublic }, + data: { title, isPublic }, }); - // logger.info('Document updated successfully', { // action: 'UPDATE_DOCUMENT_SUCCESS', // ...clientInfo, diff --git a/server/src/lib/dbPersistence.ts b/server/src/lib/dbPersistence.ts index 05bc50e..13fabe3 100644 --- a/server/src/lib/dbPersistence.ts +++ b/server/src/lib/dbPersistence.ts @@ -54,35 +54,38 @@ export const dbPersistence = new Database({ }); if (existing) { - await prisma.yjsDocumentState.update({ - where: { documentId: id }, - data: { - state: Buffer.from(state), - version: { increment: 1 }, - }, - }); - - await prisma.document.update({ - where: { id: id }, - data: { content: plainText }, - }); + // Snapshot and its plaintext mirror must stay consistent: write both atomically. + await prisma.$transaction([ + prisma.yjsDocumentState.update({ + where: { documentId: id }, + data: { + state: Buffer.from(state), + version: { increment: 1 }, + }, + }), + prisma.document.update({ + where: { id: id }, + data: { content: plainText }, + }), + ]); } else { const documentExists = await prisma.document.findFirst({ where: { id: id }, }); if (documentExists) { - await prisma.yjsDocumentState.create({ - data: { - documentId: documentExists.id, - state: Buffer.from(state), - }, - }); - - await prisma.document.update({ - where: { id: documentExists.id }, - data: { content: plainText }, - }); + await prisma.$transaction([ + prisma.yjsDocumentState.create({ + data: { + documentId: documentExists.id, + state: Buffer.from(state), + }, + }), + prisma.document.update({ + where: { id: documentExists.id }, + data: { content: plainText }, + }), + ]); } else { logger.warn(`No Document found for ID prefix: ${documentName}`, { action: 'DB_STORE_DOC_NOT_FOUND', diff --git a/server/test/document.test.ts b/server/test/document.test.ts index 58105ed..f8f682b 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -95,6 +95,24 @@ describe('Document Routes', () => { expect(res.body.title).toBe('Updated Title'); }); + it('should not overwrite document content via REST update (Yjs is the source of truth)', async () => { + const created = await prisma.document.create({ + data: { title: 'Synced Doc', content: 'yjs-derived-content', authorId: userId }, + }); + + const res = await request(app) + .put(`/api/document/${created.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Renamed', content: 'rest-overwrite-attempt' }); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body.title).toBe('Renamed'); + expect(res.body.content).toBe('yjs-derived-content'); + + const doc = await prisma.document.findUniqueOrThrow({ where: { id: created.id } }); + expect(doc.content).toBe('yjs-derived-content'); + }); + it('should delete a document', async () => { const created = await prisma.document.create({ data: { From ac37bd3e5d9ca5122a6be1b38d35c45eaeedeb55 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Tue, 25 Aug 2026 21:27:10 +0300 Subject: [PATCH 2/2] fix(client): stop sending content in document save payload Server ignores REST content writes since #47; align handleSave so a future autosave re-wire (#35) cannot silently expect text persistence. --- client/src/hooks/use-document.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/use-document.ts b/client/src/hooks/use-document.ts index 578d631..78a85d3 100644 --- a/client/src/hooks/use-document.ts +++ b/client/src/hooks/use-document.ts @@ -51,11 +51,15 @@ export function useDocument(id?: string) { }, [id]); const handleSave = useCallback(async () => { - if (!id) return; + if (!id || !editedDoc) return; try { setSaving(true); setError(null); - await api.put(`/document/${id}`, editedDoc); + // Content is owned by Yjs sync; REST persists metadata only (issue #47). + await api.put(`/document/${id}`, { + title: editedDoc.title, + isPublic: editedDoc.isPublic, + }); setDoc(editedDoc); } catch (err) { console.error('Failed to save document:', err);