From b050156fa5553c9f5194812d03d9ac13aa515ba8 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 5 Sep 2026 17:18:59 +0200 Subject: [PATCH] fix(collab): keep the cells a doc does not own when the relay persists a row The relay derived a row's cells from its Y doc and wrote them as the whole row. A page doc carries title, slug, body and the template cells; a component doc carries name, slug, body, params, classIds; a layout doc carries name, slug, body, classes. Everything else on the row (SEO title and description, featured media, plugin-owned cells) was dropped on the first relay write after it was set, because the draft save replaces cells wholesale. On a site with SEO filled in, opening a page in the editor and changing one word cleared its SEO. The relay now merges: the stored cells first, the doc-owned keys cleared, then the derived cells on top, so an owned cell the projection stopped emitting (a page that is no longer a template) is still cleared and every other cell survives exactly as stored. OWNED_CELLS in relayPersistence.ts is the one list of what each doc kind owns. Regression test in collabRelayIntegration.test.ts: SEO seeded on the row, a label edit through a real socket, the persisted row keeps both SEO cells. docs/features/site-shell.md describes the merge. Verification: bunx tsc -b clean bun run lint clean bun test full suite, all pass --- docs/features/site-shell.md | 6 +++- server/collab/relayPersistence.ts | 29 ++++++++++++++++++- .../server/collabRelayIntegration.test.ts | 26 +++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 512b056ca..8088b2f3e 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -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 diff --git a/server/collab/relayPersistence.ts b/server/collab/relayPersistence.ts index 02ea8ad83..4dffa1161 100644 --- a/server/collab/relayPersistence.ts +++ b/server/collab/relayPersistence.ts @@ -45,6 +45,30 @@ const KIND_TABLE: Record, 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, 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 | undefined, + derived: Record, + owned: readonly string[], +): Record { + const merged: Record = { ...existing } + for (const key of owned) delete merged[key] + return { ...merged, ...derived } +} + export type DerivedWrite = 'written' | 'incomplete' | 'invalid' type SiteRosters = ReturnType['rosters'] @@ -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 }, ) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index b01e750e0..57a514648 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -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}`