diff --git a/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts b/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts index c5458ec6..1fb56b07 100644 --- a/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts +++ b/src/application/publish-snapshot/__fixtures__/published-page-snapshots.ts @@ -18,7 +18,7 @@ export const publishedDocumentPayload: PublishedDocumentSnapshotPayload = { namespace: 'published-namespace', publishName: 'published-document', view: { - viewId: 'published-document-view-id', + viewId: '6e91148b-e42a-56b1-b9a0-58fbaa31552d', name: 'Published document', icon: { ty: ViewIconType.Icon, diff --git a/src/application/slate-yjs/__tests__/yjs-utils.test.ts b/src/application/slate-yjs/__tests__/yjs-utils.test.ts index 98c8e753..9764e37d 100644 --- a/src/application/slate-yjs/__tests__/yjs-utils.test.ts +++ b/src/application/slate-yjs/__tests__/yjs-utils.test.ts @@ -4,6 +4,7 @@ import * as Y from 'yjs'; import { createBlock, createEmptyDocument, + deleteBlock, getBlock, getChildrenArray, getText, @@ -78,11 +79,12 @@ describe('pageIdFromDocumentId', () => { // Should be a valid UUID format expect(pageId1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(pageId1).toBe('69d1e3fb-c2f3-5156-b8e3-636273bad252'); }); it('should generate different page_ids for different document_ids', () => { const documentId1 = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; - const documentId2 = '7f02259c-f53b-67c2-c1b1-69gcbb42663e'; + const documentId2 = '7f02259c-f53b-67c2-a1b1-69fcbb42663e'; const pageId1 = pageIdFromDocumentId(documentId1); const pageId2 = pageIdFromDocumentId(documentId2); @@ -171,6 +173,63 @@ describe('initializeDocumentStructure', () => { expect(pageId).toBe(expectedPageId); }); + it('should use document-scoped initial paragraph ids when documentId is provided', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + initializeDocumentStructure(doc, true, documentId); + + const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const document = sharedRoot.get(YjsEditorKey.document); + const pageId = document.get(YjsEditorKey.page_id); + const blocks = document.get(YjsEditorKey.blocks); + const meta = document.get(YjsEditorKey.meta); + const childrenMap = meta.get(YjsEditorKey.children_map); + const textMap = meta.get(YjsEditorKey.text_map); + const pageChildren = childrenMap.get(pageId); + const paragraphId = pageChildren.get(0); + const paragraphBlock = blocks.get(paragraphId); + + expect(paragraphId).toBe('TdtM1tmgqJ'); + expect(paragraphBlock.get(YjsEditorKey.block_children)).toBe('NyYr0DfnAu'); + expect(paragraphBlock.get(YjsEditorKey.block_external_id)).toBe('VbaxI-lEf5'); + expect(childrenMap.has('NyYr0DfnAu')).toBe(true); + expect(textMap.has('VbaxI-lEf5')).toBe(true); + }); + + it('should not override the local Yjs client id when initializing document structure', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + const originalClientId = doc.clientID; + + initializeDocumentStructure(doc, true, documentId); + + expect(doc.clientID).toBe(originalClientId); + }); + + it('should clean up document-scoped paragraph children and text ids when deleted', () => { + const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d'; + + initializeDocumentStructure(doc, true, documentId); + + const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const document = sharedRoot.get(YjsEditorKey.document); + const pageId = document.get(YjsEditorKey.page_id); + const blocks = document.get(YjsEditorKey.blocks); + const meta = document.get(YjsEditorKey.meta); + const childrenMap = meta.get(YjsEditorKey.children_map); + const textMap = meta.get(YjsEditorKey.text_map); + const pageChildren = childrenMap.get(pageId); + const paragraphId = pageChildren.get(0); + const paragraphBlock = blocks.get(paragraphId); + const paragraphChildrenId = paragraphBlock.get(YjsEditorKey.block_children); + const paragraphTextId = paragraphBlock.get(YjsEditorKey.block_external_id); + + deleteBlock(sharedRoot, paragraphId); + + expect(blocks.has(paragraphId)).toBe(false); + expect(childrenMap.has(paragraphChildrenId)).toBe(false); + expect(textMap.has(paragraphTextId)).toBe(false); + expect(pageChildren.toArray()).toEqual([]); + }); + it('should skip initialization if document already exists', () => { // First initialization initializeDocumentStructure(doc, false); diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index 1c39f8c9..5d1ec6f0 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -24,6 +24,8 @@ import { } from '@/application/types'; import { Log } from '@/utils/log'; +const RUST_NANOID_SAFE_ALPHABET = '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const DEFAULT_ID_LEN = 10; // UUID namespace OID (same as Rust's Uuid::NAMESPACE_OID) // Note: 6ba7b812 (not 6ba7b810 which is NAMESPACE_DNS) const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; @@ -60,6 +62,25 @@ export function pageIdFromDocumentId(documentId: string): string { return pageId; } +function idFromDocumentId(documentId: string, role: string): string { + const docUuid = uuidValidate(documentId) ? documentId : uuidv5(documentId, UUID_NAMESPACE_OID); + + return uuidv5(role, docUuid); +} + +function nanoidFromDocumentId(documentId: string, role: string): string { + const uuid = idFromDocumentId(documentId, role).replace(/-/g, ''); + let id = ''; + + for (let i = 0; i < DEFAULT_ID_LEN; i += 1) { + const byte = parseInt(uuid.slice(i * 2, i * 2 + 2), 16); + + id += RUST_NANOID_SAFE_ALPHABET[byte & 0x3f]; + } + + return id; +} + export function getTextMap(sharedRoot: YSharedRoot) { const document = sharedRoot.get(YjsEditorKey.document); const meta = document.get(YjsEditorKey.meta) as YMeta; @@ -355,21 +376,23 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph = if (includeInitialParagraph) { // Create an empty paragraph block as child of page // The Slate editor requires at least one text block to render properly - const paragraphId = nanoid(8); + const paragraphId = documentId ? nanoidFromDocumentId(documentId, 'block') : nanoid(8); + const paragraphChildrenId = documentId ? nanoidFromDocumentId(documentId, 'children') : paragraphId; + const paragraphTextId = documentId ? nanoidFromDocumentId(documentId, 'text') : paragraphId; const paragraphBlock = new Y.Map(); paragraphBlock.set(YjsEditorKey.block_id, paragraphId); paragraphBlock.set(YjsEditorKey.block_type, BlockType.Paragraph); - paragraphBlock.set(YjsEditorKey.block_children, paragraphId); - paragraphBlock.set(YjsEditorKey.block_external_id, paragraphId); + paragraphBlock.set(YjsEditorKey.block_children, paragraphChildrenId); + paragraphBlock.set(YjsEditorKey.block_external_id, paragraphTextId); paragraphBlock.set(YjsEditorKey.block_external_type, YjsEditorKey.text); paragraphBlock.set(YjsEditorKey.block_data, '{}'); paragraphBlock.set(YjsEditorKey.block_parent, pageId); blocks.set(paragraphId, paragraphBlock); pageChildren.push([paragraphId]); - childrenMap.set(paragraphId, new Y.Array()); - textMap.set(paragraphId, new Y.Text()); + childrenMap.set(paragraphChildrenId, new Y.Array()); + textMap.set(paragraphTextId, new Y.Text()); } childrenMap.set(pageId, pageChildren); @@ -448,6 +471,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { const meta = document.get(YjsEditorKey.meta) as YMeta; const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap; const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; + const blockChildrenId = block.get(YjsEditorKey.block_children); + const blockExternalId = block.get(YjsEditorKey.block_external_id); const parent = getBlock(parentId, sharedRoot); @@ -465,8 +490,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { } blocks.delete(blockId); - childrenMap.delete(blockId); - textMap.delete(blockId); + childrenMap.delete(blockChildrenId); + textMap.delete(blockExternalId); // delete parent if it's empty column block if (parentType === BlockType.ColumnBlock && afterDeletedLength === 0) { diff --git a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index f918de06..a230b4e6 100644 --- a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -15,7 +15,7 @@ import { useUpdateRowMetaDispatch } from '@/application/database-yjs/dispatch'; import { openCollabDB } from '@/application/db'; import { getCachedRowSubDoc, getOrCreateRowSubDoc, trackRowDocEnsure } from '@/application/services/js-services/cache'; import { YjsEditor } from '@/application/slate-yjs'; -import { dataStringTOJson, initializeDocumentStructure } from '@/application/slate-yjs/utils/yjs'; +import { dataStringTOJson } from '@/application/slate-yjs/utils/yjs'; import { BlockType, CollabOrigin, @@ -129,7 +129,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const lastIsEmptyRef = useRef(null); const pendingMetaUpdateRef = useRef | null>(null); const pendingNonEmptyRef = useRef(false); - const pendingOpenLocalRef = useRef(false); const docReadyRef = useRef(false); // Track if document is loaded to prevent retry timer from resetting it const rowDocEnsuredRef = useRef(false); // Track if row document has been ensured on server to avoid redundant API calls @@ -378,51 +377,10 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { [rowId] ); - // Fallback: Open document with local structure (when server unavailable) - const openLocalDocument = useCallback( - async (documentId: string) => { - if (!documentId) return; - try { - docReadyRef.current = false; - setDoc(null); - - // Use cached doc to preserve sync state across reopens - // This ensures the same Y.Doc instance is reused when reopening, - // preventing content loss from "different doc instance" sync replacement - const doc = await getOrCreateRowSubDoc(documentId); - - // Initialize with empty document structure if needed - // Pass true to include initial paragraph - required for Slate editor to render - // Pass documentId to ensure page_id matches server's algorithm - initializeDocumentStructure(doc, true, documentId); - - // Store metadata for sync binding - const docWithMeta = doc as YDocWithMeta; - - docWithMeta.object_id = documentId; - docWithMeta.view_id = documentId; - docWithMeta._collabType = Types.Document; - docWithMeta._syncBound = false; - - setDoc(doc); - docReadyRef.current = true; - Log.debug('[DatabaseRowSubDocument] openLocalDocument ready', { - rowId, - documentId, - }); - // eslint-disable-next-line - } catch (e: any) { - Log.error('[DatabaseRowSubDocument] openLocalDocument failed', e); - } - }, - [rowId] - ); - const handleCreateDocument = useCallback( async (documentId: string, requireServerReady: boolean = false): Promise => { if (!documentId) return false; setLoading(true); - let opened = false; let docState: Uint8Array | null = null; Log.debug('[DatabaseRowSubDocument] handleCreateDocument', { @@ -438,72 +396,63 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] local doc has content; skipping server create', { + Log.debug('[DatabaseRowSubDocument] local doc has content; creating server collab before binding', { rowId, documentId, }); - await openLocalDocument(documentId); - opened = true; - return true; } - if (requireServerReady) { - if (!createRowDocument) { - Log.debug('[DatabaseRowSubDocument] createRowDocument not available, returning false'); - setLoading(false); // Clear loading on early failure - return false; - } + if (!createRowDocument) { + Log.debug('[DatabaseRowSubDocument] createRowDocument not available; cannot open synced row document', { + documentId, + requireServerReady, + }); + return false; + } - try { - Log.debug('[DatabaseRowSubDocument] calling createRowDocument', { documentId }); - docState = await createRowDocument(documentId); - Log.debug('[DatabaseRowSubDocument] createRowDocument success', { - documentId, - docStateSize: docState?.length ?? 0, - }); - } catch (e) { - Log.error('[DatabaseRowSubDocument] createRowDocument failed', e); - setLoading(false); // Clear loading on error - return false; - } - } else if (createRowDocument) { - try { - Log.debug('[DatabaseRowSubDocument] calling createRowDocument (non-blocking)', { documentId }); - docState = await createRowDocument(documentId); - Log.debug('[DatabaseRowSubDocument] createRowDocument success (non-blocking)', { - documentId, - docStateSize: docState?.length ?? 0, - }); - } catch (e) { - Log.warn('[DatabaseRowSubDocument] createRowDocument failed (continuing)', e); - // Continue to local document if server create fails. - } + try { + Log.debug('[DatabaseRowSubDocument] calling createRowDocument', { documentId, requireServerReady }); + docState = await createRowDocument(documentId); + Log.debug('[DatabaseRowSubDocument] createRowDocument success', { + documentId, + docStateSize: docState?.length ?? 0, + }); + } catch (e) { + Log.error('[DatabaseRowSubDocument] createRowDocument failed', e); + return false; } - // Use server's doc_state if available, otherwise create structure locally + // Use the server-created doc_state as the only source of default document structure. if (docState && docState.length > 0) { const success = await openDocumentWithState(documentId, docState); - if (!success) { - Log.warn('[DatabaseRowSubDocument] server doc_state invalid, falling back to local', { - documentId, - docStateSize: docState.length, - }); - await openLocalDocument(documentId); + if (success) { + return true; } - } else { - await openLocalDocument(documentId); + + Log.warn('[DatabaseRowSubDocument] server doc_state invalid; will retry without local fallback', { + documentId, + docStateSize: docState.length, + }); } - opened = true; - return true; - } finally { - if (opened || !requireServerReady) { - setLoading(false); + if (loadRowDocument) { + const loaded = await handleOpenDocument(documentId); + + if (loaded) { + return true; + } } + + Log.warn('[DatabaseRowSubDocument] row document was not opened after server create', { + documentId, + }); + return false; + } finally { + setLoading(false); } }, - [createRowDocument, hasLocalDocContent, openDocumentWithState, openLocalDocument, rowId] + [createRowDocument, handleOpenDocument, hasLocalDocContent, loadRowDocument, openDocumentWithState, rowId] ); const scheduleEnsureRowDocumentExists = useCallback(() => { @@ -531,12 +480,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { await createRowDocument(documentId); } - if (pendingOpenLocalRef.current && (exists || createRowDocument)) { - pendingOpenLocalRef.current = false; - await openLocalDocument(documentId); - setLoading(false); - } - if (pendingNonEmptyRef.current) { const editor = editorRef.current; @@ -565,7 +508,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { documentId, isDocumentEmpty, updateRowMeta, - openLocalDocument, rowId, ]); @@ -612,19 +554,17 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { retryLoadTimerRef.current = null; - // After max retries, create the document anyway unless we already have local content + // After max retries, create the document on the server. Do not initialize a local + // synced document here; the server doc_state is the source of the default structure. if (retryCount >= MAX_RETRIES) { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] max retries reached; local content found, opening local doc', { + Log.debug('[DatabaseRowSubDocument] max retries reached; local content found, creating server doc before binding', { rowId, documentId, retryCount, }); - await openLocalDocument(documentId); - setLoading(false); - return; } Log.debug('[DatabaseRowSubDocument] max retries reached; creating document', { @@ -632,7 +572,12 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { documentId, retryCount, }); - void handleCreateDocument(documentId, true); + const created = await handleCreateDocument(documentId, true); + + if (!created && !cancelled) { + scheduleRetry(); + } + return; } @@ -678,7 +623,11 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { rowId, documentId, }); - await handleCreateDocument(documentId, false); + const created = await handleCreateDocument(documentId, false); + + if (!created && !cancelled) { + scheduleRetry(); + } return; } @@ -689,16 +638,19 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const localHasContent = await hasLocalDocContent(documentId); if (localHasContent) { - Log.debug('[DatabaseRowSubDocument] doc not found; local content found, opening local doc', { + Log.debug('[DatabaseRowSubDocument] doc not found; local content found, creating server doc before binding', { rowId, documentId, }); - await openLocalDocument(documentId); - setLoading(false); - return; } - void handleCreateDocument(documentId, true); + void (async () => { + const created = await handleCreateDocument(documentId, true); + + if (!created && !cancelled) { + scheduleRetry(); + } + })(); return; } @@ -768,11 +720,9 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { checkIfRowDocumentExists, isDocumentEmptyResolved, hasLocalDocContent, - scheduleEnsureRowDocumentExists, createRowDocument, rowId, doc, - openLocalDocument, ]); useEffect(() => { diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index ce401c81..d24f0b85 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -76,6 +76,8 @@ export interface EditorContextState { loadViewMeta?: LoadViewMeta; loadView?: LoadView; loadRowDocument?: (documentId: string) => Promise; + checkIfRowDocumentExists?: (documentId: string) => Promise; + createRowDocument?: (documentId: string) => Promise; createRow?: CreateRow; bindViewSync?: (doc: YDoc) => SyncContext | null; readSummary?: boolean; @@ -121,6 +123,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, @@ -204,6 +208,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, @@ -244,6 +250,8 @@ export const EditorContextProvider = ({ loadViewMeta, loadView, loadRowDocument, + checkIfRowDocumentExists, + createRowDocument, createRow, bindViewSync, readSummary, diff --git a/src/components/editor/__tests__/EditorContext.test.tsx b/src/components/editor/__tests__/EditorContext.test.tsx new file mode 100644 index 00000000..5ab8b055 --- /dev/null +++ b/src/components/editor/__tests__/EditorContext.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; + +import { EditorContextProvider, useEditorContext } from '../EditorContext'; + +function RowDocumentContextProbe() { + const context = useEditorContext(); + + return ( +
+ ); +} + +describe('EditorContextProvider', () => { + it('preserves row document operations for embedded databases', () => { + render( + + + + ); + + const probe = screen.getByTestId('row-document-context'); + + expect(probe.getAttribute('data-has-check')).toBe('true'); + expect(probe.getAttribute('data-has-create')).toBe('true'); + expect(probe.getAttribute('data-has-load')).toBe('true'); + }); +});