Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions server/handlers/cms/data/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
softDeleteDataTable,
updateDataTable,
createDataRow,
getDataRowBySlug,
listDataRows,
} from '../../../repositories/data'
import { normalizeDataTableFields } from '@core/data/fields'
Expand Down Expand Up @@ -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, {
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/agent/contentCollectionRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof staleWorkspace>) {
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)
})
})
102 changes: 102 additions & 0 deletions src/__tests__/collab/writeGate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, BoundCollabDoc>()
const releases: Array<() => void> = []
const resetListeners = new Set<CollabResetListener>()
return {
bind: (docId) => {
let entry = bound.get(docId)
if (!entry) {
let release = (): void => {}
const whenSynced = new Promise<void>((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)
})
})
36 changes: 29 additions & 7 deletions src/admin/pages/content/agent/useContentToolBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const CONTENT_KIND_VISIBLE: ReadonlySet<string> = new Set(['postType'])

interface ContentToolWorkspaceSurface {
collections: DataTable[]
/** Re-reads the roster from the server and returns the fresh post types. */
refreshCollections(): Promise<DataTable[]>
entries: DataRow[]
selectedEntry: DataRow | null
selectedCollectionId: string | null
Expand Down Expand Up @@ -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<DataTable | null> => {
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(
Expand Down Expand Up @@ -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) : {}
Expand Down
21 changes: 21 additions & 0 deletions src/admin/pages/content/hooks/useContentWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataTable[]> => {
const allTables = await listCmsDataTables()
const nextCollections = allTables.filter((table) => table.kind === 'postType')
setTables(allTables)
setCollections(nextCollections)
return nextCollections
}, [])

useEffect(() => {
let cancelled = false

Expand Down Expand Up @@ -488,6 +508,7 @@ export function useContentWorkspace({
return {
tables,
collections,
refreshCollections,
entries,
authors,
authorsLoading,
Expand Down
Loading
Loading