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
6 changes: 5 additions & 1 deletion docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,11 @@ across all their docs via a routing-group stack.
seeds them from the stored JSON (the server is the ONLY seeder — fixed seed
clientID, so two clients can never build divergent initial histories),
persists each doc's update blob to `collab_documents` AND the derived row
JSON to `data_rows`/site on a short debounce (~800 ms), applies
JSON to `data_rows`/site on a short debounce (~800 ms) — replacing only the
cells the doc owns (`OWNED_CELLS` in `relayPersistence.ts`: title, slug, body
and the template cells for pages; name, slug, body, params, classIds for
components; name, slug, body, classes for layouts), so SEO, featured media,
and plugin cells edited elsewhere survive every relay write — applies
roster-driven soft-deletes, and RESETS docs whose row was written outside
the relay (`rowWriteEvents.ts`) — clients rebind and reseed. The publish
endpoint flushes the relay first so the baked snapshot includes edits still
Expand Down
29 changes: 28 additions & 1 deletion server/collab/relayPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ const KIND_TABLE: Record<Exclude<CollabDocKind, 'site'>, string> = {
layout: 'layouts',
}

/**
* The cells each doc kind derives from its Y doc. Every other cell on the
* row (SEO title and description, featured media, plugin-owned fields) is
* edited elsewhere and must survive a relay write untouched; an owned cell
* the projection no longer emits (a page that stopped being a template)
* is cleared.
*/
const OWNED_CELLS: Record<Exclude<CollabDocKind, 'site'>, readonly string[]> = {
page: ['title', 'slug', 'body', 'templateEnabled', 'templateTarget', 'templatePriority'],
component: ['name', 'slug', 'body', 'params', 'classIds'],
layout: ['name', 'slug', 'body', 'classes'],
}

/** The row's stored cells with the doc-owned ones replaced by what the doc derives. */
export function mergeDerivedCells(
existing: Record<string, unknown> | undefined,
derived: Record<string, unknown>,
owned: readonly string[],
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...existing }
for (const key of owned) delete merged[key]
return { ...merged, ...derived }
}

export type DerivedWrite = 'written' | 'incomplete' | 'invalid'
type SiteRosters = ReturnType<typeof projectSiteDoc>['rosters']

Expand Down Expand Up @@ -301,9 +325,12 @@ export function createRelayPersistence(
slug = layoutSlugFromName(layout.name)
}

// The doc carries only the cells the editor owns; the rest of the row
// was edited elsewhere and stays exactly as stored.
const existing = await getDataRow(db, parsed.rowId)
await upsertDataRowDraft(
db,
{ id: parsed.rowId, tableId: table, cells, slug },
{ id: parsed.rowId, tableId: table, cells: mergeDerivedCells(existing?.cells, cells, OWNED_CELLS[parsed.kind]), slug },
null,
{ collabInternal: true },
)
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/server/collabRelayIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,32 @@ describe('collab relay integration (real server, real sockets)', () => {
})
})

it('keeps the cells the doc does not own when it persists the derived row', async () => {
const stack = await startStack()
const docId = `page:${stack.homeId}`
// SEO lives on the row, never in the doc: seed it out of band before any
// doc exists, then let the relay write the row from a doc edit.
const seeded = (await getDataRow(stack.harness.db, stack.homeId))!
await saveDataRowDraft(stack.harness.db, stack.homeId, {
cells: { ...seeded.cells, seoTitle: 'Kept title', seoDescription: 'Kept description' },
slug: seeded.slug,
})

const client = connectClient(stack)
const bound = client.bind(docId)
await bound.whenSynced
const rootId = treeMap(bound.doc).get('rootNodeId') as string
setNodeLabel(bound.doc, rootId, 'Edited in the doc')

await waitFor(async () => {
const row = await getDataRow(stack.harness.db, stack.homeId)
return row !== null && pageFromRow(row).nodes[rootId]?.label === 'Edited in the doc'
})
const row = (await getDataRow(stack.harness.db, stack.homeId))!
expect(row.cells.seoTitle).toBe('Kept title')
expect(row.cells.seoDescription).toBe('Kept description')
})

it('refuses a read-only edit AND resets the viewer so its own screen reverts', async () => {
const stack = await startStack()
const docId = `page:${stack.homeId}`
Expand Down
Loading