diff --git a/server/handlers/cms/data/tables.ts b/server/handlers/cms/data/tables.ts index 7f7d3ba9a..e4deedf4d 100644 --- a/server/handlers/cms/data/tables.ts +++ b/server/handlers/cms/data/tables.ts @@ -33,6 +33,7 @@ import { softDeleteDataTable, updateDataTable, createDataRow, + getDataRowBySlug, listDataRows, } from '../../../repositories/data' import { normalizeDataTableFields } from '@core/data/fields' @@ -327,6 +328,20 @@ async function handleTableRows( }) const slug = slugForTable(table, cells) + // A slug collision is an ordinary, recoverable authoring conflict, but the + // unique index raises a driver error that would otherwise surface as an + // opaque 500 — leaving the caller (often a script or an MCP connector) to + // guess whether it hit a bug or a duplicate. Name it instead. + if (slug) { + const clash = await getDataRowBySlug(db, tableId, slug) + if (clash) { + return jsonResponse( + { error: `A row with slug "${slug}" already exists in this table.`, conflictRowId: clash.id }, + { status: 409 }, + ) + } + } + const row = await createDataRow(db, { tableId, cells, slug }, user.id) await emitContentEntryCreated(db, row.id, { kind: 'user', userId: user.id }) await createAuditEvent(db, { diff --git a/src/__tests__/agent/contentCollectionRefresh.test.tsx b/src/__tests__/agent/contentCollectionRefresh.test.tsx new file mode 100644 index 000000000..b3bc7b334 --- /dev/null +++ b/src/__tests__/agent/contentCollectionRefresh.test.tsx @@ -0,0 +1,110 @@ +/** + * A Content workspace caches its collection roster at mount. A post type + * created after that — by an import, another admin, or an MCP connector + * building a site — was invisible to the bridge, so every write against it + * failed with "Collection not found" until someone reloaded the page. + * + * The bridge now refreshes the roster once before rejecting an unknown id. + * These tests exercise the resolution path only; they never reach the network + * (a rejected id fails before any row request, and the accepted cases assert + * on the refresh rather than on document creation). + */ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { renderHook, cleanup } from '@testing-library/react' +import type { DataRow, DataTable } from '@core/data/schemas' +import { useContentToolBridge } from '@admin/pages/content/agent/useContentToolBridge' +import { getContentBridgeHandle } from '@admin/pages/content/agent/contentBridgeHandle' + +function table(id: string): DataTable { + return { + id, + name: id, + slug: id, + kind: 'postType', + routeBase: `/${id}`, + fields: [], + } as unknown as DataTable +} + +/** Workspace whose roster starts stale and only learns `recipes` on refresh. */ +function staleWorkspace() { + let collections = [table('posts')] + const refreshCollections = mock(async () => { + collections = [table('posts'), table('recipes')] + return collections + }) + const selectCollection = mock(() => {}) + return { + surface: { + get collections() { return collections }, + refreshCollections, + entries: [] as DataRow[], + selectedEntry: null, + selectedCollectionId: 'posts', + selectCollection, + openEntry: () => true, + deleteEntry: async () => null, + updateEntryStatus: async (row: DataRow) => row, + updateEntryAuthor: async (row: DataRow) => row, + updateSelectedEntry: () => {}, + }, + refreshCollections, + selectCollection, + } +} + +const draft = { + setTitle: () => {}, setSlug: () => {}, setSeoTitle: () => {}, setSeoDescription: () => {}, + setFeaturedMediaId: () => {}, setBody: () => {}, setCustomCell: () => {}, applySelectedEntry: () => {}, +} +const currentUser = { id: 'u1', displayName: 'Tester', email: 't@example.invalid' } + +function mountBridge(workspace: ReturnType) { + renderHook(() => + useContentToolBridge({ + workspace: workspace.surface as never, + draft, + currentUser, + }), + ) + const handle = getContentBridgeHandle() + if (!handle) throw new Error('content bridge handle not registered') + return handle +} + +afterEach(cleanup) + +describe('content bridge collection resolution', () => { + it('refreshes the roster and selects a collection created after mount', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + expect(await handle.selectCollection('recipes')).toBe(true) + expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + expect(workspace.selectCollection).toHaveBeenCalledTimes(1) + }) + + it('does not refresh when the collection is already known', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + expect(await handle.selectCollection('posts')).toBe(true) + expect(workspace.refreshCollections).not.toHaveBeenCalled() + }) + + it('still reports a genuinely unknown collection as missing', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + expect(await handle.selectCollection('nope')).toBe(false) + expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + }) + + it('refuses to create in an unknown collection before issuing any row request', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + await expect(handle.createDocument({ tableId: 'nope' })).rejects.toThrow(/not found/) + expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/collab/writeGate.test.ts b/src/__tests__/collab/writeGate.test.ts new file mode 100644 index 000000000..f558b7512 --- /dev/null +++ b/src/__tests__/collab/writeGate.test.ts @@ -0,0 +1,102 @@ +/** + * whenCollabWritable — the gate a programmatic caller waits on. + * + * A newly bound doc is unsynced for a moment, and `applyLocalSitePatches` + * refuses every site write while any doc is in that state. Interactive editing + * never notices; a tool chain that creates a page and immediately fills it hits + * the window nearly every time, and used to get a success result for a + * mutation the relay never received. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' +import { + connectCollabProvider, + disconnectCollabProvider, +} from '@site/store/slices/site/collabBinding' +import { whenCollabWritable } from '@site/store/slices/site/collabWriteGate' +import type { + BoundCollabDoc, + CollabProvider, + CollabResetListener, +} from '@site/collab/collabProvider' +import { useEditorStore } from '@site/store/store' +import '@modules/base/index' + +/** Provider whose docs stay unsynced until the test releases them. */ +function deferredProvider(): CollabProvider & { releaseAll: () => void } { + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + const bound = new Map() + const releases: Array<() => void> = [] + const resetListeners = new Set() + return { + bind: (docId) => { + let entry = bound.get(docId) + if (!entry) { + let release = (): void => {} + const whenSynced = new Promise((resolve) => { release = () => resolve() }) + entry = { doc: new Y.Doc(), synced: false, whenSynced } + releases.push(() => { + entry!.synced = true + release() + }) + bound.set(docId, entry) + } + return entry + }, + unbind: (docId) => { + bound.get(docId)?.doc.destroy() + bound.delete(docId) + }, + awareness, + status: () => 'connected', + canSend: () => true, + reconnectNow: () => {}, + onStatus: () => () => {}, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + releaseAll: () => { for (const release of releases) release() }, + destroy: () => { + awareness.destroy() + presenceDoc.destroy() + }, + } +} + +afterEach(() => { + disconnectCollabProvider() + // These tests load a site into the shared editor store; leaving it behind + // leaks into every later suite that reads the same singleton. + useEditorStore.getState().clearSite() +}) + +describe('whenCollabWritable', () => { + it('resolves immediately when no provider is connected', async () => { + expect(await whenCollabWritable(50)).toBe(true) + }) + + it('waits for an unsynced doc and resolves once it syncs', async () => { + // A loaded site is what binds docs; an empty workspace has nothing to gate. + useEditorStore.getState().createSite('Gate Site') + const provider = deferredProvider() + connectCollabProvider(provider) + + let settled = false + const gate = whenCollabWritable(5_000).then((ok) => { settled = true; return ok }) + + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(settled).toBe(false) + + provider.releaseAll() + expect(await gate).toBe(true) + }) + + it('reports failure rather than hanging when sync never arrives', async () => { + useEditorStore.getState().createSite('Stuck Site') + connectCollabProvider(deferredProvider()) + expect(await whenCollabWritable(120)).toBe(false) + }) +}) diff --git a/src/admin/pages/content/agent/useContentToolBridge.ts b/src/admin/pages/content/agent/useContentToolBridge.ts index f73216899..953bc3010 100644 --- a/src/admin/pages/content/agent/useContentToolBridge.ts +++ b/src/admin/pages/content/agent/useContentToolBridge.ts @@ -33,6 +33,8 @@ const CONTENT_KIND_VISIBLE: ReadonlySet = new Set(['postType']) interface ContentToolWorkspaceSurface { collections: DataTable[] + /** Re-reads the roster from the server and returns the fresh post types. */ + refreshCollections(): Promise entries: DataRow[] selectedEntry: DataRow | null selectedCollectionId: string | null @@ -77,6 +79,29 @@ export function useContentToolBridge({ }) useEffect(() => { + /** + * Find a Content-visible collection, refreshing the roster once if the id + * is unknown. + * + * The workspace caches its collections at mount, so a table created after + * that — by an import, another admin, or an MCP connector building a site + * — is invisible here and every write against it fails with "not found" + * until someone reloads the page. One refresh distinguishes "created a + * moment ago" from "does not exist". + */ + const resolveCollection = async (tableId: string): Promise => { + const visible = (table: DataTable | undefined): DataTable | null => + table && CONTENT_KIND_VISIBLE.has(table.kind) ? table : null + + const cached = visible( + workspaceRef.current.collections.find((candidate) => candidate.id === tableId), + ) + if (cached) return cached + + const refreshed = await workspaceRef.current.refreshCollections() + return visible(refreshed.find((candidate) => candidate.id === tableId)) + } + const handle: ContentBridgeHandle = { buildSnapshot() { return buildSnapshotFromWorkspace( @@ -106,16 +131,13 @@ export function useContentToolBridge({ return opened }, async selectCollection(tableId) { - const ws = workspaceRef.current - const table = ws.collections.find((candidate) => candidate.id === tableId) - if (!table || !CONTENT_KIND_VISIBLE.has(table.kind)) return false - flushSync(() => ws.selectCollection(tableId)) + const table = await resolveCollection(tableId) + if (!table) return false + flushSync(() => workspaceRef.current.selectCollection(tableId)) return true }, async createDocument({ tableId, fields }) { - const ws = workspaceRef.current - const table = ws.collections.find((candidate) => candidate.id === tableId) - if (!table || !CONTENT_KIND_VISIBLE.has(table.kind)) { + if (!(await resolveCollection(tableId))) { throw new Error(`Collection ${tableId} not found.`) } const cells = fields ? normalizeEditableFields(fields) : {} diff --git a/src/admin/pages/content/hooks/useContentWorkspace.ts b/src/admin/pages/content/hooks/useContentWorkspace.ts index 31ba8aeb0..2f2e7ea63 100644 --- a/src/admin/pages/content/hooks/useContentWorkspace.ts +++ b/src/admin/pages/content/hooks/useContentWorkspace.ts @@ -118,6 +118,26 @@ export function useContentWorkspace({ setEntries((current) => updateRowList(current, entry)) } + /** + * Re-read the collection roster from the server and return it. + * + * The mount-time load is a snapshot: a collection created after it — by an + * import, another admin, or an MCP connector — is invisible to this + * workspace until a reload, and every write against it fails with + * "Collection not found". Callers that hit an unknown id refresh through + * here and retry rather than making the operator reload the page. + * + * Returns the fresh post-type list directly, so a caller can act on it in + * the same tick instead of waiting for a re-render. + */ + const refreshCollections = useCallback(async (): Promise => { + const allTables = await listCmsDataTables() + const nextCollections = allTables.filter((table) => table.kind === 'postType') + setTables(allTables) + setCollections(nextCollections) + return nextCollections + }, []) + useEffect(() => { let cancelled = false @@ -488,6 +508,7 @@ export function useContentWorkspace({ return { tables, collections, + refreshCollections, entries, authors, authorsLoading, diff --git a/src/admin/pages/site/agent/executor.ts b/src/admin/pages/site/agent/executor.ts index 30a14d0df..efa2e1534 100644 --- a/src/admin/pages/site/agent/executor.ts +++ b/src/admin/pages/site/agent/executor.ts @@ -73,6 +73,8 @@ import { importHtml } from '@core/htmlImport' import type { BaseNode, PageTemplateConfig } from '@core/page-tree' import { renderNode, type RenderConfig, type RenderAccumulators } from '@core/publisher' import { getAgentStoreApi } from './storeRef' +import { whenCollabWritable } from '@site/store/slices/site/collabWriteGate' +import { AUTO_NAVIGATE_TOOLS, SITE_MUTATION_TOOLS } from './toolClassification' import { runSetColorTokens, runSetFontTokens, @@ -188,24 +190,6 @@ function nodeNotInActiveDocError(store: EditorStore, nodeId: string): AiToolOutp ) } -/** - * Tools that target an existing node (by `nodeId`/`parentId`) and should pull - * the canvas to that node's document before running. Excludes catalog/page/ - * token tools (no node target) and `site_render_snapshot` (captures the live DOM, so - * a node outside the mounted canvas is genuinely uncapturable, not navigable). - */ -const AUTO_NAVIGATE_TOOLS = new Set([ - 'site_insert_html', - 'site_get_node_html', - 'site_replace_node_html', - 'site_delete_node', - 'site_update_node_props', - 'site_move_node', - 'site_rename_node', - 'site_duplicate_node', - 'site_assign_class', - 'site_remove_class', -]) /** Pull the node/parent id a write tool targets out of its raw input bag. */ function targetNodeIdFromInput(raw: unknown): string | undefined { @@ -290,6 +274,17 @@ function runInsertHtml(input: InsertHtmlInput): AiToolOutput { } for (const rootId of insertedRootIds) visit(rootId) + // `insertedRootIds` are the ids the insert INTENDED to create. If none of + // them is in the store afterwards the mutation was refused (collab gate, + // offline transport), and reporting those ids would claim a subtree that + // does not exist — the caller then builds on nodes the server never saw. + if (created.length === 0) { + return aiToolError( + 'Insert was refused before it reached the store, so nothing was created. ' + + 'The editor is usually still syncing; retry shortly.', + ) + } + return aiToolOk({ nodeIds: insertedRootIds, created }) } @@ -581,6 +576,16 @@ export async function executeAgentTool( rawInput: unknown, ): Promise { try { + // A write refused by the collab sync gate never reaches the relay, so wait + // for the gate to open rather than reporting a success the server will + // never see. Sub-second in practice; the deadline exists so a genuinely + // stuck socket surfaces as an error instead of hanging the tool call. + if (SITE_MUTATION_TOOLS.has(toolName) && !(await whenCollabWritable())) { + return aiToolError( + 'Editor is still syncing with the collaboration relay; the write was not applied. Retry shortly.', + ) + } + // Auto-navigate: if a node-targeting tool references a node that lives in a // different document, switch the canvas to that document BEFORE running, so // the mutation lands in the right tree and stays visible to the user. diff --git a/src/admin/pages/site/agent/toolClassification.ts b/src/admin/pages/site/agent/toolClassification.ts new file mode 100644 index 000000000..3d71f7057 --- /dev/null +++ b/src/admin/pages/site/agent/toolClassification.ts @@ -0,0 +1,63 @@ +/** + * Tool classification — which agent tools need which pre-flight step. + * + * Kept beside the executor rather than inside it so adding a tool means + * editing one table, and the dispatch stays a dispatch. + */ + +/** + * Tools that target a node by id and should switch the canvas to that node's + * document before running, so the mutation lands in the right tree and stays + * visible to the user. + * + * Excludes catalog/page/token tools (no node target) and + * `site_render_snapshot` (captures the live DOM, so a node outside the + * mounted canvas is genuinely uncapturable, not navigable). + */ +export const AUTO_NAVIGATE_TOOLS: ReadonlySet = new Set([ + 'site_insert_html', + 'site_get_node_html', + 'site_replace_node_html', + 'site_delete_node', + 'site_update_node_props', + 'site_move_node', + 'site_rename_node', + 'site_duplicate_node', + 'site_assign_class', + 'site_remove_class', +]) + +/** + * Tools that mutate the site document, and therefore must wait for every + * collab doc to finish its first sync before running. + * + * A write landing inside that window is refused wholesale — correctly, since + * it could never reach the relay — but the tool would still report the ids it + * meant to create, so the caller saw success and an empty document. Human + * editing never hits this (the gate is sub-second); a tool chain that creates + * a page and immediately fills it hits it nearly every time. + */ +export const SITE_MUTATION_TOOLS: ReadonlySet = new Set([ + 'site_insert_html', + 'site_replace_node_html', + 'site_delete_node', + 'site_update_node_props', + 'site_move_node', + 'site_rename_node', + 'site_duplicate_node', + 'site_assign_class', + 'site_remove_class', + 'site_apply_css', + 'site_add_page', + 'site_delete_page', + 'site_rename_page', + 'site_duplicate_page', + 'site_set_page_template', + 'site_clear_page_template', + 'site_set_color_tokens', + 'site_set_font_tokens', + 'site_set_type_scale', + 'site_set_spacing_scale', + 'site_write_code_asset', + 'site_patch_code_asset', +]) diff --git a/src/admin/pages/site/store/slices/filesSlice.ts b/src/admin/pages/site/store/slices/filesSlice.ts index a741dbb13..c4cfc822d 100644 --- a/src/admin/pages/site/store/slices/filesSlice.ts +++ b/src/admin/pages/site/store/slices/filesSlice.ts @@ -21,6 +21,7 @@ import type { EditorStoreSliceCreator } from '@site/store/types' import type { SiteFile, SiteFileType } from '@core/files/schemas' import { isSafePath, normalizePath } from '@core/files/pathValidation' import { reconcileSiteExplorerInPlace } from '@core/page-tree' +import { buildSiteHelpers } from './site/helpers' // --------------------------------------------------------------------------- // Slice interface @@ -78,7 +79,17 @@ declare module '@site/store/types' { interface EditorStore extends FilesSlice {} } -export const createFilesSlice: EditorStoreSliceCreator = (set, get) => ({ +/** + * Every action routes through `mutateSiteState` rather than a raw `set`. + * Site persistence is the collab relay: only mutations that produce site + * patches are translated into Y operations, so a direct `set` updates this + * tab's store and reaches neither the relay nor the database — the file looks + * written, survives a readback, and is gone on reload. + */ +export const createFilesSlice: EditorStoreSliceCreator = (set, get) => { + const { mutateSiteState } = buildSiteHelpers(set, get) + + return { createFile(path, type, content) { const { site } = get() if (!site) throw new Error('[filesSlice] Site document is not initialized') @@ -96,39 +107,40 @@ export const createFilesSlice: EditorStoreSliceCreator = (set, get) const now = Date.now() const id = nanoid() - set((state) => { - if (!state.site) return - const newFile: SiteFile = { - id, - path: normalized, - type, - // For non-asset types, initialize content to provided value or empty string - content: type !== 'asset' ? (content ?? '') : undefined, - createdAt: now, - updatedAt: now, - } - state.site.files.push(newFile) - reconcileSiteExplorerInPlace(state.site) - state.site.updatedAt = now - }) + mutateSiteState((state, siteDraft) => { + const newFile: SiteFile = { + id, + path: normalized, + type, + // For non-asset types, initialize content to provided value or empty string + content: type !== 'asset' ? (content ?? '') : undefined, + createdAt: now, + updatedAt: now, + } + siteDraft.files.push(newFile) + reconcileSiteExplorerInPlace(siteDraft) + siteDraft.updatedAt = now + void state + return true + }) return id }, deleteFile(id) { - set((state) => { - if (!state.site) return - const idx = state.site.files.findIndex((f) => f.id === id) - if (idx === -1) return - state.site.files.splice(idx, 1) - if (state.site.runtime?.scripts) delete state.site.runtime.scripts[id] - if (state.site.runtime?.styles) delete state.site.runtime.styles[id] - delete state.siteRuntime.scripts[id] - delete state.siteRuntime.styles[id] - if (state.activeEditorFileId === id) state.activeEditorFileId = null - reconcileSiteExplorerInPlace(state.site) - state.site.updatedAt = Date.now() - }) + mutateSiteState((state, siteDraft) => { + const idx = siteDraft.files.findIndex((f) => f.id === id) + if (idx === -1) return false + siteDraft.files.splice(idx, 1) + if (siteDraft.runtime?.scripts) delete siteDraft.runtime.scripts[id] + if (siteDraft.runtime?.styles) delete siteDraft.runtime.styles[id] + delete state.siteRuntime.scripts[id] + delete state.siteRuntime.styles[id] + if (state.activeEditorFileId === id) state.activeEditorFileId = null + reconcileSiteExplorerInPlace(siteDraft) + siteDraft.updatedAt = Date.now() + return true + }) }, renameFile(id, newPath) { @@ -146,37 +158,41 @@ export const createFilesSlice: EditorStoreSliceCreator = (set, get) throw new Error(`[filesSlice] A file at path "${normalized}" already exists`) } - set((state) => { - if (!state.site) return - const file = state.site.files.find((f) => f.id === id) - if (!file) return - file.path = normalized - file.updatedAt = Date.now() - state.site.updatedAt = Date.now() - }) + mutateSiteState((state, siteDraft) => { + const file = siteDraft.files.find((f) => f.id === id) + if (!file) return false + file.path = normalized + file.updatedAt = Date.now() + siteDraft.updatedAt = Date.now() + void state + return true + }) }, updateFileContent(id, content) { - set((state) => { - if (!state.site) return - const file = state.site.files.find((f) => f.id === id) - if (!file) return - file.content = content - if (file.generated) file.ejected = true - file.updatedAt = Date.now() - state.site.updatedAt = Date.now() - }) + mutateSiteState((state, siteDraft) => { + const file = siteDraft.files.find((f) => f.id === id) + if (!file) return false + file.content = content + if (file.generated) file.ejected = true + file.updatedAt = Date.now() + siteDraft.updatedAt = Date.now() + void state + return true + }) }, updateFileBlob(id, blob) { - set((state) => { - if (!state.site) return - const file = state.site.files.find((f) => f.id === id) - if (!file) return - file.blob = blob - if (file.generated) file.ejected = true - file.updatedAt = Date.now() - state.site.updatedAt = Date.now() - }) + mutateSiteState((state, siteDraft) => { + const file = siteDraft.files.find((f) => f.id === id) + if (!file) return false + file.blob = blob + if (file.generated) file.ejected = true + file.updatedAt = Date.now() + siteDraft.updatedAt = Date.now() + void state + return true + }) }, -}) + } +} diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 2f34d6473..a557c0586 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -73,6 +73,8 @@ import { type BlockedReason, type LocalPatchOutcome, } from './collabNotices' +import { anyGateUnsynced, clearProviderGates, hasProviderGate, registerProviderGate, setGatesActive } from './collabWriteGate' + interface ManagedDoc { doc: Y.Doc manager: Y.UndoManager @@ -238,9 +240,7 @@ export function applyLocalSitePatches( // Every already-bound doc must be past its first sync before edits may // stream — writing into an unseeded doc would duplicate server content // on merge. (Sub-second in practice; the toolbar shows "connecting".) - for (const [, binding] of providerBindings) { - if (!binding.synced) return { accepted: false, reason: 'syncing' } - } + if (anyGateUnsynced()) return { accepted: false, reason: 'syncing' } } if (!provider && preSite !== alignedSiteRef) { @@ -548,8 +548,6 @@ function projectDocIntoStore(docId: string): void { // Lifecycle + provider connection // --------------------------------------------------------------------------- -const providerBindings = new Map() - function allDocIdsForSite(site: SiteDocument): string[] { return [ SITE_DOC_ID, @@ -568,7 +566,7 @@ export function resetCollabDocsFromSite(site: SiteDocument | null): void { alignedSiteRef = site for (const [, entry] of managed) entry.detach() managed = new Map() - providerBindings.clear() + clearProviderGates() const previous = docs docs = createCollabDocSet() for (const [docId] of previous.entries()) { @@ -615,18 +613,12 @@ function seedDetachedDocs(site: SiteDocument): void { } } -function adoptProviderDoc(docId: string, doc: Y.Doc): { synced: boolean } { - docs.set(docId, doc) - ensureManaged(docId, doc) - const gate = { synced: false } - providerBindings.set(docId, gate) - return gate -} - function bindDocThroughProvider(docId: string): void { - if (!provider || providerBindings.has(docId)) return + if (!provider || hasProviderGate(docId)) return const binding = provider.bind(docId) - const gate = adoptProviderDoc(docId, binding.doc) + docs.set(docId, binding.doc) + ensureManaged(docId, binding.doc) + const gate = registerProviderGate(docId, binding.whenSynced) gate.synced = binding.synced void binding.whenSynced.then(() => { gate.synced = true @@ -649,6 +641,7 @@ function bindThroughProvider(docIds: readonly string[]): void { */ export function connectCollabProvider(next: CollabProvider): void { provider = next + setGatesActive(true) detachProviderStatus?.() // A recovered transport re-arms the block notice, so the NEXT outage speaks // up instead of being swallowed by the previous one's latch. @@ -674,7 +667,9 @@ export function connectCollabProvider(next: CollabProvider): void { // The server dropped this doc: rebind and let the fresh server seed // re-project into the store. const rebound = next.bind(docId) - const gate = adoptProviderDoc(docId, rebound.doc) + docs.set(docId, rebound.doc) + ensureManaged(docId, rebound.doc) + const gate = registerProviderGate(docId, rebound.whenSynced) gate.synced = rebound.synced void rebound.whenSynced.then(() => { gate.synced = true @@ -693,7 +688,8 @@ export function disconnectCollabProvider(): void { detachProviderStatus = null provider?.destroy() provider = null - providerBindings.clear() + setGatesActive(false) + clearProviderGates() const site = storeApi?.getState().site ?? null resetCollabDocsFromSite(site) notifyProviderChange() diff --git a/src/admin/pages/site/store/slices/site/collabWriteGate.ts b/src/admin/pages/site/store/slices/site/collabWriteGate.ts new file mode 100644 index 000000000..404e8c783 --- /dev/null +++ b/src/admin/pages/site/store/slices/site/collabWriteGate.ts @@ -0,0 +1,78 @@ +/** + * Collab write gate — which bound docs are past their first sync, and how to + * wait for that. + * + * A doc bound through the provider starts unsynced, and site writes are + * refused wholesale while ANY bound doc is in that state: writing into an + * unseeded doc would duplicate server content on merge. The window is + * sub-second, so interactive editing never notices it. + * + * Programmatic callers do. A tool chain that creates a page and immediately + * fills it lands inside the window nearly every time, and a refused write + * reaches neither the relay nor the database — so such callers wait on + * {@link whenCollabWritable} before mutating rather than reporting a success + * the server never saw. + * + * This module owns only the gate registry. `collabBinding` owns the docs. + */ + +export interface ProviderGate { + synced: boolean + /** Resolves on this doc's first sync. */ + whenSynced: Promise +} + +const providerGates = new Map() + +/** True once a provider is attached — gates are meaningless in detached mode. */ +let gatesActive = false + +export function setGatesActive(active: boolean): void { + gatesActive = active +} + +export function registerProviderGate(docId: string, whenSynced: Promise): ProviderGate { + const gate: ProviderGate = { synced: false, whenSynced } + providerGates.set(docId, gate) + return gate +} + +export function hasProviderGate(docId: string): boolean { + return providerGates.has(docId) +} + +export function clearProviderGates(): void { + providerGates.clear() +} + +/** True when at least one bound doc has not finished its first sync. */ +export function anyGateUnsynced(): boolean { + for (const gate of providerGates.values()) { + if (!gate.synced) return true + } + return false +} + +/** + * Resolves once every bound doc is past its first sync — the moment site + * writes stop being refused with `syncing`. + * + * Resolves `false` if the deadline passes with docs still unsynced, so the + * caller reports a real failure instead of writing into a store the server + * will never agree with. + */ +export async function whenCollabWritable(timeoutMs = 15_000): Promise { + if (!gatesActive) return true + const deadline = Date.now() + timeoutMs + // Re-read the registry each pass: waiting on one doc can bind further docs. + for (;;) { + const pending = [...providerGates.values()].filter((gate) => !gate.synced) + if (pending.length === 0) return true + const remaining = deadline - Date.now() + if (remaining <= 0) return false + await Promise.race([ + Promise.all(pending.map((gate) => gate.whenSynced)), + new Promise((resolve) => setTimeout(resolve, Math.min(250, remaining))), + ]) + } +}