Skip to content

Commit 473e749

Browse files
authored
Fix row document override (#243)
* chore: fix override * chore: fix override * chore: lint
1 parent 27c042c commit 473e749

File tree

6 files changed

+430
-29
lines changed

6 files changed

+430
-29
lines changed

cypress/e2e/database/board-edit-operations.cy.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,61 @@ describe('Board Operations', () => {
288288
cy.task('log', '[TEST COMPLETE] Rapid card creation test passed');
289289
});
290290
});
291+
292+
it('should preserve row document content when reopening card multiple times', () => {
293+
const testEmail = generateRandomEmail();
294+
const cardName = `Reopen-${uuidv4().substring(0, 6)}`;
295+
const documentContent = `Content-${uuidv4().substring(0, 8)}`;
296+
const reopenCount = 3;
297+
298+
cy.task('log', `[TEST START] Card reopen test - Email: ${testEmail}`);
299+
300+
const authUtils = new AuthTestUtils();
301+
createBoardAndWait(authUtils, testEmail).then(() => {
302+
// Add a new card
303+
BoardSelectors.boardContainer().contains('New').first().click({ force: true });
304+
waitForReactUpdate(500);
305+
cy.focused().type(`${cardName}{enter}`, { force: true });
306+
waitForReactUpdate(2000);
307+
308+
BoardSelectors.boardContainer().contains(cardName).should('be.visible');
309+
310+
// Open card and add content to the row document
311+
BoardSelectors.boardContainer().contains(cardName).click({ force: true });
312+
waitForReactUpdate(1500);
313+
314+
cy.get('[role="dialog"]', { timeout: 10000 }).should('be.visible');
315+
316+
// Type content in the row document area (below properties)
317+
cy.get('[role="dialog"]').find('[data-block-type]').first().click({ force: true });
318+
waitForReactUpdate(500);
319+
cy.focused().type(documentContent, { force: true });
320+
waitForReactUpdate(2000);
321+
322+
// Close modal
323+
cy.get('body').type('{esc}', { force: true });
324+
waitForReactUpdate(2000);
325+
326+
// Reopen the card multiple times and verify content persists
327+
for (let i = 1; i <= reopenCount; i++) {
328+
cy.task('log', `[REOPEN ${i}/${reopenCount}] Opening card`);
329+
330+
BoardSelectors.boardContainer().contains(cardName).click({ force: true });
331+
waitForReactUpdate(1500);
332+
333+
cy.get('[role="dialog"]', { timeout: 10000 }).should('be.visible');
334+
335+
// Verify the document content is still there
336+
cy.get('[role="dialog"]').contains(documentContent, { timeout: 10000 }).should('exist');
337+
338+
// Close modal
339+
cy.get('body').type('{esc}', { force: true });
340+
waitForReactUpdate(1500);
341+
}
342+
343+
cy.task('log', '[TEST COMPLETE] Card reopen test passed');
344+
});
345+
});
291346
});
292347

293348
describe('Card Persistence', () => {

src/application/services/js-services/cache/index.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as Y from 'yjs';
2+
13
import { migrateDatabaseFieldTypes } from '@/application/database-yjs/migrations/rollup_fieldtype';
24
import { getRowKey } from '@/application/database-yjs/row_meta';
35
import { closeCollabDB, db, openCollabDB, openCollabDBWithProvider } from '@/application/db';
@@ -562,3 +564,141 @@ export function getCachedRowDoc(rowKey: string): YDoc | undefined {
562564
export function deleteRow(rowKey: string) {
563565
rowDocs.delete(rowKey);
564566
}
567+
568+
// ============================================================================
569+
// Row Sub-Document Cache (for document content inside database rows)
570+
// ============================================================================
571+
572+
type RowSubDocEntry = {
573+
doc: YDoc;
574+
whenSynced: Promise<void>;
575+
};
576+
577+
const rowSubDocs = new Map<string, RowSubDocEntry>();
578+
579+
/**
580+
* Helper to get text content summary from a Y.Doc for debugging.
581+
*/
582+
function getDocTextSummary(doc: YDoc): { hasText: boolean; textCount: number; sampleText: string } {
583+
try {
584+
const sharedRoot = doc.getMap(YjsEditorKey.data_section);
585+
const document = sharedRoot?.get(YjsEditorKey.document) as Y.Map<unknown> | undefined;
586+
const meta = document?.get(YjsEditorKey.meta) as Y.Map<unknown> | undefined;
587+
const textMap = meta?.get(YjsEditorKey.text_map) as Y.Map<Y.Text> | undefined;
588+
589+
if (!textMap) {
590+
return { hasText: false, textCount: 0, sampleText: '' };
591+
}
592+
593+
let textCount = 0;
594+
let sampleText = '';
595+
596+
for (const text of textMap.values()) {
597+
const str = text?.toString() || '';
598+
599+
if (str.length > 0) {
600+
textCount++;
601+
if (!sampleText) {
602+
sampleText = str.slice(0, 50);
603+
}
604+
}
605+
}
606+
607+
return { hasText: textCount > 0, textCount, sampleText };
608+
} catch {
609+
return { hasText: false, textCount: 0, sampleText: '' };
610+
}
611+
}
612+
613+
/**
614+
* Get or create a cached row sub-document.
615+
* This ensures the same Y.Doc instance is reused when reopening a card,
616+
* preserving the sync state and preventing content loss.
617+
*/
618+
export async function getOrCreateRowSubDoc(documentId: string): Promise<YDoc> {
619+
const existing = rowSubDocs.get(documentId);
620+
621+
if (existing) {
622+
const textSummary = getDocTextSummary(existing.doc);
623+
624+
Log.debug('[RowSubDoc] returning cached doc', {
625+
documentId,
626+
hasDoc: Boolean(existing.doc),
627+
...textSummary,
628+
});
629+
630+
await existing.whenSynced;
631+
return existing.doc;
632+
}
633+
634+
Log.debug('[RowSubDoc] creating new doc entry', { documentId });
635+
636+
const startedAt = Date.now();
637+
const { doc, provider } = await openCollabDBWithProvider(documentId, { awaitSync: false });
638+
639+
// Check initial state
640+
const sharedRoot = doc.getMap(YjsEditorKey.data_section);
641+
const hasDocument = sharedRoot.has(YjsEditorKey.document);
642+
const textBeforeSync = getDocTextSummary(doc);
643+
644+
Log.debug('[RowSubDoc] opened (before IndexedDB sync)', {
645+
documentId,
646+
openDurationMs: Date.now() - startedAt,
647+
providerSynced: provider.synced,
648+
hasDocumentBeforeSync: hasDocument,
649+
...textBeforeSync,
650+
});
651+
652+
const whenSynced = provider.synced
653+
? Promise.resolve()
654+
: new Promise<void>((resolve) => {
655+
provider.on('synced', () => {
656+
const syncedSharedRoot = doc.getMap(YjsEditorKey.data_section);
657+
const hasDocAfterSync = syncedSharedRoot.has(YjsEditorKey.document);
658+
const textAfterSync = getDocTextSummary(doc);
659+
660+
Log.debug('[RowSubDoc] IndexedDB synced', {
661+
documentId,
662+
durationMs: Date.now() - startedAt,
663+
hasDocumentAfterSync: hasDocAfterSync,
664+
hadDocumentBeforeSync: hasDocument,
665+
textBefore: textBeforeSync,
666+
textAfter: textAfterSync,
667+
});
668+
669+
resolve();
670+
});
671+
});
672+
673+
const entry = { doc, whenSynced };
674+
675+
rowSubDocs.set(documentId, entry);
676+
677+
await whenSynced;
678+
679+
// Log final state after sync
680+
const finalTextSummary = getDocTextSummary(doc);
681+
682+
Log.debug('[RowSubDoc] ready', {
683+
documentId,
684+
totalDurationMs: Date.now() - startedAt,
685+
...finalTextSummary,
686+
});
687+
688+
return doc;
689+
}
690+
691+
/**
692+
* Get a cached row sub-document if it exists, without creating a new one.
693+
*/
694+
export function getCachedRowSubDoc(documentId: string): YDoc | undefined {
695+
return rowSubDocs.get(documentId)?.doc;
696+
}
697+
698+
/**
699+
* Remove a row sub-document from the cache.
700+
* Call this when the document is no longer needed (e.g., row deleted).
701+
*/
702+
export function deleteRowSubDoc(documentId: string) {
703+
rowSubDocs.delete(documentId);
704+
}

src/application/slate-yjs/utils/yjs.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ import {
2424
} from '@/application/types';
2525
import { Log } from '@/utils/log';
2626

27-
// UUID namespace OID (same as Rust's uuid::NAMESPACE_OID)
28-
const UUID_NAMESPACE_OID = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
27+
// UUID namespace OID (same as Rust's Uuid::NAMESPACE_OID)
28+
// Note: 6ba7b812 (not 6ba7b810 which is NAMESPACE_DNS)
29+
const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8';
2930

3031
/**
3132
* Generate a deterministic page_id from document_id.
@@ -49,7 +50,16 @@ export function pageIdFromDocumentId(documentId: string): string {
4950
: uuidv5(documentId, UUID_NAMESPACE_OID);
5051

5152
// Generate page_id as UUID v5 with document_id as namespace and "page" as name
52-
return uuidv5('page', docUuid);
53+
const pageId = uuidv5('page', docUuid);
54+
55+
Log.debug('[pageIdFromDocumentId]', {
56+
documentId,
57+
isValidUuid: uuidValidate(documentId),
58+
docUuid,
59+
pageId,
60+
});
61+
62+
return pageId;
5363
}
5464

5565
export function getTextMap(sharedRoot: YSharedRoot) {
@@ -300,6 +310,10 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph =
300310

301311
// Skip if already initialized
302312
if (sharedRoot.has(YjsEditorKey.document)) {
313+
Log.debug('[initializeDocumentStructure] skipped - already initialized', {
314+
docGuid: doc.guid,
315+
documentId,
316+
});
303317
return;
304318
}
305319

@@ -308,6 +322,13 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph =
308322
// Use deterministic page_id from documentId when provided, otherwise fallback to nanoid
309323
// The deterministic algorithm matches the server's default_document_data
310324
const pageId = documentId ? pageIdFromDocumentId(documentId) : nanoid(8);
325+
326+
Log.debug('[initializeDocumentStructure] creating new structure', {
327+
docGuid: doc.guid,
328+
documentId,
329+
pageId,
330+
includeInitialParagraph,
331+
});
311332
const meta = new Y.Map();
312333
const childrenMap = new Y.Map() as YChildrenMap;
313334
const textMap = new Y.Map() as YTextMap;
@@ -357,6 +378,11 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph =
357378
textMap.set(pageId, new Y.Text());
358379

359380
sharedRoot.set(YjsEditorKey.document, document);
381+
382+
Log.debug('[initializeDocumentStructure] completed', {
383+
docGuid: doc.guid,
384+
pageId,
385+
});
360386
}
361387

362388
export function createEmptyDocument() {

src/application/view-loader/index.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import { openCollabDB } from '@/application/db';
14-
import { hasCollabCache } from '@/application/services/js-services/cache';
14+
import { getOrCreateRowSubDoc, hasCollabCache } from '@/application/services/js-services/cache';
1515
import { fetchPageCollab } from '@/application/services/js-services/fetch';
1616
import { Types, ViewLayout, YDoc, YjsDatabaseKey, YjsEditorKey, YSharedRoot } from '@/application/types';
1717
import { applyYDoc } from '@/application/ydoc/apply';
@@ -197,3 +197,53 @@ export function getDatabaseIdFromDoc(doc: YDoc): string | null {
197197
return null;
198198
}
199199
}
200+
201+
// ============================================================================
202+
// Row Sub-Document (cached)
203+
// ============================================================================
204+
205+
/**
206+
* Open a row sub-document (the document content inside a database row).
207+
*
208+
* This uses a cache to ensure the same Y.Doc instance is reused when
209+
* reopening the same card. This is critical for:
210+
* 1. Preserving sync state between opens
211+
* 2. Preventing content loss when server updates are applied
212+
* 3. Following the same pattern as the desktop application
213+
*
214+
* @param workspaceId - The workspace ID
215+
* @param documentId - The row sub-document ID
216+
*/
217+
export async function openRowSubDocument(
218+
workspaceId: string,
219+
documentId: string
220+
): Promise<ViewLoaderResult> {
221+
const startedAt = Date.now();
222+
223+
Log.debug('[ViewLoader] openRowSubDocument start', { workspaceId, documentId });
224+
225+
// Use cached doc to preserve sync state across reopens
226+
const doc = await getOrCreateRowSubDoc(documentId);
227+
228+
// Check cache
229+
const fromCache = hasCollabCache(doc);
230+
231+
Log.debug('[ViewLoader] rowSubDoc cache check', {
232+
documentId,
233+
fromCache,
234+
durationMs: Date.now() - startedAt,
235+
});
236+
237+
// Fetch from server if not cached
238+
if (!fromCache) {
239+
await fetchAndApply(workspaceId, documentId, doc);
240+
}
241+
242+
Log.debug('[ViewLoader] openRowSubDocument complete', {
243+
documentId,
244+
fromCache,
245+
totalDurationMs: Date.now() - startedAt,
246+
});
247+
248+
return { doc, fromCache, collabType: Types.Document };
249+
}

0 commit comments

Comments
 (0)