From f8c2341b4685767196754b4cf7d1d91f790c263c Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Thu, 2 Jul 2026 22:51:26 +0200 Subject: [PATCH 01/49] feat(cms): save conflict detection with per-row base seqs and resolution banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two admins editing the same site could silently overwrite each other: saves were last-writer-wins per row with no detection. This is level A of the multi-admin live-sync plan — conflict SAFETY on top of the transactional save. Server: - The incremental save body gains `baseSeqs` (rowId → last-synchronized seq, covering changed AND deleted rows) and `shellBaseSeq`. Inside the save transaction — after allocateSiteSeq, whose counter-row lock serializes concurrent saves, making the check exact — any shipped row stored NEWER than its base (or with no base entry at all) throws SaveConflictError, rolling everything back into a 409 with `{ conflicts: [{ table, rowId, seq }] }`. Nothing is written. Soft-deleted rows are visible to the check (a remote delete conflicts with a stale edit). Replace-mode saves skip it (imports bulldoze by design). - The shell write + seq stamp now happen ONLY when shell content actually changed (shellsEqual) — an unconditional stamp would 409 every concurrent save pair on the always-shipped shell. - DataRow exposes `seq` (optional in the schema for old bundle archives); GET /site returns the shell seq; the three collection GETs accept `?id=` for the banner's single-row "Load theirs" fetch. Client: - The editor store tracks baseSeqs/shellBaseSeq (seeded at load, bumped to the response seq on every successful save — deleted rows KEEP entries so undo-resurrection carries the right base) and the pending saveConflicts list. Autosave is suppressed while conflicts pend. - SaveConflictBanner (lazy-mounted in AdminCanvasLayout) offers per-target resolution: Load theirs (swap the remote version in without undo history, clear the target's dirty marks, clear undo history, propagate VC slot re-sync / ref cascades into consumer pages WITH dirty marks) or Keep mine (bump the base seq — the overwrite becomes a stated decision). Resolving the last conflict flushes the surviving edits. - The Spotlight Save command now routes through the editor save queue (flushEditorSave) instead of a raw replace-mode adapter save that bypassed the single-flight queue and dirty tracking. - Settings modal + onboarding shell saves carry their own shell base seq. Known v1 blind spot (documented): writers outside the transactional save (plugin pack installs, data-workspace edits) don't stamp seqs and coordinate via requestCmsSiteReload() instead — widening stamping is phase B scope. Co-Authored-By: Claude Fable 5 --- docs/features/site-shell.md | 61 +++- docs/server.md | 2 +- server/handlers/cms/components.ts | 20 +- server/handlers/cms/layouts.ts | 20 +- server/handlers/cms/pages.ts | 18 +- server/handlers/cms/shared.ts | 25 ++ server/handlers/cms/site.ts | 10 +- server/handlers/cms/siteDiff.ts | 11 + server/handlers/cms/siteDocument.ts | 98 +++++- server/repositories/data/index.ts | 1 + server/repositories/data/rows/index.ts | 1 + server/repositories/data/rows/mapper.ts | 3 + server/repositories/data/rows/read.ts | 36 ++- server/repositories/site.ts | 22 +- .../siteEditorDataDeepLink.test.tsx | 12 +- .../editor-store/saveConflicts.test.ts | 224 ++++++++++++++ src/__tests__/persistence/cmsAdapter.test.ts | 101 +++++- .../persistence/savePersistenceQueue.test.tsx | 51 +++- .../server/capabilityRouteMatrix.test.ts | 51 +++- .../server/cmsRouteAuthorization.test.ts | 9 +- src/__tests__/server/cmsSiteHandlers.test.ts | 10 + src/__tests__/server/siteDocumentSave.test.ts | 287 +++++++++++++++++- .../AdminCanvasLayout/AdminCanvasLayout.tsx | 16 + .../Settings/useSiteSettingsController.ts | 36 ++- .../SiteImport/shared/importPlanning.ts | 14 +- .../components/OnboardingPanel.test.tsx | 5 +- .../dashboard/components/OnboardingPanel.tsx | 11 +- .../dashboard/hooks/useOnboardingState.ts | 2 +- src/admin/pages/site/hooks/usePersistence.ts | 59 +++- .../site/store/slices/saveTrackingSlice.ts | 71 ++++- .../site/store/slices/site/conflictActions.ts | 210 +++++++++++++ .../store/slices/site/lifecycleActions.ts | 13 + .../pages/site/store/slices/site/types.ts | 32 ++ .../pages/site/store/slices/siteSlice.ts | 3 + .../SaveConflictBanner.module.css | 64 ++++ .../SaveConflictBanner/SaveConflictBanner.tsx | 166 ++++++++++ src/admin/spotlight/commands/editor.ts | 17 +- src/admin/state/useSiteSummary.ts | 2 +- src/core/data/schemas.ts | 8 + src/core/persistence/cms.ts | 103 ++++++- src/core/persistence/responseSchemas.ts | 16 +- src/core/persistence/saveConflict.ts | 57 ++++ src/core/persistence/types.ts | 40 ++- 43 files changed, 1882 insertions(+), 136 deletions(-) create mode 100644 src/__tests__/editor-store/saveConflicts.test.ts create mode 100644 src/admin/pages/site/store/slices/site/conflictActions.ts create mode 100644 src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css create mode 100644 src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx create mode 100644 src/core/persistence/saveConflict.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 0ecf80cfd..7caa451d2 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -377,10 +377,12 @@ The whole document saves through ONE endpoint, in ONE server transaction: ``` PUT /admin/api/cms/site-document -{ mode, site, // shell — always written +{ mode, site, // shell — written only when its content changed changedPages, deletedPageIds, changedComponents, deletedComponentIds, - changedLayouts, deletedLayoutIds } + changedLayouts, deletedLayoutIds, + baseSeqs, // rowId → last-synchronized seq (conflict check) + shellBaseSeq } // the shell's counterpart ``` Two modes: @@ -442,10 +444,57 @@ reject with a 400 instead of dying on the index. Every save allocates a **site-global sync sequence number** (`server/repositories/syncSequence.ts`) inside the transaction and stamps it -on the shell and every written or deleted row (`data_rows.seq`); the -response returns it (`{ ok: true, seq }`). The seq is the substrate for -multi-admin conflict detection and delta reconciliation (live-sync plan) — -informational to the client until that lands. +on every written or deleted row (`data_rows.seq`) — and on the shell, but +**only when the shell content actually changed** (`shellsEqual` in +`siteDiff.ts` gates the shell write; the shell ships with every save, so an +unconditional stamp would make the shell seq useless as a conflict signal). +The response returns the seq (`{ ok: true, seq }`). + +### Conflict detection (multi-admin level A) + +Incremental saves carry **base seqs**: for every changed *and* deleted row, +the stored seq the client last synchronized with (`baseSeqs`), plus the +shell's (`shellBaseSeq`). Inside the transaction — *after* `allocateSiteSeq`, +whose counter-row lock serializes concurrent saves on both dialects, making +the check exact — the handler compares each shipped row's STORED seq against +its base: + +- stored seq **newer** than the base → another admin changed (or deleted — + soft-deleted rows are visible to the check via `listDataRowSeqs`) the row + since this client synchronized; +- **no base entry** for a row that exists in storage → the client doesn't + know the row at all, so its write would be a blind overwrite; +- the shell is checked only when the incoming shell differs from the stored + one (one coarse seq for the whole shell — accepted v1 granularity). + +Any hit throws `SaveConflictError` (`@core/persistence/saveConflict` — the +same class the client adapter re-throws), rolling the transaction back into +a **409** with `{ error, conflicts: [{ table, rowId, seq }] }`. Nothing is +written. Client-created rows have no stored counterpart and pass by +construction; **replace-mode saves skip the check** (imports replace +deliberately). + +Known v1 blind spot: writers OUTSIDE the transactional save (plugin pack +installs via `saveDraftSite`/`saveDataRowDraft`, data-workspace row edits) +do not stamp seqs, so conflict detection cannot see them — those flows +coordinate through `requestCmsSiteReload()` instead. Widening seq stamping +to every writer is live-sync phase B territory. + +Client side: `loadSite` seeds `baseSeqs`/`shellBaseSeq` in the editor store +(per-row seqs ride the collection GET responses — `DataRow.seq`; the shell +seq rides `GET /site`). Every successful save bumps the shipped rows' bases +to the response seq — deleted rows **keep** their entries so an undo that +resurrects a saved-deleted row carries the right base. On a 409, +`usePersistence` restores the dirty snapshot, fills `saveConflicts`, and the +**conflict banner** (`SaveConflictBanner`, mounted in `AdminCanvasLayout`) +offers per-target resolution: **Load theirs** fetches the remote state +(single-row `?id=` filter on the collection GETs, or `GET /site` for the +shell) and adopts it via `resolveSaveConflictTheirs` (swaps the row without +undo history, clears its dirty marks, clears the undo history, and for VCs +propagates slot re-sync / ref-cascade removal into consumer pages *with* +dirty marks); **Keep mine** bumps the base seq so the next save overwrites +as a stated decision. Autosave is suppressed while conflicts pend; resolving +the last one flushes the surviving edits through the save queue. ### Atomic diff validation diff --git a/docs/server.md b/docs/server.md index 00b62ded4..56cf33413 100644 --- a/docs/server.md +++ b/docs/server.md @@ -362,7 +362,7 @@ All SQL lives in `server/repositories/`. Each file owns one resource: | `sessions.ts` | User sessions | | `setup.ts` | Setup wizard state (`isSetup`, first-run owner) | | `site.ts` | The single site shell row | -| `syncSequence.ts` | Site-global sync sequence counter (multi-admin sync substrate — stamped on every row the site-document save writes or deletes) | +| `syncSequence.ts` | Site-global sync sequence counter (multi-admin sync substrate — stamped on every row the site-document save writes or deletes, and on the shell only when its content changed; the save's conflict check compares client base seqs against these inside the transaction) | | `userPreferences.ts` | Per-user editor preferences | | `users.ts` | Users + auth fields | diff --git a/server/handlers/cms/components.ts b/server/handlers/cms/components.ts index 0b000830f..aaee0490c 100644 --- a/server/handlers/cms/components.ts +++ b/server/handlers/cms/components.ts @@ -1,10 +1,14 @@ /** * Visual Components read endpoint backed by `data_rows` (table_id = 'components'). * - * GET /admin/api/cms/components — list all non-deleted component rows as - * DataRow[] (gated by `site.read`). The client - * adapter converts these to VisualComponent[] - * via visualComponentFromRow + validateVisualComponents. + * GET /admin/api/cms/components — list all non-deleted component rows + * as DataRow[] (gated by `site.read`). + * The client adapter converts these to + * VisualComponent[] via + * visualComponentFromRow + validateVisualComponents. + * GET /admin/api/cms/components?id=X — the single row X (empty `rows` when + * deleted or not a component) — the + * conflict banner's "Load theirs" fetch. * * The response returns raw DataRow objects (not VisualComponent objects) so * the client adapter can reconstruct VCs via visualComponentFromRow without a @@ -16,9 +20,8 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { listDataRows } from '../../repositories/data' -import { jsonResponse, methodNotAllowed } from '../../http' -import { CMS_API_PREFIX } from './shared' +import { methodNotAllowed } from '../../http' +import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' export async function handleComponentsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handleComponentsRoutes(req: Request, db: DbClient): Promis const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - const rows = await listDataRows(db, 'components') - return jsonResponse({ rows }) + return siteCollectionRowsResponse(db, url, 'components') } diff --git a/server/handlers/cms/layouts.ts b/server/handlers/cms/layouts.ts index 6bb3c8b92..aba05e728 100644 --- a/server/handlers/cms/layouts.ts +++ b/server/handlers/cms/layouts.ts @@ -1,10 +1,14 @@ /** * Saved-layout read endpoint backed by `data_rows` (table_id = 'layouts'). * - * GET /admin/api/cms/layouts — list all non-deleted layout rows as - * DataRow[] (gated by `site.read`). The client - * adapter converts these to SavedLayout[] - * via savedLayoutFromRow + validateSavedLayouts. + * GET /admin/api/cms/layouts — list all non-deleted layout rows as + * DataRow[] (gated by `site.read`). The + * client adapter converts these to + * SavedLayout[] via savedLayoutFromRow + + * validateSavedLayouts. + * GET /admin/api/cms/layouts?id=X — the single row X (empty `rows` when + * deleted or not a layout) — the conflict + * banner's "Load theirs" fetch. * * The response returns raw DataRow objects (not SavedLayout objects) so the * client adapter can reconstruct layouts via savedLayoutFromRow without a @@ -16,9 +20,8 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { listDataRows } from '../../repositories/data' -import { jsonResponse, methodNotAllowed } from '../../http' -import { CMS_API_PREFIX } from './shared' +import { methodNotAllowed } from '../../http' +import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -28,6 +31,5 @@ export async function handlePagesRoutes(req: Request, db: DbClient): Promise` + * single-row filter — the conflict banner's "Load theirs" fetch. A + * soft-deleted or foreign-table id yields `{ rows: [] }`: absence is the + * "deleted remotely" signal, and the table check keeps the endpoint from + * leaking rows of other data tables. + */ +export async function siteCollectionRowsResponse( + db: DbClient, + url: URL, + tableId: 'pages' | 'components' | 'layouts', +): Promise { + const id = url.searchParams.get('id') + if (id !== null) { + const row = await getDataRow(db, id) + return jsonResponse({ rows: row && row.tableId === tableId ? [row] : [] }) + } + return jsonResponse({ rows: await listDataRows(db, tableId) }) +} + export function mutationErrorResponse(err: unknown): Response { if (err instanceof UserMutationError || err instanceof RoleMutationError) { return jsonResponse({ error: err.message }, { status: err.status }) diff --git a/server/handlers/cms/site.ts b/server/handlers/cms/site.ts index 8e61982a6..b14f7c5ec 100644 --- a/server/handlers/cms/site.ts +++ b/server/handlers/cms/site.ts @@ -2,8 +2,10 @@ * Draft-site shell read endpoint. * * GET /admin/api/cms/site — load the draft site shell (gated by `site.read`). - * Returns the SiteShell without pages; the client - * adapter fetches pages separately via GET /pages. + * Returns `{ site, seq }`: the SiteShell without + * pages plus the shell's sync seq (the client's + * conflict-detection base). Pages are fetched + * separately via GET /pages. * * Writes go through the transactional site-document save * (PUT /admin/api/cms/site-document — see ./siteDocument.ts), which persists @@ -11,7 +13,7 @@ */ import type { DbClient } from '../../db/client' import { requireCapability } from '../../auth/authz' -import { getDraftSite } from '../../repositories/site' +import { getDraftSite, getDraftSiteSeq } from '../../repositories/site' import { jsonResponse, methodNotAllowed } from '../../http' export async function handleSiteRoutes(req: Request, db: DbClient): Promise { @@ -24,5 +26,5 @@ export async function handleSiteRoutes(req: Request, db: DbClient): Promise @@ -173,6 +203,13 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom const shell = validateSite(body.site) validateSiteWriteDiff(previousShell, shell, user.capabilities) + // The shell ships with EVERY save, changed or not. Detecting "actually + // changed" here (CPU work, outside the transaction) lets phase 2 skip the + // shell write + seq stamp on row-only saves — which in turn keeps the + // shell seq an honest conflict signal (an unconditional stamp would 409 + // every concurrent save pair on the shell). + const shellChanged = previousShell === null || !shellsEqual(previousShell, shell) + const hasAllSiteCaps = user.capabilities.includes('site.structure.edit') && user.capabilities.includes('site.content.edit') && @@ -307,9 +344,49 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom let seq = 0 let deletedPublishedPage = false await db.transaction(async (tx) => { + // Allocate FIRST: the counter-row UPDATE takes a row lock, so two + // concurrent save transactions serialize here (on Postgres as well as + // SQLite's fully-serialized chain) — which makes the conflict reads + // below exact, not best-effort. seq = await allocateSiteSeq(tx) - await saveDraftSite(tx, shell, user.id) - await stampDraftSiteSeq(tx, seq) + + // Conflict check (incremental mode): any shipped row whose STORED seq + // is newer than the client's base — or that the client has no base + // entry for — would be a silent overwrite of another admin's work. + // Throwing rolls the transaction back, so nothing is written on 409. + if (body.mode === 'incremental') { + const conflicts: SaveConflict[] = [] + if (shellChanged) { + const storedShellSeq = await getDraftSiteSeq(tx) + if (storedShellSeq > body.shellBaseSeq) { + conflicts.push({ table: 'site', rowId: 'default', seq: storedShellSeq }) + } + } + const rowChecks = [ + { table: 'pages', ids: [...changedPageIdsRaw, ...pageDeleteIds] }, + { table: 'components', ids: [...changedComponentIds, ...componentDeleteIds] }, + { table: 'layouts', ids: [...changedLayoutIds, ...layoutDeleteIds] }, + ] as const + for (const { table, ids } of rowChecks) { + // listDataRowSeqs sees soft-deleted rows too: a remote deletion is + // a newer write, not absence. Rows with no stored counterpart are + // client creations and pass by construction (absent from the result). + for (const stored of await listDataRowSeqs(tx, table, ids)) { + const base = body.baseSeqs[stored.id] + if (base === undefined || stored.seq > base) { + conflicts.push({ table, rowId: stored.id, seq: stored.seq }) + } + } + } + if (conflicts.length > 0) throw new SaveConflictError(conflicts) + } + + // Shell write + seq stamp only when the shell content actually changed + // — see the shellChanged comment in phase 1. + if (shellChanged) { + await saveDraftSite(tx, shell, user.id) + await stampDraftSiteSeq(tx, seq) + } // Empty change sets skip their table entirely — a shell-only save // issues no row queries inside the transaction. if (componentWrites.length > 0 || componentDeleteIds.size > 0) { @@ -350,6 +427,9 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom { status: 403 }, ) } + if (err instanceof SaveConflictError) { + return jsonResponse({ error: err.message, conflicts: err.conflicts }, { status: 409 }) + } throw err } } diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 4d3e91a84..67ce92bca 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -31,6 +31,7 @@ export { export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, listDataRowsWithFilter, searchDataRows, getDataRow, diff --git a/server/repositories/data/rows/index.ts b/server/repositories/data/rows/index.ts index c4d9fa393..c663fa60c 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -19,6 +19,7 @@ export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, getDataRow, getDataRowMany, getDataRowBySlug, diff --git a/server/repositories/data/rows/mapper.ts b/server/repositories/data/rows/mapper.ts index 5b637f81f..910dcf71e 100644 --- a/server/repositories/data/rows/mapper.ts +++ b/server/repositories/data/rows/mapper.ts @@ -56,6 +56,7 @@ interface DataRowRow extends UserJoinColumns { cells_json: Record slug: string status: DataRowStatus + seq: number author_user_id: string | null created_by_user_id: string | null updated_by_user_id: string | null @@ -78,6 +79,7 @@ function mapRow(row: DataRowRow): DataRow { cells: row.cells_json, slug: row.slug, status: row.status, + seq: Number(row.seq), authorUserId: row.author_user_id ?? null, createdByUserId: row.created_by_user_id ?? null, updatedByUserId: row.updated_by_user_id ?? null, @@ -114,6 +116,7 @@ const DATA_ROW_COLUMNS = `data_rows.id, data_rows.cells_json, data_rows.slug, data_rows.status, + data_rows.seq, data_rows.author_user_id, data_rows.created_by_user_id, data_rows.updated_by_user_id, diff --git a/server/repositories/data/rows/read.ts b/server/repositories/data/rows/read.ts index c0be6c707..a377de872 100644 --- a/server/repositories/data/rows/read.ts +++ b/server/repositories/data/rows/read.ts @@ -54,10 +54,10 @@ interface DataRowIdSlug { } /** - * Lightweight (id, slug) projection of a table's non-deleted rows. The roster - * reconcilers (PUT /pages, PUT /components) need exactly this — the reap diff - * and the cross-row slug-uniqueness check — so they must not pay the hydrated - * SELECT's full `cells_json` parse per row per save. + * Lightweight (id, slug) projection of a table's non-deleted rows. The + * transactional site-document save needs exactly this — the replace-mode reap + * diff and the cross-row slug-uniqueness check — so it must not pay the + * hydrated SELECT's full `cells_json` parse per row per save. */ export async function listDataRowIdSlugs( db: DbClient, @@ -89,6 +89,34 @@ export async function listSoftDeletedDataRowIds( return rows.map((r) => r.id) } +export interface DataRowSeq { + id: string + seq: number +} + +/** + * Lean (id, seq) projection for a set of row ids in one table — the + * transactional save's conflict check. Deliberately NO `deleted_at` filter: + * a remote soft-delete stamps the row's seq, and a stale client editing that + * row must see it as a conflict, not as absence. Runs INSIDE the save + * transaction (after `allocateSiteSeq` — the counter-row lock serializes + * concurrent saves, so the read is exact). + */ +export async function listDataRowSeqs( + db: DbClient, + tableId: string, + rowIds: ReadonlyArray, +): Promise { + if (rowIds.length === 0) return [] + const placeholders = rowIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') + const { rows } = await db.unsafe( + `select id, seq from data_rows + where table_id = ${placeholder(db.dialect, 1)} and id in (${placeholders})`, + [tableId, ...rowIds], + ) + return rows.map((r) => ({ id: r.id, seq: Number(r.seq) })) +} + export async function getDataRow( db: DbClient, rowId: string, diff --git a/server/repositories/site.ts b/server/repositories/site.ts index 0d3cff389..d046ca87a 100644 --- a/server/repositories/site.ts +++ b/server/repositories/site.ts @@ -96,11 +96,29 @@ export async function saveDraftSite( ` } +/** + * The shell's current sync seq — 0 before setup (no site row) and for + * installs that predate the sync-sequence migration. The transactional save + * reads this inside the transaction for the shell conflict check; the GET + * shell endpoint returns it so clients can seed their base seq. + */ +export async function getDraftSiteSeq(db: DbClient): Promise { + const { rows } = await db<{ seq: number }>` + select seq from site + where id = 'default' + limit 1 + ` + return rows[0] ? Number(rows[0].seq) : 0 +} + /** * Stamp the site-global sync seq on the draft-site row. The transactional * site-document save calls this right after `saveDraftSite` inside the same - * transaction, so shell changes participate in delta reconciliation exactly - * like row changes (see repositories/syncSequence.ts). + * transaction — but ONLY when the shell content actually changed. An + * unconditional stamp would advance the shell seq on every row-only save and + * make the shell conflict check fire on every concurrent save pair; a + * conditional stamp keeps the shell seq an honest "shell content changed" + * signal (see repositories/syncSequence.ts). */ export async function stampDraftSiteSeq(db: DbClient, seq: number): Promise { await db` diff --git a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx index 5a56ca7b2..16adb1add 100644 --- a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx +++ b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx @@ -63,9 +63,11 @@ function makeAdapter(site: SiteDocument): IPersistenceAdapter & { loadCount: () return { async loadSite() { loads += 1 - return site + return { site, rowSeqs: {}, shellSeq: 0 } + }, + async saveSite() { + return { seq: 1 } }, - async saveSite() {}, loadCount: () => loads, } } @@ -79,9 +81,11 @@ function makeControlledAdapter( async loadSite() { loads += 1 await new Promise((resolve) => resolvers.push(resolve)) - return site + return { site, rowSeqs: {}, shellSeq: 0 } + }, + async saveSite() { + return { seq: 1 } }, - async saveSite() {}, loadCount: () => loads, resolveNextLoad: () => { resolvers.shift()?.() diff --git a/src/__tests__/editor-store/saveConflicts.test.ts b/src/__tests__/editor-store/saveConflicts.test.ts new file mode 100644 index 000000000..f9cd1ea48 --- /dev/null +++ b/src/__tests__/editor-store/saveConflicts.test.ts @@ -0,0 +1,224 @@ +/** + * Save-conflict resolution + base-seq bookkeeping (multi-admin level A). + * + * Covers the store half of the conflict protocol: + * - seedBaseSeqs / commitSavedBaseSeqs — the conflict-detection bases every + * incremental save ships, seeded at load and bumped on success (deleted + * rows KEEP their entries so a resurrect-by-undo carries the right base), + * - resolveSaveConflictKeepMine — bumps the target's base seq so the next + * save overwrites as a stated decision; dirty marks stay untouched, + * - resolveSaveConflictTheirs — swaps the remote version in (or removes a + * remotely-deleted target), clears the target's dirty marks, syncs the + * base seq, clears the undo history, and — for Visual Components — + * propagates to consumer pages with REAL dirty marks (slot re-sync / + * ref-cascade removal). + */ +import { describe, it, expect, beforeEach } from 'bun:test' +import { useEditorStore } from '@site/store/store' +import { emptyDirtyMarks } from '@site/store/slices/site/dirtyTracking' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +function loadFixtureSite(overrides: Parameters[0] = {}) { + useEditorStore.getState().loadSite(makeSite(overrides)) +} + +beforeEach(() => { + useEditorStore.getState().clearSite() +}) + +// --------------------------------------------------------------------------- +// Base-seq bookkeeping +// --------------------------------------------------------------------------- + +describe('base-seq bookkeeping', () => { + it('seedBaseSeqs replaces the maps wholesale', () => { + loadFixtureSite() + useEditorStore.getState().seedBaseSeqs({ 'page-1': 4, 'vc-1': 5 }, 6) + expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-1': 4, 'vc-1': 5 }) + expect(useEditorStore.getState().shellBaseSeq).toBe(6) + + useEditorStore.getState().seedBaseSeqs({ 'page-2': 9 }, 10) + expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-2': 9 }) + expect(useEditorStore.getState().shellBaseSeq).toBe(10) + }) + + it('commitSavedBaseSeqs (incremental) bumps exactly the shipped ids — deleted rows keep entries', () => { + loadFixtureSite({ pages: [makePage({ id: 'page-a' }), makePage({ id: 'page-b', slug: 'b' })] }) + useEditorStore.getState().seedBaseSeqs({ 'page-a': 1, 'page-b': 1, 'vc-gone': 1 }, 1) + + const dirty = { + ...emptyDirtyMarks(), + pageIds: new Set(['page-a']), + deletedComponentIds: new Set(['vc-gone']), + } + useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, dirty, 7) + + const { baseSeqs, shellBaseSeq } = useEditorStore.getState() + expect(baseSeqs['page-a']).toBe(7) + // Deleted rows keep a base at the delete-save's seq: an undo that + // resurrects the row must not read as a blind overwrite. + expect(baseSeqs['vc-gone']).toBe(7) + // Unshipped rows keep their old base. + expect(baseSeqs['page-b']).toBe(1) + expect(shellBaseSeq).toBe(7) + }) + + it('commitSavedBaseSeqs (replace) rebuilds the map from the shipped site', () => { + loadFixtureSite({ pages: [makePage({ id: 'page-a' })] }) + useEditorStore.getState().seedBaseSeqs({ 'stale-row': 3 }, 3) + + useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, undefined, 9) + + expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-a': 9 }) + expect(useEditorStore.getState().shellBaseSeq).toBe(9) + }) + + it('a successful save clears pending conflicts', () => { + loadFixtureSite() + useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-1', seq: 5 }]) + useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, undefined, 9) + expect(useEditorStore.getState().saveConflicts).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// Keep mine +// --------------------------------------------------------------------------- + +describe('resolveSaveConflictKeepMine', () => { + it('bumps the row base to the remote seq and drops the conflict; dirty marks survive', () => { + loadFixtureSite() + useEditorStore.getState().seedBaseSeqs({ 'page-1': 2 }, 2) + useEditorStore.setState((s) => { + s._dirtySave.pageIds.add('page-1') + }) + useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-1', seq: 8 }]) + + useEditorStore.getState().resolveSaveConflictKeepMine({ table: 'pages', rowId: 'page-1', seq: 8 }) + + const state = useEditorStore.getState() + expect(state.saveConflicts).toEqual([]) + expect(state.baseSeqs['page-1']).toBe(8) + expect(state._dirtySave.pageIds.has('page-1')).toBe(true) + }) + + it('bumps the shell base for a site conflict', () => { + loadFixtureSite() + useEditorStore.getState().seedBaseSeqs({}, 2) + useEditorStore.getState().setSaveConflicts([{ table: 'site', rowId: 'default', seq: 11 }]) + + useEditorStore.getState().resolveSaveConflictKeepMine({ table: 'site', rowId: 'default', seq: 11 }) + + expect(useEditorStore.getState().shellBaseSeq).toBe(11) + expect(useEditorStore.getState().saveConflicts).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// Load theirs +// --------------------------------------------------------------------------- + +describe('resolveSaveConflictTheirs', () => { + it('swaps a remote page in, clears its dirty marks, syncs the base seq, clears history', () => { + loadFixtureSite({ pages: [makePage({ id: 'page-a', title: 'Mine' })] }) + useEditorStore.getState().seedBaseSeqs({ 'page-a': 1 }, 1) + // Local unsaved edit — real history + dirty marks. + useEditorStore.getState().renamePage('page-a', 'Mine (edited)') + expect(useEditorStore.getState().canUndo).toBe(true) + expect(useEditorStore.getState()._dirtySave.pageIds.has('page-a')).toBe(true) + useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-a', seq: 6 }]) + + useEditorStore.getState().resolveSaveConflictTheirs({ + table: 'pages', + rowId: 'page-a', + row: makePage({ id: 'page-a', title: 'Theirs' }), + seq: 6, + }) + + const state = useEditorStore.getState() + expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Theirs') + expect(state._dirtySave.pageIds.has('page-a')).toBe(false) + expect(state.baseSeqs['page-a']).toBe(6) + expect(state.saveConflicts).toEqual([]) + // Site-relative history patches are undefined across a swapped tree. + expect(state.canUndo).toBe(false) + expect(state._historyPast).toEqual([]) + }) + + it('removes a remotely-deleted page and moves the active page off it', () => { + loadFixtureSite({ + pages: [ + makePage({ id: 'page-home', slug: 'index', title: 'Home' }), + makePage({ id: 'page-b', slug: 'b', title: 'B' }), + ], + }) + useEditorStore.getState().seedBaseSeqs({ 'page-home': 1, 'page-b': 1 }, 1) + useEditorStore.getState().setActivePage('page-b') + + useEditorStore.getState().resolveSaveConflictTheirs({ + table: 'pages', + rowId: 'page-b', + row: null, + seq: 6, + }) + + const state = useEditorStore.getState() + expect(state.site!.pages.map((p) => p.id)).toEqual(['page-home']) + expect(state.activePageId).toBe('page-home') + expect(state.baseSeqs['page-b']).toBeUndefined() + }) + + it('swaps remote shell fields in place, leaving the row collections untouched', () => { + loadFixtureSite({ name: 'Mine', pages: [makePage({ id: 'page-a' })] }) + useEditorStore.getState().seedBaseSeqs({ 'page-a': 1 }, 1) + const remoteShell = (() => { + const { pages: _p, visualComponents: _v, layouts: _l, ...shell } = makeSite({ name: 'Theirs' }) + return shell + })() + + useEditorStore.getState().resolveSaveConflictTheirs({ + table: 'site', + shell: remoteShell, + seq: 12, + }) + + const state = useEditorStore.getState() + expect(state.site!.name).toBe('Theirs') + expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) + expect(state.shellBaseSeq).toBe(12) + }) + + it('a remotely-deleted VC cascades ref removal into consumer pages WITH dirty marks', () => { + const vc = makeVC({ id: 'vc-1', name: 'Card' }) + const page = makePage({ + id: 'page-a', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['ref-1'] }), + 'ref-1': makeNode({ + id: 'ref-1', + moduleId: 'base.visual-component-ref', + props: { componentId: 'vc-1' }, + }), + }, + }) + loadFixtureSite({ pages: [page], visualComponents: [vc] }) + useEditorStore.getState().seedBaseSeqs({ 'page-a': 1, 'vc-1': 1 }, 1) + useEditorStore.getState().setSaveConflicts([{ table: 'components', rowId: 'vc-1', seq: 4 }]) + + useEditorStore.getState().resolveSaveConflictTheirs({ + table: 'components', + rowId: 'vc-1', + row: null, + seq: 4, + }) + + const state = useEditorStore.getState() + expect(state.site!.visualComponents).toEqual([]) + // The consumer page lost its ref node… + expect(state.site!.pages[0].nodes['ref-1']).toBeUndefined() + // …and now DIFFERS from storage, so it must ship with the next save. + expect(state._dirtySave.pageIds.has('page-a')).toBe(true) + expect(state.baseSeqs['vc-1']).toBeUndefined() + expect(state.saveConflicts).toEqual([]) + }) +}) diff --git a/src/__tests__/persistence/cmsAdapter.test.ts b/src/__tests__/persistence/cmsAdapter.test.ts index 098c1b8eb..0fdefe378 100644 --- a/src/__tests__/persistence/cmsAdapter.test.ts +++ b/src/__tests__/persistence/cmsAdapter.test.ts @@ -3,6 +3,7 @@ import type { Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' import { CmsAdapter } from '@core/persistence/cms' +import { SaveConflictError } from '@core/persistence/saveConflict' function makePage(id: string, slug: string): Page { return { @@ -96,7 +97,7 @@ describe('CmsAdapter', () => { const loaded = await adapter.loadSite('ignored-in-single-site-mode') - expect(loaded?.id).toBe('project_1') + expect(loaded?.site.id).toBe('project_1') expect(calls[0]).toMatchObject({ input: '/admin/api/cms/site', init: { method: 'GET', credentials: 'include' }, @@ -297,3 +298,101 @@ describe('CmsAdapter save wire shapes', () => { expect(body.deletedPageIds).toEqual([]) }) }) + +// --------------------------------------------------------------------------- +// Conflict-detection wire shapes — baseSeqs / shellBaseSeq + the 409 path +// --------------------------------------------------------------------------- + +describe('CmsAdapter conflict protocol', () => { + function emptyDirty() { + return { + all: false, + pageIds: new Set(), + componentIds: new Set(), + layoutIds: new Set(), + deletedPageIds: new Set(), + deletedComponentIds: new Set(), + deletedLayoutIds: new Set(), + } + } + + it('ships the base-seq subset covering exactly the changed + deleted rows, plus shellBaseSeq', async () => { + const calls: Array<{ init?: RequestInit }> = [] + const adapter = new CmsAdapter(async (_input, init) => { + calls.push({ init }) + return new Response(JSON.stringify({ ok: true, seq: 9 }), { status: 200 }) + }) + + const doc = site() + const result = await adapter.saveSite(doc, { + dirty: { + ...emptyDirty(), + pageIds: new Set(['page_home']), + deletedComponentIds: new Set(['vc-gone']), + }, + baseSeqs: { + page_home: 4, + 'vc-gone': 5, + 'unrelated-row': 99, // not shipped — must not leak onto the wire + }, + shellBaseSeq: 7, + }) + + expect(result.seq).toBe(9) + const body = JSON.parse(String(calls[0].init?.body)) as Record + expect(body.baseSeqs).toEqual({ page_home: 4, 'vc-gone': 5 }) + expect(body.shellBaseSeq).toBe(7) + }) + + it('throws SaveConflictError with the parsed conflicts on a 409', async () => { + const conflicts = [{ table: 'pages', rowId: 'page_home', seq: 12 }] + const adapter = new CmsAdapter(async () => + new Response(JSON.stringify({ error: 'save conflict', conflicts }), { status: 409 })) + + const err = await adapter + .saveSite(site(), { dirty: emptyDirty(), baseSeqs: {}, shellBaseSeq: 0 }) + .then(() => null, (e: unknown) => e) + expect(err).toBeInstanceOf(SaveConflictError) + expect((err as SaveConflictError).conflicts).toEqual([ + { table: 'pages', rowId: 'page_home', seq: 12 }, + ]) + }) + + it('loadSite returns per-row seqs and the shell seq alongside the document', async () => { + const adapter = new CmsAdapter(async (input) => { + const url = String(input) + if (url.endsWith('/site')) { + return new Response(JSON.stringify({ site: site(), seq: 3 }), { status: 200 }) + } + if (url.endsWith('/pages')) { + return new Response(JSON.stringify({ + rows: [{ + id: 'page_home', tableId: 'pages', slug: 'index', status: 'draft', seq: 2, + cells: { title: 'index', slug: 'index', body: { rootNodeId: 'root', nodes: { root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [] } } } }, + authorUserId: null, createdByUserId: null, updatedByUserId: null, publishedByUserId: null, + author: null, createdBy: null, updatedBy: null, publishedBy: null, + createdAt: '2026-01-01', updatedAt: '2026-01-01', publishedAt: null, + scheduledPublishAt: null, deletedAt: null, + }], + }), { status: 200 }) + } + return new Response(JSON.stringify({ rows: [] }), { status: 200 }) + }) + + const loaded = await adapter.loadSite('default') + expect(loaded?.shellSeq).toBe(3) + expect(loaded?.rowSeqs).toEqual({ page_home: 2 }) + }) + + it('loadSiteRow fetches the single-row filter and returns null for a deleted row', async () => { + const requested: string[] = [] + const adapter = new CmsAdapter(async (input) => { + requested.push(String(input)) + return new Response(JSON.stringify({ rows: [] }), { status: 200 }) + }) + + const row = await adapter.loadSiteRow('pages', 'page-gone') + expect(row).toBeNull() + expect(requested[0]).toBe('/admin/api/cms/pages?id=page-gone') + }) +}) diff --git a/src/__tests__/persistence/savePersistenceQueue.test.tsx b/src/__tests__/persistence/savePersistenceQueue.test.tsx index 4bc282d88..5e5e43750 100644 --- a/src/__tests__/persistence/savePersistenceQueue.test.tsx +++ b/src/__tests__/persistence/savePersistenceQueue.test.tsx @@ -20,6 +20,7 @@ import React, { useEffect } from 'react' import { cleanup, render, waitFor } from '@testing-library/react' import { usePersistence } from '@site/hooks/usePersistence' import type { IPersistenceAdapter, SaveSiteOptions } from '@core/persistence/types' +import { SaveConflictError } from '@core/persistence/saveConflict' import type { SiteDocument } from '@core/page-tree' import { useEditorStore } from '@site/store/store' import { emptyDirtyMarks } from '@site/store/slices/site/dirtyTracking' @@ -54,7 +55,8 @@ function makeGatedAdapter(): { adapter: IPersistenceAdapter; saves: RecordedSave saveSite: (_site: SiteDocument, opts: SaveSiteOptions = {}) => { const gate = deferred() saves.push({ dirty: opts.dirty, gate }) - return gate.promise + // Each released save reports a strictly-increasing seq, like the server. + return gate.promise.then(() => ({ seq: saves.length })) }, } return { adapter, saves } @@ -206,3 +208,50 @@ describe('usePersistence single-flight save queue', () => { await second }) }) + +// --------------------------------------------------------------------------- +// Conflict flow — a 409'd save surfaces the resolution UI, loses nothing +// --------------------------------------------------------------------------- + +describe('usePersistence conflict flow', () => { + it('a SaveConflictError sets store.saveConflicts, restores the dirty marks, and bumps nothing', async () => { + seedStore() + useEditorStore.getState().seedBaseSeqs({ 'page-a': 3 }, 3) + const conflicts = [{ table: 'pages' as const, rowId: 'page-a', seq: 7 }] + const adapter: IPersistenceAdapter = { + loadSite: async () => undefined, + saveSite: async () => { + throw new SaveConflictError(conflicts) + }, + } + const save = await mountHook(adapter) + + editPage('page-a') + await expect(save()).rejects.toBeInstanceOf(SaveConflictError) + + const state = useEditorStore.getState() + expect(state.saveConflicts).toEqual(conflicts) + // The failed snapshot merged back — nothing lost, next save re-ships it. + expect(state._dirtySave.pageIds.has('page-a')).toBe(true) + // Base seqs untouched by the failure — only a resolution changes them. + expect(state.baseSeqs['page-a']).toBe(3) + }) + + it('a successful save commits the shipped bases to the response seq and clears conflicts', async () => { + seedStore() + useEditorStore.getState().seedBaseSeqs({ 'page-a': 3, 'page-b': 3 }, 3) + const adapter: IPersistenceAdapter = { + loadSite: async () => undefined, + saveSite: async () => ({ seq: 15 }), + } + const save = await mountHook(adapter) + + editPage('page-a') + await save() + + const state = useEditorStore.getState() + expect(state.baseSeqs['page-a']).toBe(15) + expect(state.baseSeqs['page-b']).toBe(3) + expect(state.shellBaseSeq).toBe(15) + }) +}) diff --git a/src/__tests__/server/capabilityRouteMatrix.test.ts b/src/__tests__/server/capabilityRouteMatrix.test.ts index c713a87d0..e0acb66d3 100644 --- a/src/__tests__/server/capabilityRouteMatrix.test.ts +++ b/src/__tests__/server/capabilityRouteMatrix.test.ts @@ -21,6 +21,28 @@ async function loadSiteShell( return body.site } +/** The shell's live sync seq — the conflict-detection base a fresh client would hold. */ +async function loadShellSeq( + harness: Awaited>, + cookie: string, +): Promise { + const res = await harness.cms('/admin/api/cms/site', { method: 'GET', cookie }) + expect(res.status).toBe(200) + const body = await readJson<{ seq?: number }>(res) + return body.seq ?? 0 +} + +/** Live per-row sync seqs for the pages collection — a fresh client's base map. */ +async function pagesBaseSeqs( + harness: Awaited>, + cookie: string, +): Promise> { + const res = await harness.cms('/admin/api/cms/pages', { method: 'GET', cookie }) + expect(res.status).toBe(200) + const body = await readJson<{ rows: DataRow[] }>(res) + return Object.fromEntries((body.rows ?? []).map((row) => [row.id, row.seq ?? 0])) +} + async function loadPages( harness: Awaited>, cookie: string, @@ -43,6 +65,8 @@ function siteDocBody(overrides: { deletedComponentIds?: string[] changedLayouts?: unknown[] deletedLayoutIds?: string[] + baseSeqs?: Record + shellBaseSeq?: number }): Record { return { mode: 'incremental', @@ -52,6 +76,11 @@ function siteDocBody(overrides: { deletedComponentIds: [], changedLayouts: [], deletedLayoutIds: [], + // Conflict-detection bases — scenarios that expect a save to LAND pass + // live values; scenarios rejected by the capability diff gate (403, + // phase 1) never reach the conflict check, so the defaults suffice. + baseSeqs: {}, + shellBaseSeq: 0, ...overrides, } } @@ -134,7 +163,10 @@ describe('capability route matrix', () => { const styleAllowed = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: styleUser.cookie, - json: siteDocBody({ site: styleEdit }), + json: siteDocBody({ + site: styleEdit, + shellBaseSeq: await loadShellSeq(harness, styleUser.cookie), + }), }) expect(styleAllowed.status).toBe(200) @@ -155,7 +187,10 @@ describe('capability route matrix', () => { const structureAllowed = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: structureUser.cookie, - json: siteDocBody({ site: { ...afterStyle, name: 'Capability Matrix Renamed' } }), + json: siteDocBody({ + site: { ...afterStyle, name: 'Capability Matrix Renamed' }, + shellBaseSeq: await loadShellSeq(harness, structureUser.cookie), + }), }) expect(structureAllowed.status).toBe(200) @@ -287,7 +322,11 @@ describe('capability route matrix', () => { const seedRes = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: ownerCookie, - json: siteDocBody({ site: ownerShell, changedPages: [seededPage] }), + json: siteDocBody({ + site: ownerShell, + changedPages: [seededPage], + baseSeqs: await pagesBaseSeqs(harness, ownerCookie), + }), }) expect(seedRes.status).toBe(200) @@ -299,7 +338,11 @@ describe('capability route matrix', () => { const editRes = await harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: contentEditor.cookie, - json: siteDocBody({ site: editorShell, changedPages: [editedPage] }), + json: siteDocBody({ + site: editorShell, + changedPages: [editedPage], + baseSeqs: await pagesBaseSeqs(harness, contentEditor.cookie), + }), }) expect(editRes.status).toBe(200) diff --git a/src/__tests__/server/cmsRouteAuthorization.test.ts b/src/__tests__/server/cmsRouteAuthorization.test.ts index 77742422c..1741029cf 100644 --- a/src/__tests__/server/cmsRouteAuthorization.test.ts +++ b/src/__tests__/server/cmsRouteAuthorization.test.ts @@ -109,7 +109,12 @@ async function currentSiteDocument(db: DbClient, cookie: string): Promise { deletedComponentIds: [], changedLayouts: [], deletedLayoutIds: [], + baseSeqs: {}, + shellBaseSeq: 0, }), headers: { 'content-type': 'application/json', diff --git a/src/__tests__/server/siteDocumentSave.test.ts b/src/__tests__/server/siteDocumentSave.test.ts index 78feb5d84..4ede58e3e 100644 --- a/src/__tests__/server/siteDocumentSave.test.ts +++ b/src/__tests__/server/siteDocumentSave.test.ts @@ -21,7 +21,12 @@ * - replace mode (imports): server-derived deletions, deleted*Ids must be * empty, * - the site-global sync seq: response seq strictly increases and is - * stamped on written AND deleted rows. + * stamped on written AND deleted rows, + * - conflict detection: incremental saves carry base seqs; a shipped row + * stored NEWER than its base (or with no base entry at all) 409s the + * whole save with a `conflicts` payload and writes nothing. The shell + * participates only when its content actually changed (row-only saves + * don't stamp the shell seq). Replace mode skips the check. * * Runs against a real isolated SQLite DB through the established capability * harness (`createCapabilityTestHarness` → `createTestDb`): migrations @@ -180,23 +185,73 @@ interface DocOverrides { deletedComponentIds?: string[] changedLayouts?: unknown[] deletedLayoutIds?: string[] + baseSeqs?: Record + shellBaseSeq?: number } -function putDoc(ctx: Ctx, overrides: DocOverrides = {}): Promise { +/** Every row id an incremental body touches — changed + deleted, all collections. */ +function touchedIds(body: { + changedPages: unknown[] + deletedPageIds: string[] + changedComponents: unknown[] + deletedComponentIds: string[] + changedLayouts: unknown[] + deletedLayoutIds: string[] +}): string[] { + const changed = [...body.changedPages, ...body.changedComponents, ...body.changedLayouts] + .map((row) => (row && typeof row === 'object' ? (row as { id?: unknown }).id : undefined)) + .filter((id): id is string => typeof id === 'string') + return [...changed, ...body.deletedPageIds, ...body.deletedComponentIds, ...body.deletedLayoutIds] +} + +/** Stored seqs for `ids` (soft-deleted rows included) — the up-to-date client's base map. */ +async function liveBaseSeqs(harness: CapabilityTestHarness, ids: string[]): Promise> { + if (ids.length === 0) return {} + const { rows } = await harness.db<{ id: string; seq: number }>` + select id, seq from data_rows + ` + const wanted = new Set(ids) + const bases: Record = {} + for (const row of rows) { + if (wanted.has(row.id)) bases[row.id] = Number(row.seq) + } + return bases +} + +async function liveShellSeq(harness: CapabilityTestHarness): Promise { + const { rows } = await harness.db<{ seq: number }>` + select seq from site where id = 'default' + ` + return rows[0] ? Number(rows[0].seq) : 0 +} + +/** + * PUT the document. Unless the test overrides them, `baseSeqs` and + * `shellBaseSeq` are derived from live storage — modeling a fully + * synchronized client, so pre-conflict scenarios stay focused on their own + * concern. Conflict tests pass stale values explicitly. + */ +async function putDoc(ctx: Ctx, overrides: DocOverrides = {}): Promise { + const json = { + mode: 'incremental' as const, + site: ctx.shell, + changedPages: [] as unknown[], + deletedPageIds: [] as string[], + changedComponents: [] as unknown[], + deletedComponentIds: [] as string[], + changedLayouts: [] as unknown[], + deletedLayoutIds: [] as string[], + ...overrides, + } + const body = { + ...json, + baseSeqs: json.baseSeqs ?? (await liveBaseSeqs(ctx.harness, touchedIds(json))), + shellBaseSeq: json.shellBaseSeq ?? (await liveShellSeq(ctx.harness)), + } return ctx.harness.cms('/admin/api/cms/site-document', { method: 'PUT', cookie: ctx.cookie, - json: { - mode: 'incremental', - site: ctx.shell, - changedPages: [], - deletedPageIds: [], - changedComponents: [], - deletedComponentIds: [], - changedLayouts: [], - deletedLayoutIds: [], - ...overrides, - }, + json: body, }) } @@ -748,3 +803,209 @@ describe('site-document save — sync seq', () => { } }) }) + +// --------------------------------------------------------------------------- +// Conflict detection — baseSeqs / shellBaseSeq (multi-admin level A) +// --------------------------------------------------------------------------- + +describe('site-document save — conflict detection', () => { + it('409s a save whose shipped row is stored NEWER than its base seq; nothing is written', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + // "Admin A" edits the row again — storage moves past `first`. + const second = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + // "Admin B" saves from the stale base — rejected, atomically. + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B stale')], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const body = await readJson<{ error: string; conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: second }]) + + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Admin A v2') + expect(rows.get('page-a')!.seq).toBe(second) + } finally { + await ctx.harness.cleanup() + } + }) + + it('Keep-mine: the identical save succeeds once the base seq is bumped to the conflict seq', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + const second = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + const stale = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B wins')], + baseSeqs: { 'page-a': first }, + }) + expect(stale.status).toBe(409) + + // Keep-mine bumps the base to the remote seq — the stated overwrite lands. + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin B wins')], + baseSeqs: { 'page-a': second }, + })) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Admin B wins') + } finally { + await ctx.harness.cleanup() + } + }) + + it('client-created rows (no stored counterpart) pass with no base entry', async () => { + const ctx = await setupHarness() + try { + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-new', 'fresh')], + baseSeqs: {}, + })) + } finally { + await ctx.harness.cleanup() + } + }) + + it('a stored row shipped with NO base entry conflicts — a blind overwrite is never silent', async () => { + const ctx = await setupHarness() + try { + const seq = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about')], + })) + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'blind write')], + baseSeqs: {}, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq }]) + } finally { + await ctx.harness.cleanup() + } + }) + + it('deleting a remotely-newer row conflicts; the row survives', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + const res = await putDoc(ctx, { + deletedPageIds: ['page-a'], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.deleted_at).toBeNull() + } finally { + await ctx.harness.cleanup() + } + }) + + it('editing a remotely-DELETED row conflicts — soft-deleted rows are visible to the check', async () => { + const ctx = await setupHarness() + try { + const first = await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + // "Admin A" deletes the row — the deletion stamps a newer seq. + const second = await expectOk(await putDoc(ctx, { deletedPageIds: ['page-a'] })) + + const res = await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'stale edit')], + baseSeqs: { 'page-a': first }, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: second }]) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.deleted_at).not.toBeNull() + } finally { + await ctx.harness.cleanup() + } + }) + + it('row-only saves do NOT stamp the shell seq (the shell write is skipped when unchanged)', async () => { + const ctx = await setupHarness() + try { + const before = await liveShellSeq(ctx.harness) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about')], + })) + expect(await liveShellSeq(ctx.harness)).toBe(before) + } finally { + await ctx.harness.cleanup() + } + }) + + it('shell conflicts fire only on REAL shell changes with a stale base', async () => { + const ctx = await setupHarness() + try { + // "Admin A" renames the site — a real shell change stamps the shell seq. + await expectOk(await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed by A' } })) + const shellSeq = await liveShellSeq(ctx.harness) + expect(shellSeq).toBeGreaterThan(0) + + // "Admin B" ships ANOTHER shell change from a stale base — 409. + const res = await putDoc(ctx, { + site: { ...ctx.shell, name: 'Renamed by B (stale)' }, + shellBaseSeq: shellSeq - 1, + }) + expect(res.status).toBe(409) + const body = await readJson<{ conflicts: unknown[] }>(res) + expect(body.conflicts).toEqual([{ table: 'site', rowId: 'default', seq: shellSeq }]) + + // But a row-only save re-shipping the CURRENT stored shell verbatim + // passes even with a stale shell base — unchanged content is no + // overwrite, so the coarse shell check must not fire. + const shellRes = await ctx.harness.cms('/admin/api/cms/site', { method: 'GET', cookie: ctx.cookie }) + const { site: storedShell } = await readJson<{ site: unknown }>(shellRes) + await expectOk(await putDoc(ctx, { + site: storedShell, + changedPages: [pagePayload('page-b', 'contact')], + shellBaseSeq: 0, + })) + } finally { + await ctx.harness.cleanup() + } + }) + + it('replace mode skips the conflict check entirely (imports replace deliberately)', async () => { + const ctx = await setupHarness() + try { + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Original')], + })) + await expectOk(await putDoc(ctx, { + changedPages: [pagePayload('page-a', 'about', 'Admin A v2')], + })) + + // Stale bases + replace mode — bulldozes by design. + await expectOk(await putDoc(ctx, { + mode: 'replace', + changedPages: [pagePayload('page-a', 'about', 'Imported')], + baseSeqs: { 'page-a': 0 }, + shellBaseSeq: 0, + })) + const rows = await storedRows(ctx.harness, 'pages') + expect(rows.get('page-a')!.cells_json.title).toBe('Imported') + } finally { + await ctx.harness.cleanup() + } + }) +}) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 5b07caee7..3944900f0 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -93,6 +93,15 @@ const SettingsModal = lazy(() => import('@admin/modals/Settings/SettingsModal').then((m) => ({ default: m.SettingsModal })), ) +// Save-conflict resolution banner — mounts only while a 409'd save has +// unresolved conflicts (multi-admin conflict safety, level A), so its chunk +// stays out of the route shell on first paint. +const SaveConflictBanner = lazy(() => + import('@admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner').then((m) => ({ + default: m.SaveConflictBanner, + })), +) + // Editor-only toolbar surface: preview iframe. It self-gates on store state, // but we ALSO conditionally render it at the call site (below) so its chunk // isn't fetched on first paint — the preview overlay drags in the entire @@ -117,6 +126,7 @@ export function AdminCanvasLayout() { const faviconUrl = useEditorStore((s) => s.site?.settings.faviconUrl ?? null) // Editor-only toolbar surface — gate its lazy chunk on store state. const previewOpen = useEditorStore((s) => s.previewOpen) + const hasSaveConflicts = useEditorStore((s) => s.saveConflicts.length > 0) // Settings modal mount gate. adminUi is the canonical source — the // editor's `settingsSlice.openSettings` mirrors into it, and the admin // shell reads from it too. @@ -240,6 +250,12 @@ export function AdminCanvasLayout() { )} /> + {hasSaveConflicts && ( + + + + )} + {loadEditorBody ? ( ((set, get) => { function persist(next: SiteDocument): void { set({ doc: next }) saveChain = saveChain - .then(() => cmsAdapter.saveSite(next, { dirty: SHELL_ONLY_DIRTY })) - .then(() => { + .then(() => + cmsAdapter.saveSite(next, { + dirty: SHELL_ONLY_DIRTY, + baseSeqs: {}, + shellBaseSeq: get().shellBaseSeq, + }), + ) + .then(({ seq }) => { + // This save's seq is the new shell base for the next one. + set({ shellBaseSeq: seq }) // Refresh the toolbar brand + any other site-summary readers, and let // the editor store re-hydrate from disk next time it loads. useAdminUi.getState().setSiteSummary({ @@ -103,12 +117,24 @@ const useSettingsDraftStore = create((set, get) => { }) .catch((err: unknown) => { console.error('[useSiteSettingsController] failed to save site settings:', err) + if (err instanceof SaveConflictError) { + // Another admin changed the shell since this modal loaded. The + // stale doc must not be re-saved over their work — force a reload + // on the next open instead of offering a per-row banner here. + set({ + doc: null, + status: 'idle', + error: 'Site settings were changed by another admin — close and reopen Settings to load the latest.', + }) + return + } set({ error: getErrorMessage(err, 'Failed to save site settings') }) }) } return { doc: null, + shellBaseSeq: 0, status: 'idle', error: null, @@ -118,12 +144,12 @@ const useSettingsDraftStore = create((set, get) => { set({ status: 'loading', error: null }) inFlightLoad = (async () => { try { - const site = await cmsAdapter.loadSite(SITE_ID) - if (!site) { + const result = await cmsAdapter.loadSite(SITE_ID) + if (!result) { set({ status: 'error', error: 'No site found. Complete first-run setup first.' }) return } - set({ doc: site, status: 'ready', error: null }) + set({ doc: result.site, shellBaseSeq: result.shellSeq, status: 'ready', error: null }) } catch (err) { console.error('[useSiteSettingsController] failed to load site settings:', err) set({ status: 'error', error: getErrorMessage(err, 'Failed to load site settings') }) diff --git a/src/admin/modals/SiteImport/shared/importPlanning.ts b/src/admin/modals/SiteImport/shared/importPlanning.ts index acde04a2d..f90302b10 100644 --- a/src/admin/modals/SiteImport/shared/importPlanning.ts +++ b/src/admin/modals/SiteImport/shared/importPlanning.ts @@ -174,10 +174,11 @@ export async function ensureCurrentSiteForStaticImport(): Promise const existingSite = useEditorStore.getState().site if (existingSite) return existingSite - const loadedSite = await cmsAdapter.loadSite('default') - if (loadedSite) { - useEditorStore.getState().loadSite(loadedSite) - return loadedSite + const loaded = await cmsAdapter.loadSite('default') + if (loaded) { + useEditorStore.getState().loadSite(loaded.site) + useEditorStore.getState().seedBaseSeqs(loaded.rowSeqs, loaded.shellSeq) + return loaded.site } return useEditorStore.getState().createSite('My Site') @@ -187,7 +188,10 @@ export async function ensureCurrentSiteForStaticImport(): Promise export async function saveImportedDraftSite(): Promise { const site = useEditorStore.getState().site if (!site) throw new Error('Import completed, but no draft site is loaded.') - await cmsAdapter.saveSite(site) + // Replace-mode full save (no dirty hints) — an import deliberately + // replaces whatever is stored, so there is no conflict check to feed. + const { seq } = await cmsAdapter.saveSite(site) + useEditorStore.getState().commitSavedBaseSeqs(site, undefined, seq) useEditorStore.getState().setHasUnsavedChanges(false) requestCmsSiteReload() } diff --git a/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx b/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx index f17d8c15e..c19d8ecdd 100644 --- a/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx +++ b/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx @@ -49,8 +49,9 @@ const FACTS: OnboardingFacts = { describe('OnboardingPanel framework import', () => { it('dispatches a CMS site reload after a successful import so the editor refetches', async () => { - const loadSpy = spyOn(cmsAdapter, 'loadSite').mockResolvedValue(fakeSite()) - const saveSpy = spyOn(cmsAdapter, 'saveSite').mockResolvedValue(undefined) + const loadSpy = spyOn(cmsAdapter, 'loadSite') + .mockResolvedValue({ site: fakeSite(), rowSeqs: {}, shellSeq: 0 }) + const saveSpy = spyOn(cmsAdapter, 'saveSite').mockResolvedValue({ seq: 1 }) let reloadFired = false const onReload = () => { reloadFired = true } diff --git a/src/admin/pages/dashboard/components/OnboardingPanel.tsx b/src/admin/pages/dashboard/components/OnboardingPanel.tsx index 709f18988..dc935b84d 100644 --- a/src/admin/pages/dashboard/components/OnboardingPanel.tsx +++ b/src/admin/pages/dashboard/components/OnboardingPanel.tsx @@ -135,8 +135,9 @@ export function OnboardingPanel({ facts, onDismiss, onFrameworkImported }: Onboa const onboardingApplier: FrameworkManagerApplier = { capabilities: { canRemove: true }, apply: async (target) => { - const site = await cmsAdapter.loadSite('default') - if (!site) throw new Error('Site is not ready yet — finish setup first.') + const loaded = await cmsAdapter.loadSite('default') + if (!loaded) throw new Error('Site is not ready yet — finish setup first.') + const { site, shellSeq } = loaded site.settings.framework = applyFrameworkPreset(site.settings.framework, target) // Regenerate / prune the generated `framework:` utility classes (and strip // stale classIds off nodes) to match the new settings — the same reconcile @@ -145,7 +146,9 @@ export function OnboardingPanel({ facts, onDismiss, onFrameworkImported }: Onboa // styleRules, so the Site editor keeps showing them until a hard refresh. reconcileFrameworkClasses(site) // Shell-only incremental save: framework settings live on the shell, - // no rows changed and nothing was deleted. + // no rows changed and nothing was deleted. The shell base seq comes + // from the load above — a concurrent shell change 409s instead of + // being silently overwritten. await cmsAdapter.saveSite(site, { dirty: { all: false, @@ -156,6 +159,8 @@ export function OnboardingPanel({ facts, onDismiss, onFrameworkImported }: Onboa deletedComponentIds: new Set(), deletedLayoutIds: new Set(), }, + baseSeqs: {}, + shellBaseSeq: shellSeq, }) // The framework settings were written to storage outside the editor. If // the Site editor's store was hydrated earlier this session, its in-memory diff --git a/src/admin/pages/dashboard/hooks/useOnboardingState.ts b/src/admin/pages/dashboard/hooks/useOnboardingState.ts index 166545067..d0aa3f4b7 100644 --- a/src/admin/pages/dashboard/hooks/useOnboardingState.ts +++ b/src/admin/pages/dashboard/hooks/useOnboardingState.ts @@ -58,7 +58,7 @@ export function useOnboardingState(): OnboardingStateResult { listCmsUsers(), ]) - const site = siteResult.status === 'fulfilled' ? siteResult.value : undefined + const site = siteResult.status === 'fulfilled' ? siteResult.value?.site : undefined const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.plugins : [] const users = usersResult.status === 'fulfilled' ? usersResult.value : [] diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index d99b9f63d..bb327c4f1 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -35,6 +35,7 @@ import { useEditorStore } from '@site/store/store' import type { SiteDocument } from '@core/page-tree' import type { IPersistenceAdapter } from '@core/persistence/types' import { cmsAdapter } from '@core/persistence/cms' +import { SaveConflictError } from '@core/persistence/saveConflict' import { SiteValidationError } from '@core/persistence/validate' import { readAutoSaveDelayMs, @@ -134,8 +135,14 @@ export function usePersistence( // Exception #1: referenced in the useCallback dep array of saveCurrentSite, // so exhaustive-deps requires a stable identity here. const runSave = useCallback(async () => { - const { site, setHasUnsavedChanges, takeDirtySaveSnapshot, restoreDirtySaveSnapshot } = - useEditorStore.getState() + const { + site, + setHasUnsavedChanges, + takeDirtySaveSnapshot, + restoreDirtySaveSnapshot, + baseSeqs, + shellBaseSeq, + } = useEditorStore.getState() if (!site) return setSaveStatus({ state: 'saving', message: 'Saving draft' }) @@ -144,7 +151,10 @@ export function usePersistence( // failed save merges this snapshot back so nothing is lost. const dirty = takeDirtySaveSnapshot() try { - await adapterRef.current.saveSite(site, { dirty }) + const { seq } = await adapterRef.current.saveSite(site, { dirty, baseSeqs, shellBaseSeq }) + // Storage now holds this save — bump the conflict-detection bases of + // everything it shipped to the save's seq. + useEditorStore.getState().commitSavedBaseSeqs(site, dirty, seq) // Clear the unsaved flag ONLY when no mutation landed while the save // was on the wire — every store mutation produces a new `site` // reference, so reference equality is the exact signal. Without this @@ -155,6 +165,13 @@ export function usePersistence( setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) } catch (err) { restoreDirtySaveSnapshot(dirty) + if (err instanceof SaveConflictError) { + // Another admin stored newer versions of rows this save shipped. + // Surface the resolution UI (conflict banner) instead of a plain + // error; the restored dirty marks keep the local edits safe while + // the user decides per row. + useEditorStore.getState().setSaveConflicts(err.conflicts) + } setSaveStatus({ state: 'error', message: errorMessage(err, 'Save failed') }) throw err } @@ -243,11 +260,14 @@ export function usePersistence( try { // The adapter validates internally (validateSite + validatePages). // Constraint #230 is satisfied at the adapter boundary. - const site = await adapterRef.current.loadSite(idToTry) - if (site && !cancelled) { + const result = await adapterRef.current.loadSite(idToTry) + if (result && !cancelled) { if (pendingCmsSiteReload) consumePendingCmsSiteReload() - loadSite(site) - applyDefaultBreakpointPreference(site.breakpoints) + loadSite(result.site) + // Seed the conflict-detection bases from the same read that + // produced the document — every future save compares against these. + useEditorStore.getState().seedBaseSeqs(result.rowSeqs, result.shellSeq) + applyDefaultBreakpointPreference(result.site.breakpoints) loadedRef.current = true setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) return @@ -287,9 +307,11 @@ export function usePersistence( try { // Replace-mode full save (no dirty hints): the site doesn't exist // in storage yet. - await adapterRef.current.saveSite(created) - // Storage now matches the store — drop the createSite all-dirty mark. + const { seq } = await adapterRef.current.saveSite(created) + // Storage now matches the store — drop the createSite all-dirty + // mark and seed the conflict-detection bases at the save's seq. useEditorStore.getState().takeDirtySaveSnapshot() + useEditorStore.getState().commitSavedBaseSeqs(created, undefined, seq) setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) } catch (err) { setHasUnsavedChanges(true) @@ -316,14 +338,15 @@ export function usePersistence( const pendingCmsSiteReload = hasPendingCmsSiteReload() try { // Adapter validates internally (Constraint #230). - const site = await adapterRef.current.loadSite(idToTry) - if (!site) { + const result = await adapterRef.current.loadSite(idToTry) + if (!result) { if (pendingCmsSiteReload) consumePendingCmsSiteReload() return } - const { loadSite, setHasUnsavedChanges } = useEditorStore.getState() - loadSite(site) - applyDefaultBreakpointPreference(site.breakpoints) + const { loadSite, setHasUnsavedChanges, seedBaseSeqs } = useEditorStore.getState() + loadSite(result.site) + seedBaseSeqs(result.rowSeqs, result.shellSeq) + applyDefaultBreakpointPreference(result.site.breakpoints) // The site doc on disk is now authoritative; clear the unsaved flag so // the auto-save loop doesn't immediately overwrite it back. setHasUnsavedChanges(false) @@ -356,6 +379,9 @@ export function usePersistence( clearTimeout(timer) if (!loadedRef.current) return if (!useEditorStore.getState().hasUnsavedChanges) return + // Pending conflicts need a per-row decision — an autosave retry would + // just 409 again. The conflict banner resumes saving on resolution. + if (useEditorStore.getState().saveConflicts.length > 0) return if (!readAutoSavePreference()) return // Read the delay each time auto-save is scheduled — toggling the @@ -390,11 +416,12 @@ export function usePersistence( // is retried by the next save). One request now, so either the whole // save lands or none of it does — no partial-prefix commits at unload. function flushBeforeUnload() { - const { site, hasUnsavedChanges, peekDirtySaveSnapshot } = useEditorStore.getState() + const { site, hasUnsavedChanges, peekDirtySaveSnapshot, baseSeqs, shellBaseSeq } = + useEditorStore.getState() if (!site || !loadedRef.current || !hasUnsavedChanges) return clearTimeout(timer) void adapterRef.current - .saveSite(site, { dirty: peekDirtySaveSnapshot() }) + .saveSite(site, { dirty: peekDirtySaveSnapshot(), baseSeqs, shellBaseSeq }) .catch((err) => { console.error('[persistence] flush save failed:', err) }) diff --git a/src/admin/pages/site/store/slices/saveTrackingSlice.ts b/src/admin/pages/site/store/slices/saveTrackingSlice.ts index fa210ac0c..66abfd83d 100644 --- a/src/admin/pages/site/store/slices/saveTrackingSlice.ts +++ b/src/admin/pages/site/store/slices/saveTrackingSlice.ts @@ -1,6 +1,10 @@ /** - * Save-tracking slice — the unsaved-changes flag and the patch-derived - * save-dirty accumulator (see slices/site/dirtyTracking.ts). + * Save-tracking slice — the unsaved-changes flag, the patch-derived + * save-dirty accumulator (see slices/site/dirtyTracking.ts), and the + * multi-admin sync bookkeeping: per-row + shell base seqs (the + * conflict-detection bases every incremental save ships) and the + * pending-conflicts list behind the SaveConflictBanner. Conflict RESOLUTION + * actions live in slices/site/conflictActions.ts — they mutate the document. * * Autosave takes a snapshot (which resets the accumulator), ships only the * named page/component/layout writes plus explicit deleted-row ids, and @@ -21,6 +25,7 @@ */ import type { SiteDocument } from '@core/page-tree' +import type { SaveConflict } from '@core/persistence/saveConflict' import type { EditorStoreSliceCreator } from '@site/store/types' import { emptyDirtyMarks, mergeDirtyMarks, type DirtyMarks } from './site/dirtyTracking' @@ -42,6 +47,32 @@ interface SaveTrackingSlice { peekDirtySaveSnapshot: () => DirtyMarks /** Merge a failed save's snapshot back so the next save retries it. */ restoreDirtySaveSnapshot: (marks: DirtyMarks) => void + + /** + * Conflict-detection bases: rowId → the sync seq this editor last + * synchronized with (seeded at load, bumped to the response seq on every + * successful save — for DELETED rows too, so an undo that resurrects a + * saved-deleted row carries the right base instead of reading as a blind + * overwrite). Every incremental save ships the subset covering its + * changed + deleted rows; the server 409s when storage is newer. + */ + baseSeqs: Record + /** The shell's counterpart to `baseSeqs` — one coarse seq for the whole shell. */ + shellBaseSeq: number + /** + * Conflicts reported by the last 409'd save, pending user resolution via + * the conflict banner (Keep mine / Load theirs — see site/conflictActions). + * Autosave is suppressed while non-empty; a successful save clears it. + */ + saveConflicts: SaveConflict[] + /** Replace the base-seq maps wholesale — the load/reload path. */ + seedBaseSeqs: (rowSeqs: Record, shellSeq: number) => void + /** + * After a successful save: bump the bases of everything the save shipped to + * the response seq. Incremental saves cover the dirty-snapshot ids + the + * shell; replace-mode saves rebuild the whole map from the shipped site. + */ + commitSavedBaseSeqs: (savedSite: SiteDocument, dirty: DirtyMarks | undefined, seq: number) => void } declare module '@site/store/types' { @@ -117,4 +148,40 @@ export const createSaveTrackingSlice: EditorStoreSliceCreator set((state) => { mergeDirtyMarks(state._dirtySave, marks) }), + + baseSeqs: {}, + shellBaseSeq: 0, + saveConflicts: [], + + seedBaseSeqs: (rowSeqs, shellSeq) => + set((state) => { + state.baseSeqs = { ...rowSeqs } + state.shellBaseSeq = shellSeq + }), + + commitSavedBaseSeqs: (savedSite, dirty, seq) => + set((state) => { + if (!dirty || dirty.all) { + // Replace-mode save — storage now holds exactly the shipped site. + const next: Record = {} + for (const page of savedSite.pages) next[page.id] = seq + for (const vc of savedSite.visualComponents) next[vc.id] = seq + for (const layout of savedSite.layouts) next[layout.id] = seq + state.baseSeqs = next + } else { + const shippedIds = [ + ...dirty.pageIds, ...dirty.deletedPageIds, + ...dirty.componentIds, ...dirty.deletedComponentIds, + ...dirty.layoutIds, ...dirty.deletedLayoutIds, + ] + for (const id of shippedIds) state.baseSeqs[id] = seq + } + // Correct even when the server skipped the shell write (content + // unchanged): the stored shell seq then stays BELOW this save's seq, + // and the conflict check only fires on stored > base. + state.shellBaseSeq = seq + // A successful save proves no shipped row conflicted — stale banner + // entries (all conflicts come from shipped rows) are moot. + state.saveConflicts = [] + }), }) diff --git a/src/admin/pages/site/store/slices/site/conflictActions.ts b/src/admin/pages/site/store/slices/site/conflictActions.ts new file mode 100644 index 000000000..488f494fc --- /dev/null +++ b/src/admin/pages/site/store/slices/site/conflictActions.ts @@ -0,0 +1,210 @@ +/** + * Save-conflict resolution actions — multi-admin conflict safety (level A). + * + * When an incremental save 409s, the failed save restores its dirty marks and + * `setSaveConflicts` fills the pending list; the conflict banner then offers + * two resolutions per conflicted target: + * + * Keep mine — bump the target's base seq to the remote seq. The local + * edits stay dirty and the NEXT save overwrites the remote + * version — a stated decision instead of a silent one. + * Load theirs — the banner fetches the remote state and hands it here as a + * `RemoteConflictSnapshot`; the target is swapped in place + * (or removed, when deleted remotely) WITHOUT undo history, + * its dirty marks are cleared, and the base seq syncs to the + * remote seq. + * + * Both resolutions clear the undo history: history entries are site-relative + * Mutative patches with array indices — replaying them across a remotely + * swapped tree is undefined behavior. (Keep-mine keeps the local document + * intact, so its history survives.) + * + * VC consumer propagation: adopting a remote Visual Component re-syncs the + * slot instances of every ref to it, and adopting a remote VC DELETION + * cascades ref removal — both through `mutateSite`, so the affected consumer + * pages earn REAL dirty marks (they now differ from storage and must ship + * with the next save). The history entries those mutations push are cleared + * along with everything else. + */ + +import type { BaseNode } from '@core/page-tree' +import { findHomePage, reconcileSiteExplorerInPlace, reindexNodeParents } from '@core/page-tree' +import { clonePackageJson } from '@core/site-dependencies/manifest' +import { cloneSiteRuntimeConfig } from '@core/site-runtime' +import type { EditorStore } from '@site/store/types' +import type { Draft } from 'mutative' +import { renderCache } from '@site/canvas/renderCache' +import { clearCanvasSelectionDraft } from '../selectionSlice' +import { allTreeNodeMaps, syncAllVCRefSlotInstances } from '../vcSlotReconcile' +import { cascadeRemoveVCRefs } from '../vcTreeOps' +import { reconcileFrameworkClasses } from './framework/reconcile' +import type { SiteSlice, SiteSliceHelpers } from './types' + +type ConflictActions = Pick< + SiteSlice, + 'setSaveConflicts' | 'resolveSaveConflictKeepMine' | 'resolveSaveConflictTheirs' +> + +function dropConflict(state: Draft, table: string, rowId: string): void { + state.saveConflicts = state.saveConflicts.filter( + (c) => !(c.table === table && c.rowId === rowId), + ) +} + +function clearUndoHistory(state: Draft): void { + state._historyPast = [] + state._historyFuture = [] + state._historyCoalesceKey = null + state.canUndo = false + state.canRedo = false +} + +export function createConflictActions({ set, mutateSite }: SiteSliceHelpers): ConflictActions { + return { + setSaveConflicts: (conflicts) => + set((state) => { + state.saveConflicts = [...conflicts] + }), + + resolveSaveConflictKeepMine: (conflict) => + set((state) => { + dropConflict(state, conflict.table, conflict.rowId) + if (conflict.table === 'site') { + state.shellBaseSeq = Math.max(state.shellBaseSeq, conflict.seq) + } else { + state.baseSeqs[conflict.rowId] = Math.max( + state.baseSeqs[conflict.rowId] ?? 0, + conflict.seq, + ) + } + // The failed save restored its dirty marks — nothing else to do; the + // next save ships the same rows and now passes the seq check. + }), + + resolveSaveConflictTheirs: (snapshot) => { + // Consumer propagation FIRST (see module doc): mutateSite gives the + // affected pages real patch-derived dirty marks. Neither helper needs + // the VC swapped in yet — the sync takes the remote VC as an argument, + // and the cascade only reads ref nodes. + if (snapshot.table === 'components') { + if (snapshot.row) { + const vc = snapshot.row + mutateSite((site) => { + syncAllVCRefSlotInstances(allTreeNodeMaps(site), vc.id, vc) + }) + } else { + mutateSite((site) => { + for (const page of site.pages) { + cascadeRemoveVCRefs(page.nodes as Record, snapshot.rowId) + } + for (const vc of site.visualComponents) { + if (vc.id !== snapshot.rowId) { + cascadeRemoveVCRefs(vc.tree.nodes as Record, snapshot.rowId) + } + } + }) + } + } + + // Remote HTML swaps under the canvas — drop cached renders wholesale. + renderCache.clear() + + set((state) => { + const site = state.site + if (!site) return + dropConflict(state, snapshot.table, snapshot.table === 'site' ? 'default' : snapshot.rowId) + + switch (snapshot.table) { + case 'site': { + // Overwrite every shell field in place; the row-backed + // collections stay untouched. `conditions` is the one optional + // shell key — drop it explicitly when the remote shell lacks it. + Object.assign(site, snapshot.shell) + if (snapshot.shell.conditions === undefined) delete site.conditions + reconcileFrameworkClasses(site) + reconcileSiteExplorerInPlace(site) + state.packageJson = clonePackageJson(snapshot.shell.packageJson) + state.siteRuntime = cloneSiteRuntimeConfig(snapshot.shell.runtime) + state.shellBaseSeq = snapshot.seq + break + } + + case 'pages': { + state._dirtySave.pageIds.delete(snapshot.rowId) + state._dirtySave.deletedPageIds.delete(snapshot.rowId) + const idx = site.pages.findIndex((p) => p.id === snapshot.rowId) + const wasActive = + state.activePageId === snapshot.rowId && + (state.activeDocument === null || state.activeDocument.kind === 'page') + if (snapshot.row) { + reindexNodeParents(snapshot.row.nodes) + if (idx >= 0) site.pages[idx] = snapshot.row + else site.pages.push(snapshot.row) + state.baseSeqs[snapshot.rowId] = snapshot.seq + } else { + if (idx >= 0) site.pages.splice(idx, 1) + delete state.baseSeqs[snapshot.rowId] + if (state.activePageId === snapshot.rowId) { + state.activePageId = (findHomePage(site.pages) ?? site.pages[0])?.id ?? null + } + // A page-kind activeDocument pointing at the removed page would + // make mutateActiveTree silently no-op — fall back to page mode. + if ( + state.activeDocument?.kind === 'page' && + state.activeDocument.pageId === snapshot.rowId + ) { + state.activeDocument = null + } + } + reconcileSiteExplorerInPlace(site) + // The canvas may hold selection/hover state into the replaced + // (or removed) tree — clear it when that document was active. + if (wasActive) clearCanvasSelectionDraft(state) + break + } + + case 'components': { + state._dirtySave.componentIds.delete(snapshot.rowId) + state._dirtySave.deletedComponentIds.delete(snapshot.rowId) + const idx = site.visualComponents.findIndex((vc) => vc.id === snapshot.rowId) + const wasActive = + state.activeDocument?.kind === 'visualComponent' && + state.activeDocument.vcId === snapshot.rowId + if (snapshot.row) { + reindexNodeParents(snapshot.row.tree.nodes) + if (idx >= 0) site.visualComponents[idx] = snapshot.row + else site.visualComponents.push(snapshot.row) + state.baseSeqs[snapshot.rowId] = snapshot.seq + } else { + if (idx >= 0) site.visualComponents.splice(idx, 1) + delete state.baseSeqs[snapshot.rowId] + // The open document was deleted remotely — fall back to page mode. + if (wasActive) state.activeDocument = null + } + reconcileSiteExplorerInPlace(site) + if (wasActive) clearCanvasSelectionDraft(state) + break + } + + case 'layouts': { + state._dirtySave.layoutIds.delete(snapshot.rowId) + state._dirtySave.deletedLayoutIds.delete(snapshot.rowId) + const idx = site.layouts.findIndex((layout) => layout.id === snapshot.rowId) + if (snapshot.row) { + reindexNodeParents(snapshot.row.nodes) + if (idx >= 0) site.layouts[idx] = snapshot.row + else site.layouts.push(snapshot.row) + state.baseSeqs[snapshot.rowId] = snapshot.seq + } else { + if (idx >= 0) site.layouts.splice(idx, 1) + delete state.baseSeqs[snapshot.rowId] + } + break + } + } + + clearUndoHistory(state) + }) + }, + } +} diff --git a/src/admin/pages/site/store/slices/site/lifecycleActions.ts b/src/admin/pages/site/store/slices/site/lifecycleActions.ts index 4e260d2e8..2a8d200d6 100644 --- a/src/admin/pages/site/store/slices/site/lifecycleActions.ts +++ b/src/admin/pages/site/store/slices/site/lifecycleActions.ts @@ -66,6 +66,10 @@ export function createLifecycleActions({ state.hasUnsavedChanges = false // A brand-new site has no stored rows at all — first save is full. state._dirtySave = { ...emptyDirtyMarks(), all: true } + // No stored rows → no sync bases and nothing to conflict with. + state.baseSeqs = {} + state.shellBaseSeq = 0 + state.saveConflicts = [] }) return site }, @@ -96,6 +100,12 @@ export function createLifecycleActions({ state.canRedo = false state.hasUnsavedChanges = false state._dirtySave = emptyDirtyMarks() + // Sync bases for the fresh document are seeded by the load path + // (usePersistence → seedBaseSeqs) — reset here so a hand-assembled + // load never inherits a previous document's bases or conflicts. + state.baseSeqs = {} + state.shellBaseSeq = 0 + state.saveConflicts = [] }) }, @@ -114,6 +124,9 @@ export function createLifecycleActions({ state.canUndo = false state.canRedo = false state._dirtySave = emptyDirtyMarks() + state.baseSeqs = {} + state.shellBaseSeq = 0 + state.saveConflicts = [] }) }, diff --git a/src/admin/pages/site/store/slices/site/types.ts b/src/admin/pages/site/store/slices/site/types.ts index 5d03e62ac..c22c425ec 100644 --- a/src/admin/pages/site/store/slices/site/types.ts +++ b/src/admin/pages/site/store/slices/site/types.ts @@ -20,17 +20,32 @@ import type { SiteDocument, SiteExplorerSectionId, SiteSettings, + SiteShell, PageTemplateConfig, ConditionDef, StructuralExplorerRowOrder, StructuralSiteExplorerSectionId, } from '@core/page-tree' +import type { SaveConflict } from '@core/persistence/saveConflict' +import type { VisualComponent } from '@core/visualComponents' +import type { SavedLayout } from '@core/layouts' import type { FontEntry, FontToken } from '@core/fonts' import type { ImportFragment } from '@core/htmlImport' import type { NewStyleRule, SiteImportTransaction } from '@core/siteImport' import type { FrameworkChangeImpact, FrameworkPreset } from '@core/framework' import type { EditorStore } from '@site/store/types' +/** + * The remote ("theirs") state of one conflicted target, fetched by the + * conflict banner for `resolveSaveConflictTheirs`. `row: null` means the + * target was deleted remotely — resolution removes it locally. + */ +export type RemoteConflictSnapshot = + | { table: 'site'; shell: SiteShell; seq: number } + | { table: 'pages'; rowId: string; row: Page | null; seq: number } + | { table: 'components'; rowId: string; row: VisualComponent | null; seq: number } + | { table: 'layouts'; rowId: string; row: SavedLayout | null; seq: number } + // --------------------------------------------------------------------------- // Public action surface — every method below appears as a top-level entry on @@ -128,6 +143,23 @@ export interface SiteSlice { clearSite: () => void updateSiteName: (name: string) => void + // Save-conflict resolution (multi-admin conflict safety — level A) + /** Replace the pending-conflicts list (set from the save pipeline's 409 handler). */ + setSaveConflicts: (conflicts: readonly SaveConflict[]) => void + /** + * Keep the local version: bump the target's base seq to the remote seq so + * the next save passes the conflict check — the overwrite becomes a stated + * decision instead of a silent one. Local dirty marks stay untouched. + */ + resolveSaveConflictKeepMine: (conflict: SaveConflict) => void + /** + * Adopt the remote version: swap it into the document (or remove the + * target when deleted remotely) without pushing undo history, clear the + * target's dirty marks, and sync the base seq. Clears the undo history — + * site-relative patches are undefined across a remotely swapped tree. + */ + resolveSaveConflictTheirs: (snapshot: RemoteConflictSnapshot) => void + // Page mutations addPage: (title: string, slug?: string) => Page deletePage: (pageId: string) => void diff --git a/src/admin/pages/site/store/slices/siteSlice.ts b/src/admin/pages/site/store/slices/siteSlice.ts index a677c99e0..c3dac7cdb 100644 --- a/src/admin/pages/site/store/slices/siteSlice.ts +++ b/src/admin/pages/site/store/slices/siteSlice.ts @@ -12,6 +12,7 @@ * - `./site/helpers` — buildSiteHelpers (mutate* + patch-based history) + depthInTree * - `./site/undoRedoActions` — undo / redo * - `./site/lifecycleActions` — createSite / loadSite / clearSite / updateSiteName + * - `./site/conflictActions` — save-conflict resolution (Keep mine / Load theirs) * - `./site/pageActions` — page CRUD + template conversions * - `./site/explorerActions` — Site Explorer folder/order organization * - `./site/nodeActions` — the 11 named tree mutations + multi-select variants + dynamic bindings @@ -25,6 +26,7 @@ import type { EditorStoreSliceCreator } from '@site/store/types' import { buildSiteHelpers } from './site/helpers' import { createUndoRedoActions } from './site/undoRedoActions' import { createLifecycleActions } from './site/lifecycleActions' +import { createConflictActions } from './site/conflictActions' import { createPageActions } from './site/pageActions' import { createExplorerActions } from './site/explorerActions' import { createNodeActions } from './site/nodeActions' @@ -72,6 +74,7 @@ export const createSiteSlice: EditorStoreSliceCreator = (set, get) => // ─── Action surface ────────────────────────────────────────────────────── ...createUndoRedoActions(helpers), ...createLifecycleActions(helpers), + ...createConflictActions(helpers), ...createPageActions(helpers), ...createExplorerActions(helpers), ...createNodeActions(helpers), diff --git a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css new file mode 100644 index 000000000..02d330d95 --- /dev/null +++ b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css @@ -0,0 +1,64 @@ +/** + * Save-conflict banner — a persistent resolution surface floating below the + * toolbar. Warning identity comes from the 2px left rule (Toast convention); + * everything else stays achromatic per the two-layer color model. + */ + +.banner { + position: fixed; + top: 64px; + left: 50%; + transform: translateX(-50%); + z-index: var(--z-dropdown); + width: min(560px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-m) var(--space-l); + background: var(--bg-surface); + border: 1px solid var(--overlay-10); + border-left: 2px solid var(--warning); + border-radius: var(--panel-radius); + box-shadow: var(--shadow-panel); +} + +.title { + font-size: var(--text-m); + font-weight: 600; + color: var(--text); +} + +.subtitle { + font-size: var(--text-s); + color: var(--text-subtle); +} + +.conflictList { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.conflictRow { + display: flex; + align-items: center; + gap: var(--space-s); + padding: var(--space-xs) 0; +} + +.conflictLabel { + flex: 1; + min-width: 0; + font-size: var(--text-s); + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conflictKind { + color: var(--text-subtle); +} diff --git a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx new file mode 100644 index 000000000..75a0e840d --- /dev/null +++ b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx @@ -0,0 +1,166 @@ +/** + * SaveConflictBanner — the resolution UI for 409'd saves (multi-admin + * conflict safety, level A). + * + * Renders when the store holds pending `saveConflicts` (set by the save + * pipeline when the server rejects an incremental save because another admin + * stored newer versions of shipped rows). One row per conflicted target with + * the two resolutions: + * + * Load theirs — fetch the remote state (single-row `?id=` fetch, or the + * shell GET) and adopt it via `resolveSaveConflictTheirs`, + * discarding the local unsaved edits to that target only. + * Keep mine — `resolveSaveConflictKeepMine` bumps the base seq; the next + * save overwrites the remote version as a stated decision. + * The button label carries the "(overwrite)" warning — that + * IS the explicit confirmation. + * + * This is deliberately a persistent banner, not a toast: a conflict is not a + * transient failure but a decision the user must make per target, and the + * unsaved local edits stay parked until they do (autosave is suppressed + * while conflicts pend). Once the last conflict is resolved, the pending + * edits are flushed through the editor save queue (`flushEditorSave`). + */ + +import { useState } from 'react' +import type { SiteDocument } from '@core/page-tree' +import type { SaveConflict } from '@core/persistence/saveConflict' +import { cmsAdapter } from '@core/persistence/cms' +import { pageFromRow } from '@core/data/pageFromRow' +import { visualComponentFromRow } from '@core/data/componentFromRow' +import { savedLayoutFromRow } from '@core/data/layoutFromRow' +import { getErrorMessage } from '@core/utils/errorMessage' +import { Button } from '@ui/components/Button' +import { pushToast } from '@ui/components/Toast' +import { useEditorStore } from '@site/store/store' +import type { RemoteConflictSnapshot } from '@site/store/slices/site/types' +import { flushEditorSave } from '@site/hooks/editorSaveRef' +import styles from './SaveConflictBanner.module.css' + +const KIND_LABEL: Record = { + site: 'Site settings', + pages: 'Page', + components: 'Component', + layouts: 'Layout', +} + +/** Human label for a conflicted target, resolved from the local document. */ +function conflictLabel(site: SiteDocument | null, conflict: SaveConflict): string { + if (conflict.table === 'site') return 'Site settings & styles' + if (!site) return conflict.rowId + if (conflict.table === 'pages') { + return site.pages.find((p) => p.id === conflict.rowId)?.title ?? conflict.rowId + } + if (conflict.table === 'components') { + return site.visualComponents.find((vc) => vc.id === conflict.rowId)?.name ?? conflict.rowId + } + return site.layouts.find((layout) => layout.id === conflict.rowId)?.name ?? conflict.rowId +} + +/** Fetch the remote ("theirs") state of one conflicted target. */ +async function fetchRemoteSnapshot(conflict: SaveConflict): Promise { + if (conflict.table === 'site') { + const remote = await cmsAdapter.loadSiteShell() + if (!remote) throw new Error('The site shell no longer exists on the server.') + return { table: 'site', shell: remote.shell, seq: remote.seq } + } + const row = await cmsAdapter.loadSiteRow(conflict.table, conflict.rowId) + const seq = row?.seq ?? conflict.seq + if (conflict.table === 'pages') { + return { table: 'pages', rowId: conflict.rowId, row: row ? pageFromRow(row) : null, seq } + } + if (conflict.table === 'components') { + return { + table: 'components', + rowId: conflict.rowId, + row: row ? visualComponentFromRow(row) : null, + seq, + } + } + return { table: 'layouts', rowId: conflict.rowId, row: row ? savedLayoutFromRow(row) : null, seq } +} + +/** Ship the surviving local edits once the last conflict is decided. */ +async function flushIfResolved(): Promise { + const { saveConflicts, hasUnsavedChanges } = useEditorStore.getState() + if (saveConflicts.length > 0 || !hasUnsavedChanges) return + await flushEditorSave() +} + +export function SaveConflictBanner() { + const conflicts = useEditorStore((s) => s.saveConflicts) + // Subscribed (not getState) so labels track renames/removals live. + const site = useEditorStore((s) => s.site) + const [busyKey, setBusyKey] = useState(null) + + if (conflicts.length === 0) return null + + async function loadTheirs(conflict: SaveConflict) { + setBusyKey(`${conflict.table}:${conflict.rowId}`) + try { + const snapshot = await fetchRemoteSnapshot(conflict) + useEditorStore.getState().resolveSaveConflictTheirs(snapshot) + await flushIfResolved() + } catch (err) { + console.error('[SaveConflictBanner] failed to load the remote version:', err) + pushToast({ + kind: 'error', + title: 'Could not load their version', + body: getErrorMessage(err, 'Unknown conflict-resolution error'), + }) + } finally { + setBusyKey(null) + } + } + + async function keepMine(conflict: SaveConflict) { + useEditorStore.getState().resolveSaveConflictKeepMine(conflict) + try { + await flushIfResolved() + } catch (err) { + // The save pipeline already surfaces failures via saveStatus / a + // fresh conflict banner; log for the console trail only. + console.error('[SaveConflictBanner] post-resolution save failed:', err) + } + } + + return ( +
+
Another admin saved newer changes
+
+ Your edits are safe but unsaved. Decide per item: load their version + (discards your unsaved edits to it) or keep yours (overwrites theirs on + the next save). +
+
    + {conflicts.map((conflict) => { + const key = `${conflict.table}:${conflict.rowId}` + return ( +
  • + + {KIND_LABEL[conflict.table]} · + {conflictLabel(site, conflict)} + + + +
  • + ) + })} +
+
+ ) +} diff --git a/src/admin/spotlight/commands/editor.ts b/src/admin/spotlight/commands/editor.ts index 3ae410179..561286a42 100644 --- a/src/admin/spotlight/commands/editor.ts +++ b/src/admin/spotlight/commands/editor.ts @@ -4,7 +4,10 @@ * * All commands are gated to workspace: ['site'] only. * Undo/redo use useEditorStore.getState() (Zustand getState is safe outside React). - * Save uses cmsAdapter.saveSite() directly (mirrors usePersistence logic). + * Save flushes through `usePersistence`'s registered save (editorSaveRef) so + * it rides the single-flight queue, the dirty-mark snapshot, and the + * conflict-detection base seqs — a raw adapter call here would bulldoze all + * three and ship a replace-mode full save. * Publish calls publishCmsDraft() from the persistence layer, wrapped in * `ctx.runStepUp` so the StepUpProvider's password re-entry dialog opens * when the server replies with `step_up_required` (publish is gated on a @@ -12,7 +15,7 @@ */ import { StepUpCancelledMessage } from '@admin/shared/StepUp' -import { cmsAdapter, publishCmsDraft } from '@core/persistence' +import { publishCmsDraft } from '@core/persistence' import type { Command } from '../types' /** Mirrors `SITE_WRITE_CAPABILITIES` — any holder can save a draft. */ @@ -36,12 +39,10 @@ export function getEditorCommands(): Command[] { run: async (ctx) => { ctx.closeSpotlight() try { - // Import lazily to avoid loading the editor store in non-site contexts. - const { useEditorStore } = await import('@site/store/store') - const { site, setHasUnsavedChanges } = useEditorStore.getState() - if (!site) return - await cmsAdapter.saveSite(site) - setHasUnsavedChanges(false) + // Import lazily to avoid loading editor code in non-site contexts. + // No-op when the editor isn't mounted (the command is site-only). + const { flushEditorSave } = await import('@site/hooks/editorSaveRef') + await flushEditorSave() } catch (err) { console.error('[spotlight] save failed:', err) } diff --git a/src/admin/state/useSiteSummary.ts b/src/admin/state/useSiteSummary.ts index 0a20adf65..f33f431ab 100644 --- a/src/admin/state/useSiteSummary.ts +++ b/src/admin/state/useSiteSummary.ts @@ -33,7 +33,7 @@ let initialFetchPromise: Promise | null = null async function fetchAndPublishSummary(): Promise { try { - const site = await cmsAdapter.loadSite('default') + const site = (await cmsAdapter.loadSite('default'))?.site useAdminUi.getState().setSiteSummary({ name: site?.name ?? null, faviconUrl: site?.settings?.faviconUrl ?? null, diff --git a/src/core/data/schemas.ts b/src/core/data/schemas.ts index a76f7b181..51d2bd774 100644 --- a/src/core/data/schemas.ts +++ b/src/core/data/schemas.ts @@ -356,6 +356,14 @@ export const DataRowSchema = Type.Object({ /** Denormalized from `cells.slug` for fast unique / route lookup. */ slug: Type.String(), status: DataRowStatusSchema, + /** + * Site-global sync seq stamped by the last transactional save that wrote or + * soft-deleted this row (0 = never stamped). Conflict-detection and + * delta-reconciliation substrate for multi-admin sync. OPTIONAL because + * `DataRowSchema` also validates bundle archives exported before seqs + * existed — server reads always populate it. + */ + seq: Type.Optional(Type.Number()), authorUserId: NullableUserIdSchema, createdByUserId: NullableUserIdSchema, updatedByUserId: NullableUserIdSchema, diff --git a/src/core/persistence/cms.ts b/src/core/persistence/cms.ts index 9e1b90d29..3e16aa228 100644 --- a/src/core/persistence/cms.ts +++ b/src/core/persistence/cms.ts @@ -1,7 +1,13 @@ import { reconcileSiteExplorerOrganization, type SiteDocument, type SiteShell } from '@core/page-tree' -import type { IPersistenceAdapter, SaveSiteOptions } from './types' +import type { + IPersistenceAdapter, + SaveSiteOptions, + SaveSiteResult, + SiteLoadResult, +} from './types' +import { SaveConflictError, SaveConflictsEnvelopeSchema } from './saveConflict' import { parseJsonResponse } from '@core/utils/jsonValidate' -import { apiRequest, assertOk, type FetchLike } from '@core/http' +import { apiRequest, assertOk, readEnvelope, type FetchLike } from '@core/http' import { CmsSiteEnvelopeSchema, CmsSiteDocumentSaveEnvelopeSchema, @@ -11,6 +17,7 @@ import { } from './responseSchemas' import { validateSite, validatePages, validateVisualComponents } from './validate' import { validateSavedLayouts } from './validateLayouts' +import type { DataRow } from '@core/data/schemas' import { pageFromRow } from '@core/data/pageFromRow' import { visualComponentFromRow } from '@core/data/componentFromRow' import { savedLayoutFromRow } from '@core/data/layoutFromRow' @@ -19,6 +26,15 @@ import type { SavedLayout } from '@core/layouts' const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init) +/** The three row-backed site collections (the shell is not one of them). */ +export type SiteCollectionTable = 'pages' | 'components' | 'layouts' + +const COLLECTION_ENVELOPES = { + pages: CmsPagesEnvelopeSchema, + components: CmsComponentsEnvelopeSchema, + layouts: CmsLayoutsEnvelopeSchema, +} as const + export class CmsAdapter implements IPersistenceAdapter { private readonly fetchImpl: FetchLike private readonly basePath: string @@ -48,12 +64,29 @@ export class CmsAdapter implements IPersistenceAdapter { * gone: the server validates pages against the merged post-save component * roster inside the same transaction. */ - async saveSite(site: SiteDocument, opts: SaveSiteOptions = {}): Promise { + async saveSite(site: SiteDocument, opts: SaveSiteOptions = {}): Promise { // Extract shell (strip the row-backed collections from the full SiteDocument) const { pages, visualComponents, layouts, ...shell } = site const { dirty } = opts const incremental = dirty !== undefined && !dirty.all + // Ship the base seqs covering exactly the rows this save touches — + // changed AND deleted (deleting a remotely-newer row is an overwrite + // too). Rows the client has never synchronized (its own creations) have + // no entry, which is how the server tells creations apart. + const baseSeqs: Record = {} + if (incremental) { + const shippedIds = [ + ...dirty.pageIds, ...dirty.deletedPageIds, + ...dirty.componentIds, ...dirty.deletedComponentIds, + ...dirty.layoutIds, ...dirty.deletedLayoutIds, + ] + for (const id of shippedIds) { + const base = opts.baseSeqs?.[id] + if (base !== undefined) baseSeqs[id] = base + } + } + const body = incremental ? { mode: 'incremental', @@ -64,6 +97,8 @@ export class CmsAdapter implements IPersistenceAdapter { deletedComponentIds: [...dirty.deletedComponentIds], changedLayouts: layouts.filter((layout) => dirty.layoutIds.has(layout.id)), deletedLayoutIds: [...dirty.deletedLayoutIds], + baseSeqs, + shellBaseSeq: opts.shellBaseSeq ?? 0, } : { mode: 'replace', @@ -74,15 +109,55 @@ export class CmsAdapter implements IPersistenceAdapter { deletedComponentIds: [], changedLayouts: layouts, deletedLayoutIds: [], + // Ignored in replace mode — imports and bootstraps replace + // deliberately, so there is nothing to conflict with. + baseSeqs, + shellBaseSeq: opts.shellBaseSeq ?? 0, } - await apiRequest(`${this.basePath}/site-document`, { + // Own fetch instead of `apiRequest`: a 409 carries the typed conflicts + // payload, which the generic ApiError cannot transport. + const res = await this.fetchImpl(`${this.basePath}/site-document`, { method: 'PUT', - body, - schema: CmsSiteDocumentSaveEnvelopeSchema, + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (res.status === 409) { + const conflictBody = await parseJsonResponse(res, SaveConflictsEnvelopeSchema) + throw new SaveConflictError(conflictBody.conflicts) + } + const saved = await readEnvelope(res, CmsSiteDocumentSaveEnvelopeSchema, 'Site save failed') + return { seq: saved.seq } + } + + /** + * Load the current shell (with its sync seq) on its own — the conflict + * banner's "Load theirs" fetch for a shell conflict. + */ + async loadSiteShell(): Promise<{ shell: SiteShell; seq: number } | undefined> { + const body = await apiRequest(`${this.basePath}/site`, { + schema: CmsSiteEnvelopeSchema, fetchImpl: this.fetchImpl, - fallbackMessage: 'Site save failed', + fallbackMessage: 'CMS shell load failed', }) + if (!body.site) return undefined + return { shell: validateSite(body.site), seq: body.seq ?? 0 } + } + + /** + * Load a single row of one site collection — the conflict banner's + * "Load theirs" fetch. Returns null when the row is (soft-)deleted: + * absence IS the "deleted remotely" signal. + */ + async loadSiteRow(table: SiteCollectionTable, rowId: string): Promise { + const body = await apiRequest(`${this.basePath}/${table}`, { + query: { id: rowId }, + schema: COLLECTION_ENVELOPES[table], + fetchImpl: this.fetchImpl, + fallbackMessage: `CMS ${table} row load failed`, + }) + return body.rows?.[0] ?? null } /** @@ -96,8 +171,11 @@ export class CmsAdapter implements IPersistenceAdapter { * savedLayoutFromRow, validated by validateSavedLayouts) * * Returns undefined when any endpoint returns 404 (before setup). + * + * Alongside the document, returns the sync-seq bases (per-row + shell) the + * editor tracks for save conflict detection. */ - async loadSite(_id: string): Promise { + async loadSite(_id: string): Promise { // Parallel fetch — all four are GETs with no dependency on each other const [shellRes, pagesRes, componentsRes, layoutsRes] = await Promise.all([ this.fetchImpl(`${this.basePath}/site`, { @@ -168,7 +246,14 @@ export class CmsAdapter implements IPersistenceAdapter { const site: SiteDocument = { ...shell, pages, visualComponents, layouts } site.explorer = reconcileSiteExplorerOrganization(site.explorer, site) - return site + + // Sync-seq bases: one entry per stored row (across all three + // collections — row ids are globally unique) plus the shell's seq. + const rowSeqs: Record = {} + for (const row of [...rawDataRows, ...rawVCRows, ...(layoutsBody.rows ?? [])]) { + rowSeqs[row.id] = row.seq ?? 0 + } + return { site, rowSeqs, shellSeq: shellBody.seq ?? 0 } } } diff --git a/src/core/persistence/responseSchemas.ts b/src/core/persistence/responseSchemas.ts index 60aee7aeb..d972651b2 100644 --- a/src/core/persistence/responseSchemas.ts +++ b/src/core/persistence/responseSchemas.ts @@ -227,16 +227,24 @@ export const CmsRuntimePreviewResponseSchema = Type.Object( // cms.ts — envelopes only; inner types are deep // --------------------------------------------------------------------------- +/** + * Envelope for GET /admin/api/cms/site. `seq` is the shell's sync sequence + * number — the client's conflict-detection base for shell changes (absent + * only in pre-setup 404 shapes, which return no `site` either). + */ export const CmsSiteEnvelopeSchema = Type.Object( - { site: Type.Optional(Type.Unknown()) }, + { + site: Type.Optional(Type.Unknown()), + seq: Type.Optional(Type.Number()), + }, { additionalProperties: true }, ) /** * Envelope for PUT /admin/api/cms/site-document — the transactional - * whole-document save. `seq` is the save's site-global sync sequence number - * (multi-admin sync substrate; informational to the client until the - * live-sync plan consumes it for conflict detection). + * whole-document save. `seq` is the save's site-global sync sequence number: + * every row the save wrote or deleted (and the shell, when it changed) is + * stamped with it, so the client bumps its base seqs to `seq` on success. */ export const CmsSiteDocumentSaveEnvelopeSchema = Type.Object( { diff --git a/src/core/persistence/saveConflict.ts b/src/core/persistence/saveConflict.ts new file mode 100644 index 000000000..c6f626d50 --- /dev/null +++ b/src/core/persistence/saveConflict.ts @@ -0,0 +1,57 @@ +/** + * Save-conflict protocol shapes — shared by the transactional save endpoint + * (server) and the CMS persistence adapter (client). + * + * An incremental site-document save carries base seqs: for every changed or + * deleted row, the site-global sync seq the client last synchronized with + * (seeded at load, bumped on every successful save). Inside the save + * transaction the server compares each shipped row's STORED seq against its + * base seq; any stored row that is newer — or that the client has no base for + * at all — means the save would silently overwrite another admin's work, so + * the whole save is rejected with 409 and this envelope. Nothing is written. + * + * The shell participates coarsely: one seq for the whole shell (settings + + * style rules + files), checked only when the incoming shell actually differs + * from the stored one. `table: 'site'` conflicts carry the draft-site row id + * (`'default'`). + * + * The same `SaveConflictError` class is thrown on both sides: the server + * handler throws it out of the transaction (caught and turned into the 409 + * response), and the client adapter re-throws it after parsing the 409 body — + * so editor code has exactly one typed error to branch on. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' + +export const SaveConflictSchema = Type.Object({ + /** Which collection conflicted; `'site'` is the shell (rowId `'default'`). */ + table: Type.Union([ + Type.Literal('site'), + Type.Literal('pages'), + Type.Literal('components'), + Type.Literal('layouts'), + ]), + rowId: Type.String(), + /** The STORED row's seq — the newer version the save would have overwritten. */ + seq: Type.Number(), +}) + +export type SaveConflict = Static + +/** 409 response body of PUT /admin/api/cms/site-document. */ +export const SaveConflictsEnvelopeSchema = Type.Object( + { + error: Type.String(), + conflicts: Type.Array(SaveConflictSchema), + }, + { additionalProperties: true }, +) + +export class SaveConflictError extends Error { + readonly conflicts: SaveConflict[] + + constructor(conflicts: SaveConflict[]) { + super('Another admin saved a newer version of content in this save') + this.name = 'SaveConflictError' + this.conflicts = conflicts + } +} diff --git a/src/core/persistence/types.ts b/src/core/persistence/types.ts index 981a7e52c..6e93dd799 100644 --- a/src/core/persistence/types.ts +++ b/src/core/persistence/types.ts @@ -25,6 +25,34 @@ interface SaveDirtyHints { export interface SaveSiteOptions { /** Dirty hints from the editor store. Absent → replace-mode full save. */ dirty?: SaveDirtyHints + /** + * Conflict-detection bases for an incremental save: rowId → the sync seq + * the client last synchronized with (seeded at load, bumped on every + * successful save). The adapter ships the subset covering the changed and + * deleted rows; the server 409s the save when any stored row is newer. + * Irrelevant for replace-mode saves (the server skips the check). + */ + baseSeqs?: Readonly> + /** The shell seq the client last synchronized with (same protocol as `baseSeqs`). */ + shellBaseSeq?: number +} + +export interface SaveSiteResult { + /** + * The save's site-global sync seq — stamped on every row the save wrote or + * deleted (and the shell, when it changed). The client bumps its base seqs + * to this value on success. + */ + seq: number +} + +/** The loaded document plus the sync-seq bases the editor tracks alongside it. */ +export interface SiteLoadResult { + site: SiteDocument + /** rowId → stored sync seq at load time, across pages + components + layouts. */ + rowSeqs: Record + /** The shell's sync seq at load time. */ + shellSeq: number } /** @@ -37,12 +65,16 @@ export interface IPersistenceAdapter { * only the changed pages/components/layouts plus explicitly deleted row * ids. Without hints (or `dirty.all`), ships a replace-mode full save — * the server derives deletions as stored − shipped. + * + * Throws `SaveConflictError` (see ./saveConflict) when the server rejects + * an incremental save because another admin stored newer versions of rows + * this save ships. Nothing is written on conflict. */ - saveSite(site: SiteDocument, opts?: SaveSiteOptions): Promise + saveSite(site: SiteDocument, opts?: SaveSiteOptions): Promise /** - * Load the single site draft document (shell + pages assembled). - * Returns undefined before setup creates it. + * Load the single site draft document (shell + pages assembled) together + * with its sync-seq bases. Returns undefined before setup creates it. */ - loadSite(id: string): Promise + loadSite(id: string): Promise } From 7ed68caaf5973daa7117e2d9eba18cd24cd3a7ab Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 00:22:33 +0200 Subject: [PATCH 02/49] =?UTF-8?q?feat(cms):=20live=20pull=20over=20WebSock?= =?UTF-8?q?et=20=E2=80=94=20sibling=20saves=20merge=20into=20open=20editor?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Level B of the multi-admin live-sync plan, on top of the conflict-safety substrate: two admins in the editor now SEE each other's saves land, instead of silently diverging until a 409. Server: - server/events/siteEvents.ts — in-process bus over Bun's native pub/sub (correct for the single-process-by-definition deployment; multi-process is documented out of scope). The transactional save publishes post-commit hints: rows-changed / rows-deleted / shell-changed, ONE site-reloaded for replace-mode saves, and the publish flow emits `published`. Events carry ids + seqs, never payloads (@core/persistence/syncEvents). - server/events/siteSocket.ts — GET /admin/api/cms/site-socket upgrades at the Bun.serve boundary (the router stays request/response). Gated by `site.read` AND originAllowed (browsers always send Origin on WS handshakes — closes cross-origin WebSocket hijacking). On `{ kind: 'subscribe', cursor }` the server replies with the missed delta synthesized from `rows where seq > cursor` (soft-deleted included), so live events are hints and the delta is truth — dropped frames can never cause drift. - Vite dev proxy forwards the upgrade (ws: true). Client: - useSiteSocket (AdminCanvasLayout) lazy-imports the transport (siteSocketClient.ts, backoff reconnect, strict in-order processing) so the sync machinery stays out of the route-shell chunk. - siteSyncMerge.ts — the merge policy: base seq ≥ event seq → skip (own echo); dirty target → pending conflict (the SAME banner as a 409, one resolution UX for levels A+B; dirtiness re-checked after the fetch); clean → fetch + applyRemoteSnapshot. Aligned deletions (both sides deleted the row) merge silently. Undo history resets ONLY when remote content genuinely replaced local state — a toast explains exactly then; a remote deletion of the open page navigates home with a toast. - applyRemoteSnapshot (generalized from the banner's Load-theirs) now deep-equal-skips identical content, so own-save echoes never clear history; DirtyMarks gains a `shell` flag so remote shell changes apply silently when the local shell is untouched. - Fixed a latent phase A hole: the editor bumps site.updatedAt on EVERY mutation, which would have made every save look like a shell change. shellsEqual (now shared in @core/persistence/shellsEqual) and the dirty tracker deliberately ignore updatedAt — the shell seq stays an honest conflict signal. Tests: WS integration (real Bun.serve round-trip: gating, delta, live fan-out), save-emission shapes, delta computation, and 12 merge-policy scenarios via an injected snapshot fetcher. Co-Authored-By: Claude Fable 5 --- docs/architecture.md | 1 + docs/features/site-shell.md | 50 ++- docs/server.md | 8 +- server/events/siteEvents.ts | 37 ++ server/events/siteSocket.ts | 128 +++++++ server/handlers/cms/siteDiff.ts | 41 +- server/handlers/cms/siteDocument.ts | 34 +- server/index.ts | 23 +- server/publish/publishSite.ts | 6 + server/repositories/data/index.ts | 1 + server/repositories/data/rows/index.ts | 1 + server/repositories/data/rows/read.ts | 34 ++ .../editor-store/dirtyTracking.test.ts | 15 +- .../editor-store/saveConflicts.test.ts | 12 +- .../editor-store/siteSyncMerge.test.ts | 260 +++++++++++++ src/__tests__/server/siteDocumentSave.test.ts | 8 + src/__tests__/server/siteSocket.test.ts | 356 ++++++++++++++++++ .../AdminCanvasLayout/AdminCanvasLayout.tsx | 6 + src/admin/pages/site/hooks/remoteSnapshot.ts | 55 +++ .../pages/site/hooks/siteSocketClient.ts | 102 +++++ src/admin/pages/site/hooks/siteSyncMerge.ts | 197 ++++++++++ src/admin/pages/site/hooks/useSiteSocket.ts | 35 ++ .../site/store/slices/saveTrackingSlice.ts | 39 ++ .../site/store/slices/site/conflictActions.ts | 119 ++++-- .../site/store/slices/site/dirtyTracking.ts | 24 +- .../store/slices/site/lifecycleActions.ts | 3 + .../pages/site/store/slices/site/types.ts | 31 +- .../SaveConflictBanner/SaveConflictBanner.tsx | 33 +- src/core/persistence/shellsEqual.ts | 18 + src/core/persistence/syncEvents.ts | 93 +++++ src/core/utils/deepEqual.ts | 37 ++ vite.config.ts | 7 +- 32 files changed, 1691 insertions(+), 123 deletions(-) create mode 100644 server/events/siteEvents.ts create mode 100644 server/events/siteSocket.ts create mode 100644 src/__tests__/editor-store/siteSyncMerge.test.ts create mode 100644 src/__tests__/server/siteSocket.test.ts create mode 100644 src/admin/pages/site/hooks/remoteSnapshot.ts create mode 100644 src/admin/pages/site/hooks/siteSocketClient.ts create mode 100644 src/admin/pages/site/hooks/siteSyncMerge.ts create mode 100644 src/admin/pages/site/hooks/useSiteSocket.ts create mode 100644 src/core/persistence/shellsEqual.ts create mode 100644 src/core/persistence/syncEvents.ts create mode 100644 src/core/utils/deepEqual.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3c4b67c7a..fc65e2de1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,6 +89,7 @@ The repo is organized by responsibility, not by feature. Every file has one reas | HTTP & routing | `server/router.ts`, `server/http.ts` | Request dispatch, body parsing, error envelopes | | CMS endpoints | `server/handlers/cms/*.ts` | Per-resource handlers (pages, posts, components, media, plugins, …) | | Auth & sessions | `server/auth/*` | Session validation, capability checks, login flow | +| Live-sync events | `server/events/*` | Multi-admin sync: in-process event bus (Bun pub/sub) + the `/admin/api/cms/site-socket` WebSocket (upgrade gating, seq-cursor reconnect delta) | | Repositories | `server/repositories/*.ts` | Database access; dialect-naive ANSI SQL only | | Database adapters | `server/db/postgres.ts`, `sqlite.ts` | Engine-specific `DbClient` implementation | | Migrations | `server/db/migrations-*.ts` | Schema in both dialects, parity-gated | diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 7caa451d2..291263b25 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -477,8 +477,54 @@ deliberately). Known v1 blind spot: writers OUTSIDE the transactional save (plugin pack installs via `saveDraftSite`/`saveDataRowDraft`, data-workspace row edits) do not stamp seqs, so conflict detection cannot see them — those flows -coordinate through `requestCmsSiteReload()` instead. Widening seq stamping -to every writer is live-sync phase B territory. +coordinate through `requestCmsSiteReload()` instead. + +One subtlety: the editor bumps `site.updatedAt` on EVERY historic mutation, +so `shellsEqual` (`@core/persistence/shellsEqual`, shared by the server's +shell-skip and the client's echo detection) deliberately ignores it, and the +dirty tracker never marks the shell for `updatedAt`-only patches — otherwise +every page edit would read as a shell change and destroy the shell seq as a +conflict signal. + +### Live pull (multi-admin level B) + +Open editors learn about sibling admins' saves the moment they land — no +polling, no manual refresh: + +- **Channel**: `GET /admin/api/cms/site-socket` upgrades to a WebSocket + (`server/events/siteSocket.ts`; wired at the `Bun.serve` boundary in + `server/index.ts` — an upgrade is a different protocol lifecycle from the + request/response router). Gated at upgrade time by `site.read` AND an + `originAllowed` check (browsers always send Origin on WS handshakes, so + this closes cross-site WebSocket hijacking). The dev Vite proxy forwards + the upgrade (`ws: true`). +- **Events** (`@core/persistence/syncEvents`): `rows-changed` / + `rows-deleted` / `shell-changed` / `site-reloaded` (replace-mode saves + collapse to ONE event) / `published`. Events carry **ids + seqs, never + payloads** — they are idempotent hints. The transactional save publishes + them post-commit through the in-process bus (`server/events/siteEvents.ts`, + Bun's native pub/sub — correct for the single-process-by-definition + deployment model; multi-process would need a shared bus and is out of + scope). +- **Self-healing reconnect**: on every (re)connect the client sends its seq + cursor (`{ kind: 'subscribe', cursor }`) and the server replies with the + missed delta synthesized from `rows where seq > cursor` (soft-deleted rows + included — a missed deletion surfaces as `rows-deleted`, not silence). + Live events are hints; the delta is truth, so a dropped frame can never + cause drift. +- **Client** (`useSiteSocket` = transport with backoff reconnect, + `siteSyncMerge.ts` = policy): per event target — ① base seq ≥ event seq → + skip (own-save echo); ② target dirty locally → pending conflict (the SAME + banner as a 409'd save — one resolution UX for levels A and B; dirtiness + is re-checked after the fetch, so an edit landing mid-wire degrades to a + conflict, never a silent overwrite); ③ clean → fetch current state + (`fetchRemoteSnapshot`) and `applyRemoteSnapshot` it. The apply + deep-equal-skips identical content, so undo history resets only when + remote content genuinely replaced local state — a toast explains exactly + then. A remote deletion of the open page navigates home with a toast; + `site-reloaded` reloads the document through the ordinary persistence + reload path. Events process strictly in arrival order through one promise + chain. Client side: `loadSite` seeds `baseSeqs`/`shellBaseSeq` in the editor store (per-row seqs ride the collection GET responses — `DataRow.seq`; the shell diff --git a/docs/server.md b/docs/server.md index 56cf33413..0b43a6c7d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -38,7 +38,13 @@ server/index.ts ├─→ mediaStorageRegistry.configureLocalDisk({ uploadsDir }) ← register local-disk media adapter ├─→ activateInstalledServerPlugins(db, uploadsDir) ← run plugin lifecycle: activate │ - └─→ Bun.serve({ fetch: req => handleServerRequest(req, runtime) }) + ├─→ Bun.serve({ fetch: req => handleServerRequest(req, runtime), + │ websocket: createSiteSocketHandlers(db) }) + │ (the live-sync socket `/admin/api/cms/site-socket` upgrades at this + │ boundary — see server/events/siteSocket.ts; everything else goes + │ through the router) + │ + └─→ setSiteEventPublisher(server) ← wire the live-sync bus to Bun pub/sub ``` Boot is sequential and fail-fast. If migrations fail, the process exits. If a plugin's `activate` throws, the host logs `[plugin:]` and continues — one bad plugin doesn't bring the server down. diff --git a/server/events/siteEvents.ts b/server/events/siteEvents.ts new file mode 100644 index 000000000..ba7bd7094 --- /dev/null +++ b/server/events/siteEvents.ts @@ -0,0 +1,37 @@ +/** + * Site events bus — the in-process fan-out behind the multi-admin live-pull + * channel (level B of the live-sync plan). + * + * Instatic is self-hosted and runs as ONE Bun process by product definition, + * so an in-memory bus wrapping Bun's native pub/sub is *correct*, not a + * shortcut — multi-process deployments would need a shared bus and are + * explicitly out of scope (documented in docs/features/site-shell.md). + * + * The publisher is the `Bun.Server` instance, registered at boot + * (server/index.ts) AFTER `Bun.serve` returns. Until then — and in tests + * that exercise handlers without a listening server — `publishSiteEvent` + * drops events silently, which is safe by design: events are idempotent + * HINTS; the seq-cursor delta on (re)connect is the truth + * (see @core/persistence/syncEvents). + */ +import type { SiteSyncEvent } from '@core/persistence/syncEvents' + +/** The one durable topic every open editor subscribes to. */ +export const SITE_EVENTS_TOPIC = 'site' + +interface SiteEventPublisher { + publish(topic: string, data: string): number +} + +let publisher: SiteEventPublisher | null = null + +/** Register the boot-time publisher (the Bun server). Pass null to detach (tests). */ +export function setSiteEventPublisher(next: SiteEventPublisher | null): void { + publisher = next +} + +/** Broadcast one sync event to every subscribed editor socket. */ +export function publishSiteEvent(event: SiteSyncEvent): void { + if (!publisher) return + publisher.publish(SITE_EVENTS_TOPIC, JSON.stringify(event)) +} diff --git a/server/events/siteSocket.ts b/server/events/siteSocket.ts new file mode 100644 index 000000000..938b27921 --- /dev/null +++ b/server/events/siteSocket.ts @@ -0,0 +1,128 @@ +/** + * Site socket — the WebSocket endpoint of the multi-admin live-pull channel. + * + * GET /admin/api/cms/site-socket → WebSocket upgrade + * + * Auth happens at upgrade time: the session cookie must resolve to a user + * with `site.read`, and the Origin header must pass `originAllowed` — the + * browser always sends Origin on WebSocket handshakes, so this closes + * cross-origin WebSocket hijacking (CSWSH): cookies ride the handshake, but + * a foreign origin is rejected before the socket opens. + * + * Protocol (shapes in @core/persistence/syncEvents): + * 1. On open the socket subscribes to the `site` topic — every + * post-commit save event published through server/events/siteEvents + * fans out to it via Bun's native pub/sub (subscriptions die with the + * socket; no cleanup bookkeeping to leak). + * 2. The client sends `{ kind: 'subscribe', cursor }` with the highest + * seq it has synchronized. The server replies with DELTA events + * synthesized from `rows where seq > cursor` (soft-deleted included — + * a missed deletion surfaces as `rows-deleted`, not silence) plus a + * `shell-changed` when the shell seq is past the cursor. This makes + * the socket self-healing: live events are hints; the delta is truth. + * 3. Malformed or non-string frames are logged and dropped — never acted + * on. + */ +import type { ServerWebSocket, WebSocketHandler } from 'bun' +import type { SiteSyncEvent, SiteSyncTable } from '@core/persistence/syncEvents' +import { SITE_SOCKET_PATH, SiteSocketSubscribeSchema } from '@core/persistence/syncEvents' +import { safeParseJson } from '@core/utils/jsonValidate' +import { requireCapability } from '../auth/authz' +import { originAllowed } from '../auth/security' +import type { DbClient } from '../db/client' +import { jsonResponse } from '../http' +import { listChangedDataRowRefsSince } from '../repositories/data' +import { getDraftSiteSeq } from '../repositories/site' +import { SITE_EVENTS_TOPIC } from './siteEvents' + +export { SITE_SOCKET_PATH } + +export interface SiteSocketData { + userId: string +} + +const SITE_SYNC_TABLES: readonly SiteSyncTable[] = ['pages', 'components', 'layouts'] + +interface UpgradeCapableServer { + upgrade(req: Request, options: { data: SiteSocketData }): boolean +} + +/** + * Gate + upgrade the socket request. Returns `null` when the connection was + * upgraded (the caller must then return `undefined` from `fetch`), or an + * error `Response` (401/403/426) to send instead. + */ +export async function handleSiteSocketUpgrade( + req: Request, + db: DbClient, + server: UpgradeCapableServer, +): Promise { + // CSWSH defense — a cookie-bearing cross-origin handshake is rejected + // before auth even runs. + if (!originAllowed(req)) { + return jsonResponse({ error: 'Origin not allowed' }, { status: 403 }) + } + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + const upgraded = server.upgrade(req, { data: { userId: user.id } }) + if (!upgraded) { + return jsonResponse({ error: 'WebSocket upgrade required' }, { status: 426 }) + } + return null +} + +/** + * Reconnect delta: every sync event the client with this cursor has missed, + * in seq order, ready to run through the client's ordinary merge rule. + * Delta events carry no actor — the originating saves are no longer known. + */ +export async function computeDeltaEvents(db: DbClient, cursor: number): Promise { + const refs = await listChangedDataRowRefsSince(db, SITE_SYNC_TABLES, cursor) + + const changed = new Map>() + const deleted = new Map>() + for (const ref of refs) { + const table = ref.tableId as SiteSyncTable // query is scoped to SITE_SYNC_TABLES + const bucket = ref.deleted ? deleted : changed + const seqs = bucket.get(table) ?? {} + seqs[ref.id] = ref.seq + bucket.set(table, seqs) + } + + const events: SiteSyncEvent[] = [] + for (const [table, seqs] of changed) events.push({ kind: 'rows-changed', table, seqs }) + for (const [table, seqs] of deleted) events.push({ kind: 'rows-deleted', table, seqs }) + + const shellSeq = await getDraftSiteSeq(db) + if (shellSeq > cursor) events.push({ kind: 'shell-changed', seq: shellSeq }) + + return events +} + +/** The `websocket` config for `Bun.serve` — one instance per boot, bound to the db. */ +export function createSiteSocketHandlers(db: DbClient): WebSocketHandler { + return { + open(ws: ServerWebSocket) { + ws.subscribe(SITE_EVENTS_TOPIC) + }, + + async message(ws: ServerWebSocket, raw: string | Buffer) { + if (typeof raw !== 'string') return // binary frames are not part of the protocol + const parsed = safeParseJson(raw, SiteSocketSubscribeSchema) + if (!parsed.ok) { + console.warn('[siteSocket] dropping malformed client message') + return + } + try { + const events = await computeDeltaEvents(db, parsed.value.cursor) + for (const event of events) ws.send(JSON.stringify(event)) + } catch (err) { + console.error('[siteSocket] delta computation failed:', err) + } + }, + + close() { + // Bun drops the socket's topic subscriptions automatically. + }, + } +} diff --git a/server/handlers/cms/siteDiff.ts b/server/handlers/cms/siteDiff.ts index 7528b8014..b298ac2bd 100644 --- a/server/handlers/cms/siteDiff.ts +++ b/server/handlers/cms/siteDiff.ts @@ -33,6 +33,7 @@ import type { StyleRule, SiteShell, } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' type SiteChangeKind = 'structure' | 'content' | 'style' @@ -237,44 +238,4 @@ function diffFiles( } } -/** - * True when two shells are content-identical. The transactional save uses - * this to SKIP the shell write + seq stamp on row-only saves — the pillar of - * the shell conflict check: the shell seq advances only when shell content - * genuinely changed, so a stale-but-untouched shell shipped alongside a page - * edit never trips a spurious conflict. - */ -export function shellsEqual(a: SiteShell, b: SiteShell): boolean { - return deepEqual(a, b) -} - -// --------------------------------------------------------------------------- -// Small deep-equal helpers -// --------------------------------------------------------------------------- -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null) return a === b - if (typeof a !== typeof b) return false - if (typeof a !== 'object') return false - if (Array.isArray(a)) { - if (!Array.isArray(b)) return false - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false - } - return true - } - if (Array.isArray(b)) return false - const aKeys = Object.keys(a as Record) - const bKeys = Object.keys(b as Record) - if (aKeys.length !== bKeys.length) return false - for (const k of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, k)) return false - if (!deepEqual( - (a as Record)[k], - (b as Record)[k], - )) return false - } - return true -} diff --git a/server/handlers/cms/siteDocument.ts b/server/handlers/cms/siteDocument.ts index 31c557a60..c7836f702 100644 --- a/server/handlers/cms/siteDocument.ts +++ b/server/handlers/cms/siteDocument.ts @@ -77,11 +77,14 @@ import { VisualComponentSchema, vcSlugFromName, type VisualComponent } from '@co import { SavedLayoutSchema, layoutSlugFromName, type SavedLayout } from '@core/layouts' import type { Page } from '@core/page-tree' import { SaveConflictError, type SaveConflict } from '@core/persistence/saveConflict' +import { shellsEqual } from '@core/persistence/shellsEqual' +import type { SiteSyncActor, SiteSyncTable } from '@core/persistence/syncEvents' +import { publishSiteEvent } from '../../events/siteEvents' import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' import { bumpPublishVersionSerialized } from '../../publish/publishState' import { Type, type Static } from '@core/utils/typeboxHelpers' import { CMS_API_PREFIX } from './shared' -import { ForbiddenSiteChangeError, shellsEqual, validateSiteWriteDiff } from './siteDiff' +import { ForbiddenSiteChangeError, validateSiteWriteDiff } from './siteDiff' import { validatePageWriteDiff } from './pageDiff' const SITE_WRITE_CAPABILITIES = [ @@ -414,10 +417,35 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // Deleting a published page retracts its public route — invalidate the // render cache AFTER the transaction commits (never inside it: the bump // serializes against the publish lock, which itself waits on the - // transaction chain). The multi-admin live-sync plan emits its site - // events from this point too. + // transaction chain). if (deletedPublishedPage) await bumpPublishVersionSerialized() + // Live-sync fan-out — idempotent hints to every open editor socket + // (ids + seqs only, never payloads; see @core/persistence/syncEvents). + // A replace-mode save collapses to ONE site-reloaded event instead of + // thousands of row events. + const actor: SiteSyncActor = { userId: user.id, name: user.displayName || user.email } + if (body.mode === 'replace') { + publishSiteEvent({ kind: 'site-reloaded', seq, actor }) + } else { + if (shellChanged) publishSiteEvent({ kind: 'shell-changed', seq, actor }) + const rowGroups: Array<{ table: SiteSyncTable; changedIds: Iterable; deleteIds: Iterable }> = [ + { table: 'pages', changedIds: changedPageIdsRaw, deleteIds: pageDeleteIds }, + { table: 'components', changedIds: changedComponentIds, deleteIds: componentDeleteIds }, + { table: 'layouts', changedIds: changedLayoutIds, deleteIds: layoutDeleteIds }, + ] + for (const { table, changedIds, deleteIds } of rowGroups) { + const changedSeqs = Object.fromEntries([...changedIds].map((id) => [id, seq])) + if (Object.keys(changedSeqs).length > 0) { + publishSiteEvent({ kind: 'rows-changed', table, seqs: changedSeqs, actor }) + } + const deletedSeqs = Object.fromEntries([...deleteIds].map((id) => [id, seq])) + if (Object.keys(deletedSeqs).length > 0) { + publishSiteEvent({ kind: 'rows-deleted', table, seqs: deletedSeqs, actor }) + } + } + } + return jsonResponse({ ok: true, seq }) } catch (err) { if (err instanceof SiteValidationError) return badRequest(err.message) diff --git a/server/index.ts b/server/index.ts index e4f3675b0..85def8fa5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,9 @@ await import('./richtextSanitizer') const { handleServerRequest } = await import('./router') const { activateInstalledServerPlugins } = await import('./plugins/runtime') const { mediaStorageRegistry } = await import('@core/plugins/mediaStorageRegistry') +const { setSiteEventPublisher } = await import('./events/siteEvents') +const { SITE_SOCKET_PATH, createSiteSocketHandlers, handleSiteSocketUpgrade } = + await import('./events/siteSocket') const config = readServerConfig() configureTrustedProxyCidrs(config.trustedProxyCidrs) @@ -58,7 +61,7 @@ function corsHeaders(origin: string | null): Record { } } -Bun.serve({ +const server = Bun.serve({ port: config.port, // Disable Bun's default 10-second idle timeout. The agent endpoint streams @@ -88,6 +91,17 @@ Bun.serve({ ) } + // Multi-admin live-sync socket — a WebSocket upgrade is a different + // protocol lifecycle from the request/response router, so it dispatches + // here at the `Bun.serve` boundary (the only place `server.upgrade` is + // available). Returning `undefined` hands the connection to the + // `websocket` handlers below. + if (pathname === SITE_SOCKET_PATH) { + const rejection = await handleSiteSocketUpgrade(req, db, server) + if (rejection === null) return undefined + return applySecurityHeaders(rejection, pathname) + } + try { const res = await handleServerRequest(req, { db, @@ -116,10 +130,17 @@ Bun.serve({ } }, + websocket: createSiteSocketHandlers(db), + error(err: Error) { console.error('[server] Unhandled error:', err) return new Response('Internal Server Error', { status: 500 }) }, }) +// The bus needs the live server handle for Bun pub/sub fan-out — register it +// now that `Bun.serve` returned. Save events emitted before this line (none +// in practice) would drop harmlessly: they are hints, the delta is truth. +setSiteEventPublisher(server) + console.log(`[server] Listening on http://localhost:${config.port}`) diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 925249bc9..6ecdb7274 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -50,6 +50,7 @@ import { import { buildPublishedSiteCssBundle } from './siteCssBundle' import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { publishSiteEvent } from '../events/siteEvents' interface PublishResult { publishedPages: number @@ -302,5 +303,10 @@ async function publishDraftSiteLocked( // live while the version counter still reads the old value. bumpPublishVersion() + // Live-sync hint: open editors learn the site was published without + // polling. No actor — the publish lock context only carries the user id, + // and the event is informational. + publishSiteEvent({ kind: 'published', publishVersion: getPublishVersion() }) + return { publishedPages } } diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 67ce92bca..6a45583ec 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -32,6 +32,7 @@ export { listDataRows, listDataRowIdSlugs, listDataRowSeqs, + listChangedDataRowRefsSince, listDataRowsWithFilter, searchDataRows, getDataRow, diff --git a/server/repositories/data/rows/index.ts b/server/repositories/data/rows/index.ts index c663fa60c..d40b93129 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -20,6 +20,7 @@ export { listDataRows, listDataRowIdSlugs, listDataRowSeqs, + listChangedDataRowRefsSince, getDataRow, getDataRowMany, getDataRowBySlug, diff --git a/server/repositories/data/rows/read.ts b/server/repositories/data/rows/read.ts index a377de872..d0a9cb407 100644 --- a/server/repositories/data/rows/read.ts +++ b/server/repositories/data/rows/read.ts @@ -117,6 +117,40 @@ export async function listDataRowSeqs( return rows.map((r) => ({ id: r.id, seq: Number(r.seq) })) } +export interface ChangedDataRowRef { + id: string + tableId: string + seq: number + deleted: boolean +} + +/** + * Lean refs of every row in the given tables whose seq is PAST the client's + * cursor — the reconnect delta of the live-sync channel. Soft-deleted rows + * included (a deletion the client missed must surface as a `rows-deleted` + * hint, not as silence). O(delta) via `data_rows_table_seq_idx`. + */ +export async function listChangedDataRowRefsSince( + db: DbClient, + tableIds: ReadonlyArray, + cursor: number, +): Promise { + if (tableIds.length === 0) return [] + const placeholders = tableIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') + const { rows } = await db.unsafe<{ id: string; table_id: string; seq: number; deleted_at: string | null }>( + `select id, table_id, seq, deleted_at from data_rows + where seq > ${placeholder(db.dialect, 1)} and table_id in (${placeholders}) + order by seq asc`, + [cursor, ...tableIds], + ) + return rows.map((row) => ({ + id: row.id, + tableId: row.table_id, + seq: Number(row.seq), + deleted: row.deleted_at !== null, + })) +} + export async function getDataRow( db: DbClient, rowId: string, diff --git a/src/__tests__/editor-store/dirtyTracking.test.ts b/src/__tests__/editor-store/dirtyTracking.test.ts index 0cd43b174..b8f69e0fa 100644 --- a/src/__tests__/editor-store/dirtyTracking.test.ts +++ b/src/__tests__/editor-store/dirtyTracking.test.ts @@ -124,15 +124,20 @@ describe('collectDirtyFromSitePatches', () => { expect(marks.all).toBe(true) }) - it('marks nothing for shell-field paths — the shell is always saved', () => { + it('marks the SHELL (no row marks) for shell-field paths — feeds the live-sync merge rule', () => { const site = twoPageTwoVcSite() const patches: Patches = [ { op: 'replace', path: ['styleRules', 'x'], value: {} }, { op: 'replace', path: ['name'], value: 'Renamed' }, - { op: 'replace', path: ['updatedAt'], value: 123 }, ] const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks).toEqual(emptyDirtyMarks()) + expect(marks).toEqual({ ...emptyDirtyMarks(), shell: true }) + }) + + it('does NOT mark the shell for updatedAt — bumped by every mutation, bookkeeping not content', () => { + const site = twoPageTwoVcSite() + const patches: Patches = [{ op: 'replace', path: ['updatedAt'], value: 123 }] + expect(collectDirtyFromSitePatches(patches, site, site)).toEqual(emptyDirtyMarks()) }) it('attributes an element add at [pages, i] to the added page', () => { @@ -460,12 +465,12 @@ describe('editor store dirty-save tracking', () => { expect([...snapshot.deletedPageIds]).toEqual(['page-b']) }) - it('shell-only mutations (site rename) accumulate no marks', () => { + it('shell-only mutations (site rename) accumulate the shell mark and NO row marks', () => { loadTwoPageSite() useEditorStore.getState().updateSiteName('Renamed Site') expect(useEditorStore.getState().site!.name).toBe('Renamed Site') expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) - expect(dirty()).toEqual(emptyDirtyMarks()) + expect(dirty()).toEqual({ ...emptyDirtyMarks(), shell: true }) }) }) diff --git a/src/__tests__/editor-store/saveConflicts.test.ts b/src/__tests__/editor-store/saveConflicts.test.ts index f9cd1ea48..2bf859e40 100644 --- a/src/__tests__/editor-store/saveConflicts.test.ts +++ b/src/__tests__/editor-store/saveConflicts.test.ts @@ -7,7 +7,7 @@ * rows KEEP their entries so a resurrect-by-undo carries the right base), * - resolveSaveConflictKeepMine — bumps the target's base seq so the next * save overwrites as a stated decision; dirty marks stay untouched, - * - resolveSaveConflictTheirs — swaps the remote version in (or removes a + * - applyRemoteSnapshot — swaps the remote version in (or removes a * remotely-deleted target), clears the target's dirty marks, syncs the * base seq, clears the undo history, and — for Visual Components — * propagates to consumer pages with REAL dirty marks (slot re-sync / @@ -118,7 +118,7 @@ describe('resolveSaveConflictKeepMine', () => { // Load theirs // --------------------------------------------------------------------------- -describe('resolveSaveConflictTheirs', () => { +describe('applyRemoteSnapshot', () => { it('swaps a remote page in, clears its dirty marks, syncs the base seq, clears history', () => { loadFixtureSite({ pages: [makePage({ id: 'page-a', title: 'Mine' })] }) useEditorStore.getState().seedBaseSeqs({ 'page-a': 1 }, 1) @@ -128,7 +128,7 @@ describe('resolveSaveConflictTheirs', () => { expect(useEditorStore.getState()._dirtySave.pageIds.has('page-a')).toBe(true) useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-a', seq: 6 }]) - useEditorStore.getState().resolveSaveConflictTheirs({ + useEditorStore.getState().applyRemoteSnapshot({ table: 'pages', rowId: 'page-a', row: makePage({ id: 'page-a', title: 'Theirs' }), @@ -155,7 +155,7 @@ describe('resolveSaveConflictTheirs', () => { useEditorStore.getState().seedBaseSeqs({ 'page-home': 1, 'page-b': 1 }, 1) useEditorStore.getState().setActivePage('page-b') - useEditorStore.getState().resolveSaveConflictTheirs({ + useEditorStore.getState().applyRemoteSnapshot({ table: 'pages', rowId: 'page-b', row: null, @@ -176,7 +176,7 @@ describe('resolveSaveConflictTheirs', () => { return shell })() - useEditorStore.getState().resolveSaveConflictTheirs({ + useEditorStore.getState().applyRemoteSnapshot({ table: 'site', shell: remoteShell, seq: 12, @@ -205,7 +205,7 @@ describe('resolveSaveConflictTheirs', () => { useEditorStore.getState().seedBaseSeqs({ 'page-a': 1, 'vc-1': 1 }, 1) useEditorStore.getState().setSaveConflicts([{ table: 'components', rowId: 'vc-1', seq: 4 }]) - useEditorStore.getState().resolveSaveConflictTheirs({ + useEditorStore.getState().applyRemoteSnapshot({ table: 'components', rowId: 'vc-1', row: null, diff --git a/src/__tests__/editor-store/siteSyncMerge.test.ts b/src/__tests__/editor-store/siteSyncMerge.test.ts new file mode 100644 index 000000000..6547eaa22 --- /dev/null +++ b/src/__tests__/editor-store/siteSyncMerge.test.ts @@ -0,0 +1,260 @@ +/** + * Live-pull merge policy (multi-admin level B) — processSiteSyncEvent. + * + * The rule, per event target: + * 1. base seq ≥ event seq → skipped entirely (own-save echo, no fetch), + * 2. dirty locally → pending conflict, content untouched, + * 3. clean → fetch + applyRemoteSnapshot; identical content (echo that + * outran the save response) is bookkeeping-only and PRESERVES history; + * genuinely newer content swaps in and clears history, + * 4. dirtiness is re-checked after the fetch — an edit landing mid-wire + * degrades to a conflict, never a silent overwrite. + * + * Plus: the shell path keys off the dedicated `shell` dirty mark, deletions + * remove rows, and the cursor advances with every processed event. The + * snapshot fetch is injected (`SiteSyncMergeDeps`) so no HTTP or module + * mocking is involved. + */ +import { describe, it, expect, beforeEach } from 'bun:test' +import type { Page } from '@core/page-tree' +import { useEditorStore } from '@site/store/store' +import { processSiteSyncEvent, type SiteSyncMergeDeps } from '@site/hooks/siteSyncMerge' +import type { RemoteSnapshot } from '@site/store/slices/site/types' +import { makePage, makeSite } from '../fixtures' + +function depsReturning(snapshot: RemoteSnapshot): SiteSyncMergeDeps & { calls: number } { + const deps = { + calls: 0, + fetchSnapshot: async () => { + deps.calls += 1 + return snapshot + }, + } + return deps +} + +function depsThatMustNotFetch(): SiteSyncMergeDeps { + return { + fetchSnapshot: () => { + throw new Error('fetchSnapshot must not be called for this event') + }, + } +} + +function loadTwoPageSite(): void { + useEditorStore.getState().loadSite( + makeSite({ + pages: [ + makePage({ id: 'page-a', slug: 'index', title: 'Home' }), + makePage({ id: 'page-b', slug: 'about', title: 'About' }), + ], + }), + ) + useEditorStore.getState().seedBaseSeqs({ 'page-a': 5, 'page-b': 5 }, 5) +} + +function remotePage(title: string): Page { + return makePage({ id: 'page-a', slug: 'index', title }) +} + +beforeEach(() => { + useEditorStore.getState().clearSite() +}) + +describe('processSiteSyncEvent — rows-changed', () => { + it('applies a newer remote row to a clean target and advances the cursor', async () => { + loadTwoPageSite() + const deps = depsReturning({ + table: 'pages', + rowId: 'page-a', + row: remotePage('Home (remote v2)'), + seq: 9, + }) + + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, + deps, + ) + + const state = useEditorStore.getState() + expect(deps.calls).toBe(1) + expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (remote v2)') + expect(state.baseSeqs['page-a']).toBe(9) + expect(state.syncCursor).toBe(9) + expect(state.saveConflicts).toEqual([]) + }) + + it('skips events at or below the base seq without fetching (own-save echo, fast path)', async () => { + loadTwoPageSite() + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 5 } }, + depsThatMustNotFetch(), + ) + expect(useEditorStore.getState().site!.pages[0].title).toBe('Home') + }) + + it('degrades to a pending conflict when the target is dirty locally — content untouched, no fetch', async () => { + loadTwoPageSite() + useEditorStore.getState().renamePage('page-a', 'Home (my edit)') + + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, + depsThatMustNotFetch(), + ) + + const state = useEditorStore.getState() + expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: 9 }]) + expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (my edit)') + // The conflict does not stop the cursor — the pending entry carries the info. + expect(state.syncCursor).toBe(9) + }) + + it('re-checks dirtiness AFTER the fetch — an edit landing mid-wire becomes a conflict, not an overwrite', async () => { + loadTwoPageSite() + const deps: SiteSyncMergeDeps = { + fetchSnapshot: async () => { + // The user edits while the fetch is on the wire. + useEditorStore.getState().renamePage('page-a', 'Home (raced edit)') + return { table: 'pages', rowId: 'page-a', row: remotePage('Home (remote)'), seq: 9 } + }, + } + + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, + deps, + ) + + const state = useEditorStore.getState() + expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (raced edit)') + expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: 9 }]) + }) + + it('an echo with identical content is bookkeeping-only — undo history survives', async () => { + loadTwoPageSite() + // Real local history on ANOTHER page — must survive the echo. + useEditorStore.getState().renamePage('page-b', 'About (edited)') + expect(useEditorStore.getState().canUndo).toBe(true) + + // The fetched remote page-a equals the local one byte-for-byte. + const local = useEditorStore.getState().site!.pages.find((p) => p.id === 'page-a')! + const deps = depsReturning({ + table: 'pages', + rowId: 'page-a', + row: structuredClone(local) as Page, + seq: 9, + }) + + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, + deps, + ) + + const state = useEditorStore.getState() + expect(state.canUndo).toBe(true) // history preserved + expect(state.baseSeqs['page-a']).toBe(9) // bookkeeping synced + }) +}) + +describe('processSiteSyncEvent — rows-deleted', () => { + it('removes a remotely-deleted clean row without any fetch', async () => { + loadTwoPageSite() + await processSiteSyncEvent( + { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, + depsThatMustNotFetch(), + ) + + const state = useEditorStore.getState() + expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) + expect(state.baseSeqs['page-b']).toBeUndefined() + expect(state.syncCursor).toBe(9) + }) + + it('a remote deletion of a locally-DIRTY row becomes a conflict', async () => { + loadTwoPageSite() + useEditorStore.getState().renamePage('page-b', 'About (my edit)') + + await processSiteSyncEvent( + { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, + depsThatMustNotFetch(), + ) + + const state = useEditorStore.getState() + expect(state.site!.pages).toHaveLength(2) + expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-b', seq: 9 }]) + }) +}) + +describe('processSiteSyncEvent — shell-changed', () => { + it('applies a remote shell when the local shell is untouched', async () => { + loadTwoPageSite() + const { pages: _p, visualComponents: _v, layouts: _l, ...shell } = makeSite({ + name: 'Renamed remotely', + }) + const deps = depsReturning({ table: 'site', shell, seq: 9 }) + + await processSiteSyncEvent({ kind: 'shell-changed', seq: 9 }, deps) + + const state = useEditorStore.getState() + expect(state.site!.name).toBe('Renamed remotely') + expect(state.shellBaseSeq).toBe(9) + expect(state.syncCursor).toBe(9) + }) + + it('a dirty local shell degrades to a conflict — the dedicated shell mark gates it', async () => { + loadTwoPageSite() + // A shell-field mutation sets the shell dirty mark via patch tracking. + useEditorStore.getState().updateSiteName('Renamed locally') + expect(useEditorStore.getState()._dirtySave.shell).toBe(true) + + await processSiteSyncEvent({ kind: 'shell-changed', seq: 9 }, depsThatMustNotFetch()) + + const state = useEditorStore.getState() + expect(state.site!.name).toBe('Renamed locally') + expect(state.saveConflicts).toEqual([{ table: 'site', rowId: 'default', seq: 9 }]) + }) + + it('a page edit does NOT mark the shell dirty — sibling shell changes still apply live', async () => { + loadTwoPageSite() + useEditorStore.getState().renamePage('page-a', 'Home (edited)') + expect(useEditorStore.getState()._dirtySave.shell).toBe(false) + }) +}) + +describe('processSiteSyncEvent — conflict dedupe', () => { + it('repeated events for the same dirty target keep ONE pending conflict at the newest seq', async () => { + loadTwoPageSite() + useEditorStore.getState().renamePage('page-a', 'Home (my edit)') + + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, + depsThatMustNotFetch(), + ) + await processSiteSyncEvent( + { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 12 } }, + depsThatMustNotFetch(), + ) + + expect(useEditorStore.getState().saveConflicts).toEqual([ + { table: 'pages', rowId: 'page-a', seq: 12 }, + ]) + }) +}) + +describe('processSiteSyncEvent — aligned deletions', () => { + it('a remote deletion of a row this editor ALSO deleted merges silently (agreement, not conflict)', async () => { + loadTwoPageSite() + useEditorStore.getState().deletePage('page-b') + expect(useEditorStore.getState()._dirtySave.deletedPageIds.has('page-b')).toBe(true) + + await processSiteSyncEvent( + { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, + depsThatMustNotFetch(), + ) + + const state = useEditorStore.getState() + expect(state.saveConflicts).toEqual([]) + // The now-moot local deletion mark is cleared — nothing left to ship. + expect(state._dirtySave.deletedPageIds.has('page-b')).toBe(false) + expect(state.baseSeqs['page-b']).toBeUndefined() + }) +}) diff --git a/src/__tests__/server/siteDocumentSave.test.ts b/src/__tests__/server/siteDocumentSave.test.ts index 4ede58e3e..75cc27624 100644 --- a/src/__tests__/server/siteDocumentSave.test.ts +++ b/src/__tests__/server/siteDocumentSave.test.ts @@ -948,6 +948,14 @@ describe('site-document save — conflict detection', () => { changedPages: [pagePayload('page-a', 'about')], })) expect(await liveShellSeq(ctx.harness)).toBe(before) + + // The REAL editor bumps `updatedAt` on every mutation — a shell that + // differs only by that bookkeeping timestamp is still "unchanged". + await expectOk(await putDoc(ctx, { + site: { ...ctx.shell, updatedAt: Date.now() + 60_000 }, + changedPages: [pagePayload('page-a', 'about', 'About v2')], + })) + expect(await liveShellSeq(ctx.harness)).toBe(before) } finally { await ctx.harness.cleanup() } diff --git a/src/__tests__/server/siteSocket.test.ts b/src/__tests__/server/siteSocket.test.ts new file mode 100644 index 000000000..b78a1b6e8 --- /dev/null +++ b/src/__tests__/server/siteSocket.test.ts @@ -0,0 +1,356 @@ +/** + * Site socket — the live-pull channel's server half (multi-admin level B). + * + * Covered here: + * - upgrade gating: no session → 401; wrong Origin → 403 (CSWSH defense); + * authenticated non-WS request → 426; authenticated WS handshake → + * upgraded (null), + * - post-commit event emission from the transactional save: rows-changed / + * rows-deleted / shell-changed carry the save's seq and actor; a + * replace-mode save emits ONE site-reloaded; row-only saves emit no + * shell-changed, + * - the reconnect delta (`computeDeltaEvents`): rows past the cursor + * surface as changed/deleted hints (soft-deleted included), the shell + * only when its seq is past the cursor, nothing at the live cursor, + * - a REAL WebSocket round-trip against `Bun.serve`: subscribe → delta, + * then a live save fans out through the bus to the open socket. + * + * Uses the capability harness for auth/session/save plumbing; the publisher + * is injected per test via `setSiteEventPublisher` and detached afterwards + * (the module-global default is null — handlers drop events silently). + */ +import { afterEach, describe, expect, it } from 'bun:test' +import type { SiteShell } from '@core/page-tree' +import type { SiteSyncEvent } from '@core/persistence/syncEvents' +import { publishSiteEvent, setSiteEventPublisher, SITE_EVENTS_TOPIC } from '../../../server/events/siteEvents' +import { + computeDeltaEvents, + createSiteSocketHandlers, + handleSiteSocketUpgrade, + SITE_SOCKET_PATH, + type SiteSocketData, +} from '../../../server/events/siteSocket' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +afterEach(() => { + setSiteEventPublisher(null) +}) + +// --------------------------------------------------------------------------- +// Shared save plumbing (mirrors siteDocumentSave.test.ts, trimmed) +// --------------------------------------------------------------------------- + +function pagePayload(id: string, slug: string, title = slug): Record { + const rootId = `root-${id}` + return { + id, + slug, + title, + rootNodeId: rootId, + nodes: { + [rootId]: { id: rootId, moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [] }, + }, + } +} + +interface Ctx { + harness: CapabilityTestHarness + cookie: string + shell: SiteShell +} + +async function setupHarness(): Promise { + const harness = await createCapabilityTestHarness() + const cookie = await harness.setupOwner() + const shellRes = await harness.cms('/admin/api/cms/site', { method: 'GET', cookie }) + const { site: shell } = await readJson<{ site: SiteShell }>(shellRes) + return { harness, cookie, shell } +} + +async function liveBaseSeqs(harness: CapabilityTestHarness, ids: string[]): Promise> { + const { rows } = await harness.db<{ id: string; seq: number }>`select id, seq from data_rows` + const wanted = new Set(ids) + return Object.fromEntries(rows.filter((r) => wanted.has(r.id)).map((r) => [r.id, Number(r.seq)])) +} + +async function putDoc( + ctx: Ctx, + overrides: { + mode?: 'incremental' | 'replace' + site?: unknown + changedPages?: unknown[] + deletedPageIds?: string[] + } = {}, +): Promise { + const json = { + mode: 'incremental' as const, + site: ctx.shell, + changedPages: [] as unknown[], + deletedPageIds: [] as string[], + changedComponents: [], + deletedComponentIds: [], + changedLayouts: [], + deletedLayoutIds: [], + ...overrides, + } + const ids = [ + ...json.changedPages + .map((p) => (p && typeof p === 'object' ? (p as { id?: unknown }).id : undefined)) + .filter((id): id is string => typeof id === 'string'), + ...json.deletedPageIds, + ] + const res = await ctx.harness.cms('/admin/api/cms/site-document', { + method: 'PUT', + cookie: ctx.cookie, + json: { ...json, baseSeqs: await liveBaseSeqs(ctx.harness, ids), shellBaseSeq: 0 }, + }) + expect(res.status).toBe(200) + const body = await readJson<{ seq: number }>(res) + return body.seq +} + +/** Capture bus output for one test. */ +function captureEvents(): SiteSyncEvent[] { + const events: SiteSyncEvent[] = [] + setSiteEventPublisher({ + publish: (_topic, data) => { + events.push(JSON.parse(data) as SiteSyncEvent) + return 0 + }, + }) + return events +} + +// --------------------------------------------------------------------------- +// Upgrade gating +// --------------------------------------------------------------------------- + +describe('site socket — upgrade gating', () => { + function fakeServer(upgradeResult: boolean): { upgrade: () => boolean; upgraded: () => boolean } { + let upgraded = false + return { + upgrade: () => { + upgraded = true + return upgradeResult + }, + upgraded: () => upgraded, + } + } + + it('rejects an unauthenticated handshake with 401 before upgrading', async () => { + const ctx = await setupHarness() + try { + const server = fakeServer(true) + const res = await handleSiteSocketUpgrade( + new Request(`http://localhost${SITE_SOCKET_PATH}`), + ctx.harness.db, + server, + ) + expect(res?.status).toBe(401) + expect(server.upgraded()).toBe(false) + } finally { + await ctx.harness.cleanup() + } + }) + + /** `cookie`/`origin` are fetch-forbidden constructor headers — set after construction. */ + function socketRequest(headers: Record): Request { + const req = new Request(`http://localhost${SITE_SOCKET_PATH}`) + for (const [name, value] of Object.entries(headers)) req.headers.set(name, value) + return req + } + + it('rejects a cross-origin handshake with 403 (CSWSH defense) even with a valid session', async () => { + const ctx = await setupHarness() + try { + const server = fakeServer(true) + const req = socketRequest({ cookie: ctx.cookie, origin: 'https://evil.example' }) + const res = await handleSiteSocketUpgrade(req, ctx.harness.db, server) + expect(res?.status).toBe(403) + expect(server.upgraded()).toBe(false) + } finally { + await ctx.harness.cleanup() + } + }) + + it('upgrades an authenticated same-origin handshake; a non-WS request gets 426', async () => { + const ctx = await setupHarness() + try { + const req = socketRequest({ cookie: ctx.cookie }) + expect(await handleSiteSocketUpgrade(req, ctx.harness.db, fakeServer(true))).toBeNull() + const rejected = await handleSiteSocketUpgrade( + socketRequest({ cookie: ctx.cookie }), + ctx.harness.db, + fakeServer(false), + ) + expect(rejected?.status).toBe(426) + } finally { + await ctx.harness.cleanup() + } + }) +}) + +// --------------------------------------------------------------------------- +// Save emission +// --------------------------------------------------------------------------- + +describe('site socket — save event emission', () => { + it('an incremental save emits rows-changed/rows-deleted with the save seq and actor, and NO shell event on row-only saves', async () => { + const ctx = await setupHarness() + try { + const events = captureEvents() + const createSeq = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) + const deleteSeq = await putDoc(ctx, { deletedPageIds: ['page-a'] }) + + expect(events).toEqual([ + { + kind: 'rows-changed', + table: 'pages', + seqs: { 'page-a': createSeq }, + actor: { userId: expect.any(String), name: expect.any(String) }, + }, + { + kind: 'rows-deleted', + table: 'pages', + seqs: { 'page-a': deleteSeq }, + actor: { userId: expect.any(String), name: expect.any(String) }, + }, + ]) + } finally { + await ctx.harness.cleanup() + } + }) + + it('a shell change emits shell-changed; a replace-mode save emits ONE site-reloaded', async () => { + const ctx = await setupHarness() + try { + const events = captureEvents() + const shellSeq = await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed' } }) + expect(events).toEqual([ + expect.objectContaining({ kind: 'shell-changed', seq: shellSeq }), + ]) + + events.length = 0 + const replaceSeq = await putDoc(ctx, { + mode: 'replace', + site: { ...ctx.shell, name: 'Imported site' }, + changedPages: [pagePayload('page-b', 'contact')], + }) + expect(events).toEqual([ + expect.objectContaining({ kind: 'site-reloaded', seq: replaceSeq }), + ]) + } finally { + await ctx.harness.cleanup() + } + }) +}) + +// --------------------------------------------------------------------------- +// Reconnect delta +// --------------------------------------------------------------------------- + +describe('site socket — reconnect delta', () => { + it('synthesizes changed + deleted row hints past the cursor, and nothing at the live cursor', async () => { + const ctx = await setupHarness() + try { + const seqA = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) + const seqB = await putDoc(ctx, { changedPages: [pagePayload('page-b', 'contact')] }) + const seqDel = await putDoc(ctx, { deletedPageIds: ['page-a'] }) + + // Cursor 0 → everything: page-a surfaces as DELETED (its latest state), + // page-b as changed. No shell event — row-only saves never stamped it. + const fromZero = await computeDeltaEvents(ctx.harness.db, 0) + expect(fromZero).toEqual([ + { kind: 'rows-changed', table: 'pages', seqs: { 'page-b': seqB } }, + { kind: 'rows-deleted', table: 'pages', seqs: { 'page-a': seqDel } }, + ]) + + // Mid cursor → only what came after. + const fromA = await computeDeltaEvents(ctx.harness.db, seqA) + expect(fromA).toEqual([ + { kind: 'rows-changed', table: 'pages', seqs: { 'page-b': seqB } }, + { kind: 'rows-deleted', table: 'pages', seqs: { 'page-a': seqDel } }, + ]) + + // Live cursor → empty delta. + expect(await computeDeltaEvents(ctx.harness.db, seqDel)).toEqual([]) + } finally { + await ctx.harness.cleanup() + } + }) + + it('includes shell-changed only when the shell seq is past the cursor', async () => { + const ctx = await setupHarness() + try { + const shellSeq = await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed' } }) + const delta = await computeDeltaEvents(ctx.harness.db, 0) + expect(delta).toEqual([{ kind: 'shell-changed', seq: shellSeq }]) + expect(await computeDeltaEvents(ctx.harness.db, shellSeq)).toEqual([]) + } finally { + await ctx.harness.cleanup() + } + }) +}) + +// --------------------------------------------------------------------------- +// Real WebSocket round-trip +// --------------------------------------------------------------------------- + +describe('site socket — WebSocket round-trip', () => { + it('subscribe returns the delta, and a live publish fans out to the open socket', async () => { + const ctx = await setupHarness() + const seqA = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) + + const server = Bun.serve>({ + port: 0, + async fetch(req, srv) { + const rejection = await handleSiteSocketUpgrade(req, ctx.harness.db, srv) + return rejection === null ? undefined : rejection + }, + websocket: createSiteSocketHandlers(ctx.harness.db), + }) + + try { + setSiteEventPublisher(server) + + const received: SiteSyncEvent[] = [] + const gotDelta = Promise.withResolvers() + const gotLive = Promise.withResolvers() + + const ws = new WebSocket(`ws://localhost:${server.port}${SITE_SOCKET_PATH}`, { + headers: { cookie: ctx.cookie }, + }) + ws.onmessage = (msg) => { + received.push(JSON.parse(String(msg.data)) as SiteSyncEvent) + if (received.length === 1) gotDelta.resolve() + if (received.length === 2) gotLive.resolve() + } + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = () => reject(new Error('socket failed to open')) + }) + + ws.send(JSON.stringify({ kind: 'subscribe', cursor: 0 })) + await gotDelta.promise + expect(received[0]).toEqual({ + kind: 'rows-changed', + table: 'pages', + seqs: { 'page-a': seqA }, + }) + + // Live fan-out through the bus (same path the save handler uses). + publishSiteEvent({ kind: 'shell-changed', seq: seqA + 1 }) + await gotLive.promise + expect(received[1]).toEqual({ kind: 'shell-changed', seq: seqA + 1 }) + + ws.close() + } finally { + server.stop(true) + await ctx.harness.cleanup() + } + }) +}) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 3944900f0..6a13af502 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -44,6 +44,7 @@ import { useEditorAppearancePreferences } from '@admin/pages/site/preferences/ed import { usePersistence } from '@admin/pages/site/hooks/usePersistence' import { useSiteEditorUrlSync } from '@admin/pages/site/hooks/useSiteEditorUrlSync' import { useEditorLayoutPersistence } from '@admin/pages/site/hooks/useEditorLayoutPersistence' +import { useSiteSocket } from '@admin/pages/site/hooks/useSiteSocket' import { useEditorStore } from '@admin/pages/site/store/store' import { cmsAdapter } from '@core/persistence/cms' import { useAdminUi } from '@admin/state/adminUi' @@ -181,6 +182,11 @@ export function AdminCanvasLayout() { enabled: true, loaded: persistence.saveStatus.state !== 'loading', }) + // Multi-admin live pull — sibling admins' saves merge into this editor as + // they land (or surface in the conflict banner when they collide with + // local unsaved edits). Gated on the document being loaded: before that + // there is no sync cursor to subscribe with. + useSiteSocket(site !== null) useEditorLayoutPersistence() useInstalledEditorPlugins(pluginBackgroundWorkEnabled) // Mount the SSE bridge ONCE per admin tab — gives toasts on plugin diff --git a/src/admin/pages/site/hooks/remoteSnapshot.ts b/src/admin/pages/site/hooks/remoteSnapshot.ts new file mode 100644 index 000000000..72af396b1 --- /dev/null +++ b/src/admin/pages/site/hooks/remoteSnapshot.ts @@ -0,0 +1,55 @@ +/** + * fetchRemoteSnapshot — pull the CURRENT server state of one sync target and + * convert it to the domain shape `applyRemoteSnapshot` consumes. + * + * Shared by the conflict banner's "Load theirs" and the live-sync socket's + * clean-target pull. Row targets ride the collection GETs' single-row `?id=` + * filter; absence (`row: null`) IS the "deleted remotely" signal. The shell + * rides `GET /site`, which carries its sync seq. + * + * The returned seq prefers the FETCHED row's seq over the triggering + * event/conflict seq — the row may have moved further since. + */ +import { cmsAdapter } from '@core/persistence/cms' +import { pageFromRow } from '@core/data/pageFromRow' +import { visualComponentFromRow } from '@core/data/componentFromRow' +import { savedLayoutFromRow } from '@core/data/layoutFromRow' +import type { RemoteSnapshot } from '@site/store/slices/site/types' + +export interface RemoteSnapshotTarget { + table: 'site' | 'pages' | 'components' | 'layouts' + rowId: string + /** The seq that triggered the fetch — fallback when the target is deleted. */ + seq: number +} + +/** Row snapshot — the variants of {@link RemoteSnapshot} that carry a `row`. */ +export type RemoteRowSnapshot = Exclude + +// Overloads: a row-table target provably yields a row snapshot, so callers +// that never fetch the shell (the socket's row handler) get the narrow type. +export async function fetchRemoteSnapshot( + target: RemoteSnapshotTarget & { table: RemoteRowSnapshot['table'] }, +): Promise +export async function fetchRemoteSnapshot(target: RemoteSnapshotTarget): Promise +export async function fetchRemoteSnapshot(target: RemoteSnapshotTarget): Promise { + if (target.table === 'site') { + const remote = await cmsAdapter.loadSiteShell() + if (!remote) throw new Error('The site shell no longer exists on the server.') + return { table: 'site', shell: remote.shell, seq: remote.seq } + } + const row = await cmsAdapter.loadSiteRow(target.table, target.rowId) + const seq = row?.seq ?? target.seq + if (target.table === 'pages') { + return { table: 'pages', rowId: target.rowId, row: row ? pageFromRow(row) : null, seq } + } + if (target.table === 'components') { + return { + table: 'components', + rowId: target.rowId, + row: row ? visualComponentFromRow(row) : null, + seq, + } + } + return { table: 'layouts', rowId: target.rowId, row: row ? savedLayoutFromRow(row) : null, seq } +} diff --git a/src/admin/pages/site/hooks/siteSocketClient.ts b/src/admin/pages/site/hooks/siteSocketClient.ts new file mode 100644 index 000000000..1dcff6c5e --- /dev/null +++ b/src/admin/pages/site/hooks/siteSocketClient.ts @@ -0,0 +1,102 @@ +/** + * siteSocketClient — the live-pull TRANSPORT of the multi-admin sync channel + * (level B of the live-sync plan). Lazy-imported by `useSiteSocket` so the + * whole sync machinery (event schemas, merge policy, snapshot fetching) + * stays out of the site route's first-paint chunk. + * + * One WebSocket to `/admin/api/cms/site-socket` with exponential-backoff + * reconnect. On every (re)connect it sends the store's seq cursor and the + * server replies with the missed delta as ordinary events — the socket is a + * HINT channel; the delta query is the truth, so dropped events can never + * cause drift (self-healing by design). + * + * Incoming frames are TypeBox-validated (malformed frames are logged and + * dropped) and processed strictly in arrival order through one promise + * chain — a fetch for event N never interleaves with the apply of event + * N+1. What an event DOES to the store is the merge policy in + * `siteSyncMerge.ts`. + */ +import { SITE_SOCKET_PATH, SiteSyncEventSchema } from '@core/persistence/syncEvents' +import { safeParseJson } from '@core/utils/jsonValidate' +import { getErrorMessage } from '@core/utils/errorMessage' +import { useEditorStore } from '@site/store/store' +import { processSiteSyncEvent } from './siteSyncMerge' + +const RECONNECT_BASE_DELAY_MS = 1_000 +const RECONNECT_MAX_DELAY_MS = 30_000 + +/** + * Open (and own) the live-sync socket. Returns the disposer that tears the + * connection down and stops reconnecting. + */ +export function connectSiteSocket(): () => void { + let socket: WebSocket | null = null + let disposed = false + let attempts = 0 + let reconnectTimer: ReturnType | undefined + let chain: Promise = Promise.resolve() + + function scheduleReconnect() { + if (disposed) return + // Exponential backoff with jitter, capped — the delta-on-reconnect + // protocol makes aggressive retry unnecessary. + const delay = + Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** attempts) + + Math.random() * 500 + attempts += 1 + reconnectTimer = setTimeout(connect, delay) + } + + function connect() { + if (disposed) return + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + socket = new WebSocket(`${protocol}://${window.location.host}${SITE_SOCKET_PATH}`) + + socket.onopen = () => { + attempts = 0 + socket?.send( + JSON.stringify({ kind: 'subscribe', cursor: useEditorStore.getState().syncCursor }), + ) + } + + socket.onmessage = (msg: MessageEvent) => { + if (typeof msg.data !== 'string') return // binary frames are not part of the protocol + const parsed = safeParseJson(msg.data, SiteSyncEventSchema) + if (!parsed.ok) { + console.warn('[siteSocketClient] dropping malformed sync event') + return + } + const event = parsed.value + chain = chain.then(() => + processSiteSyncEvent(event).catch((err) => { + console.error( + '[siteSocketClient] failed to apply sync event:', + getErrorMessage(err, 'unknown error'), + err, + ) + }), + ) + } + + // Errors always surface as a close — reconnect is scheduled there. + socket.onerror = () => {} + + socket.onclose = () => { + socket = null + scheduleReconnect() + } + } + + connect() + + return () => { + disposed = true + clearTimeout(reconnectTimer) + if (socket) { + // Detach first — the close handler would otherwise schedule a + // reconnect for a socket we are deliberately tearing down. + socket.onclose = null + socket.close() + } + } +} diff --git a/src/admin/pages/site/hooks/siteSyncMerge.ts b/src/admin/pages/site/hooks/siteSyncMerge.ts new file mode 100644 index 000000000..7ec6f39ef --- /dev/null +++ b/src/admin/pages/site/hooks/siteSyncMerge.ts @@ -0,0 +1,197 @@ +/** + * siteSyncMerge — the merge POLICY of the live-pull channel: what one + * incoming sync event does to the editor store. The transport (WebSocket + * lifecycle, ordering, reconnect) lives in `useSiteSocket`; keeping the + * policy separate makes it testable with a plain fake snapshot fetcher. + * + * Merge rule per event target (order matters): + * 1. base seq already ≥ event seq → skip. Absorbs the echo of this + * editor's own saves when the save response beat the event. + * 2. target dirty locally → do NOT touch it; add a pending conflict — the + * same banner as a 409'd save, one resolution UX for levels A and B. + * 3. clean → fetch the current remote state and `applyRemoteSnapshot` it. + * Dirtiness is re-checked after the fetch (an edit can land mid-wire). + * The apply deep-equal-skips identical content (own-save echoes that + * outran the response), so undo history only resets when remote content + * genuinely replaced local state — and the toast fires exactly then. + * + * `site-reloaded` (replace-mode save — import/bootstrap) hands off to the + * ordinary persistence reload path. `published` is informational. + */ +import type { SiteSyncEvent, SiteSyncTable } from '@core/persistence/syncEvents' +import { pushToast } from '@ui/components/Toast' +import { requestCmsSiteReload } from '@admin/state/adminEvents' +import { useEditorStore } from '@site/store/store' +import type { RemoteSnapshot } from '@site/store/slices/site/types' +import { fetchRemoteSnapshot, type RemoteSnapshotTarget } from './remoteSnapshot' + +/** Injectable fetch seam — tests hand in a fake that returns domain snapshots. */ +export interface SiteSyncMergeDeps { + fetchSnapshot: (target: RemoteSnapshotTarget) => Promise +} + +const defaultDeps: SiteSyncMergeDeps = { fetchSnapshot: fetchRemoteSnapshot } + +function isTargetDirty(table: SiteSyncTable, rowId: string): boolean { + const { _dirtySave } = useEditorStore.getState() + if (_dirtySave.all) return true + if (table === 'pages') { + return _dirtySave.pageIds.has(rowId) || _dirtySave.deletedPageIds.has(rowId) + } + if (table === 'components') { + return _dirtySave.componentIds.has(rowId) || _dirtySave.deletedComponentIds.has(rowId) + } + return _dirtySave.layoutIds.has(rowId) || _dirtySave.deletedLayoutIds.has(rowId) +} + +/** + * True when the target's only local dirtiness is its own pending DELETION — + * a remote deletion of the same row is then agreement, not a conflict, and + * merges silently (the apply clears the now-moot deleted mark). + */ +function isOnlyLocallyDeleted(table: SiteSyncTable, rowId: string): boolean { + const { _dirtySave } = useEditorStore.getState() + if (_dirtySave.all) return false + if (table === 'pages') { + return _dirtySave.deletedPageIds.has(rowId) && !_dirtySave.pageIds.has(rowId) + } + if (table === 'components') { + return _dirtySave.deletedComponentIds.has(rowId) && !_dirtySave.componentIds.has(rowId) + } + return _dirtySave.deletedLayoutIds.has(rowId) && !_dirtySave.layoutIds.has(rowId) +} + +/** Local display title of a row — for the remote-change toasts. */ +function localRowTitle(table: SiteSyncTable, rowId: string): string | null { + const site = useEditorStore.getState().site + if (!site) return null + if (table === 'pages') return site.pages.find((p) => p.id === rowId)?.title ?? null + if (table === 'components') { + return site.visualComponents.find((vc) => vc.id === rowId)?.name ?? null + } + return site.layouts.find((layout) => layout.id === rowId)?.name ?? null +} + +async function handleRowEvent( + table: SiteSyncTable, + rowId: string, + seq: number, + deletedRemotely: boolean, + actorName: string | undefined, + deps: SiteSyncMergeDeps, +): Promise { + const store = useEditorStore.getState() + if ((store.baseSeqs[rowId] ?? -1) >= seq) return // own write / already applied + if (isTargetDirty(table, rowId)) { + // Exception: both sides deleted the row — agreement merges silently. + if (!(deletedRemotely && isOnlyLocallyDeleted(table, rowId))) { + store.addSaveConflicts([{ table, rowId, seq }]) + return + } + } + + const wasActivePage = table === 'pages' && store.activePageId === rowId + const title = localRowTitle(table, rowId) + + let snapshot: RemoteSnapshot + if (deletedRemotely) { + // No fetch — the snapshot is synchronous, so nothing can interleave + // between the dirty check above and the apply below. + snapshot = { table, rowId, row: null, seq } + } else { + snapshot = await deps.fetchSnapshot({ table, rowId, seq }) + // An edit may have landed while the fetch was on the wire — a dirty + // target must never be silently overwritten, so it degrades to a conflict. + if (isTargetDirty(table, rowId)) { + useEditorStore.getState().addSaveConflicts([{ table, rowId, seq }]) + return + } + } + + const result = useEditorStore.getState().applyRemoteSnapshot(snapshot) + if (!result.applied) return + + const who = actorName ?? 'Another admin' + if (snapshot.table !== 'site' && snapshot.row === null && wasActivePage) { + pushToast({ + kind: 'info', + title: 'Page deleted', + body: `${who} deleted ${title ? `"${title}"` : 'the page you were viewing'}.`, + }) + } else if (result.clearedHistory) { + pushToast({ + kind: 'info', + title: 'Updated with newer changes', + body: `${who} saved ${title ? `"${title}"` : 'content you have open'} — your undo history was reset.`, + }) + } +} + +async function handleShellEvent( + seq: number, + actorName: string | undefined, + deps: SiteSyncMergeDeps, +): Promise { + const store = useEditorStore.getState() + if (store.shellBaseSeq >= seq) return + if (store._dirtySave.shell || store._dirtySave.all) { + store.addSaveConflicts([{ table: 'site', rowId: 'default', seq }]) + return + } + + const snapshot = await deps.fetchSnapshot({ table: 'site', rowId: 'default', seq }) + const after = useEditorStore.getState() + if (after._dirtySave.shell || after._dirtySave.all) { + after.addSaveConflicts([{ table: 'site', rowId: 'default', seq }]) + return + } + + const result = after.applyRemoteSnapshot(snapshot) + if (result.applied && result.clearedHistory) { + pushToast({ + kind: 'info', + title: 'Site settings updated', + body: `${actorName ?? 'Another admin'} changed site settings or styles — your undo history was reset.`, + }) + } +} + +/** Apply one validated sync event to the editor store (see module doc). */ +export async function processSiteSyncEvent( + event: SiteSyncEvent, + deps: SiteSyncMergeDeps = defaultDeps, +): Promise { + switch (event.kind) { + case 'rows-changed': + case 'rows-deleted': { + const deletedRemotely = event.kind === 'rows-deleted' + for (const [rowId, seq] of Object.entries(event.seqs)) { + await handleRowEvent(event.table, rowId, seq, deletedRemotely, event.actor?.name, deps) + } + useEditorStore.getState().advanceSyncCursor(Math.max(...Object.values(event.seqs), 0)) + break + } + case 'shell-changed': { + await handleShellEvent(event.seq, event.actor?.name, deps) + useEditorStore.getState().advanceSyncCursor(event.seq) + break + } + case 'site-reloaded': { + const store = useEditorStore.getState() + if (store.syncCursor >= event.seq) break + store.advanceSyncCursor(event.seq) + pushToast({ + kind: 'info', + title: 'Site replaced', + body: `${event.actor?.name ?? 'Another admin'} replaced the site (import) — reloading the editor.`, + }) + // The ordinary reload path refetches, revalidates, and reseeds the + // sync bases; local unsaved edits are moot against a replaced site. + requestCmsSiteReload() + break + } + case 'published': + // Informational — no editor state derives from the publish version yet. + break + } +} diff --git a/src/admin/pages/site/hooks/useSiteSocket.ts b/src/admin/pages/site/hooks/useSiteSocket.ts new file mode 100644 index 000000000..a7dbbf436 --- /dev/null +++ b/src/admin/pages/site/hooks/useSiteSocket.ts @@ -0,0 +1,35 @@ +/** + * useSiteSocket — lifecycle glue for the live-pull channel (multi-admin + * level B). The actual transport + merge machinery lives in + * `siteSocketClient.ts` / `siteSyncMerge.ts` and is LAZY-imported here so + * its chunk (event schemas, snapshot fetching, conflict plumbing) stays out + * of the site route's first-paint bundle. + * + * `enabled` gates on the site document being loaded — before that there is + * no sync cursor to subscribe with and nothing to merge into. + */ +import { useEffect } from 'react' + +export function useSiteSocket(enabled: boolean): void { + useEffect(() => { + if (!enabled) return undefined + if (typeof WebSocket === 'undefined') return undefined + + let dispose: (() => void) | null = null + let cancelled = false + + void import('./siteSocketClient') + .then((mod) => { + if (cancelled) return + dispose = mod.connectSiteSocket() + }) + .catch((err) => { + console.error('[useSiteSocket] failed to load the sync client:', err) + }) + + return () => { + cancelled = true + dispose?.() + } + }, [enabled]) +} diff --git a/src/admin/pages/site/store/slices/saveTrackingSlice.ts b/src/admin/pages/site/store/slices/saveTrackingSlice.ts index 66abfd83d..041f1d4da 100644 --- a/src/admin/pages/site/store/slices/saveTrackingSlice.ts +++ b/src/admin/pages/site/store/slices/saveTrackingSlice.ts @@ -65,6 +65,21 @@ interface SaveTrackingSlice { * Autosave is suppressed while non-empty; a successful save clears it. */ saveConflicts: SaveConflict[] + /** + * The highest site-global seq this editor has synchronized — the cursor + * the live-sync socket sends on (re)connect so the server can reply with + * exactly the delta it missed. Advanced by loads, save responses, and + * every processed sync event. + */ + syncCursor: number + /** Advance the sync cursor (monotonic — lower values are ignored). */ + advanceSyncCursor: (seq: number) => void + /** + * Merge incoming conflicts (from live-sync events) into the pending list — + * deduped by table+rowId, keeping the newest seq. `setSaveConflicts` + * REPLACES the list and stays the 409 handler's entry point. + */ + addSaveConflicts: (conflicts: readonly SaveConflict[]) => void /** Replace the base-seq maps wholesale — the load/reload path. */ seedBaseSeqs: (rowSeqs: Record, shellSeq: number) => void /** @@ -93,6 +108,7 @@ function nettedDirtySnapshot(current: DirtyMarks, site: SiteDocument | null): Di // No document to net against — return the marks as accumulated. return { all: current.all, + shell: current.shell, pageIds: new Set(current.pageIds), componentIds: new Set(current.componentIds), layoutIds: new Set(current.layoutIds), @@ -106,6 +122,7 @@ function nettedDirtySnapshot(current: DirtyMarks, site: SiteDocument | null): Di const layoutIds = new Set(site.layouts.map((l) => l.id)) return { all: current.all, + shell: current.shell, pageIds: filteredSet(current.pageIds, (id) => pageIds.has(id)), componentIds: filteredSet(current.componentIds, (id) => componentIds.has(id)), layoutIds: filteredSet(current.layoutIds, (id) => layoutIds.has(id)), @@ -152,11 +169,32 @@ export const createSaveTrackingSlice: EditorStoreSliceCreator baseSeqs: {}, shellBaseSeq: 0, saveConflicts: [], + syncCursor: 0, + + advanceSyncCursor: (seq) => + set((state) => { + if (seq > state.syncCursor) state.syncCursor = seq + }), + + addSaveConflicts: (conflicts) => + set((state) => { + for (const incoming of conflicts) { + const existing = state.saveConflicts.find( + (c) => c.table === incoming.table && c.rowId === incoming.rowId, + ) + if (existing) { + existing.seq = Math.max(existing.seq, incoming.seq) + } else { + state.saveConflicts.push({ ...incoming }) + } + } + }), seedBaseSeqs: (rowSeqs, shellSeq) => set((state) => { state.baseSeqs = { ...rowSeqs } state.shellBaseSeq = shellSeq + state.syncCursor = Math.max(shellSeq, ...Object.values(rowSeqs), 0) }), commitSavedBaseSeqs: (savedSite, dirty, seq) => @@ -180,6 +218,7 @@ export const createSaveTrackingSlice: EditorStoreSliceCreator // unchanged): the stored shell seq then stays BELOW this save's seq, // and the conflict check only fires on stored > base. state.shellBaseSeq = seq + if (seq > state.syncCursor) state.syncCursor = seq // A successful save proves no shipped row conflicted — stale banner // entries (all conflicts come from shipped rows) are moot. state.saveConflicts = [] diff --git a/src/admin/pages/site/store/slices/site/conflictActions.ts b/src/admin/pages/site/store/slices/site/conflictActions.ts index 488f494fc..081085639 100644 --- a/src/admin/pages/site/store/slices/site/conflictActions.ts +++ b/src/admin/pages/site/store/slices/site/conflictActions.ts @@ -1,23 +1,24 @@ /** - * Save-conflict resolution actions — multi-admin conflict safety (level A). + * Remote-apply + save-conflict resolution actions (multi-admin levels A+B). * - * When an incremental save 409s, the failed save restores its dirty marks and - * `setSaveConflicts` fills the pending list; the conflict banner then offers - * two resolutions per conflicted target: + * `applyRemoteSnapshot` is the ONE way remote state enters the live editor + * document. Two callers share it: + * - the conflict banner's **Load theirs** (level A) — the user explicitly + * discards their local edits to one conflicted target; + * - the live-sync socket's **clean-target pull** (level B) — a sibling + * admin saved a row this editor holds clean, so it merges in place. * - * Keep mine — bump the target's base seq to the remote seq. The local - * edits stay dirty and the NEXT save overwrites the remote - * version — a stated decision instead of a silent one. - * Load theirs — the banner fetches the remote state and hands it here as a - * `RemoteConflictSnapshot`; the target is swapped in place - * (or removed, when deleted remotely) WITHOUT undo history, - * its dirty marks are cleared, and the base seq syncs to the - * remote seq. - * - * Both resolutions clear the undo history: history entries are site-relative - * Mutative patches with array indices — replaying them across a remotely - * swapped tree is undefined behavior. (Keep-mine keeps the local document - * intact, so its history survives.) + * Apply semantics: + * - the target is swapped in (or removed, when deleted remotely) WITHOUT + * pushing undo history; its dirty marks, base seq, and any pending + * conflict entry are synchronized; + * - the undo history is cleared — history entries are site-relative + * Mutative patches with array indices, and replaying them across a + * remotely swapped tree is undefined behavior; + * - EXCEPT when the fetched remote content deep-equals the local copy. + * That is exactly what the echo of this editor's own save looks like + * (the socket event can arrive before the save response), so the apply + * collapses to bookkeeping — and the user's undo history survives. * * VC consumer propagation: adopting a remote Visual Component re-syncs the * slot instances of every ref to it, and adopting a remote VC DELETION @@ -25,12 +26,19 @@ * pages earn REAL dirty marks (they now differ from storage and must ship * with the next save). The history entries those mutations push are cleared * along with everything else. + * + * `resolveSaveConflictKeepMine` (level A) is the other resolution: bump the + * target's base seq so the NEXT save overwrites the remote version — a + * stated decision instead of a silent one. Local state stays untouched, so + * its history survives. */ import type { BaseNode } from '@core/page-tree' import { findHomePage, reconcileSiteExplorerInPlace, reindexNodeParents } from '@core/page-tree' +import { shellsEqual } from '@core/persistence/shellsEqual' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' +import { deepEqual } from '@core/utils/deepEqual' import type { EditorStore } from '@site/store/types' import type { Draft } from 'mutative' import { renderCache } from '@site/canvas/renderCache' @@ -38,11 +46,11 @@ import { clearCanvasSelectionDraft } from '../selectionSlice' import { allTreeNodeMaps, syncAllVCRefSlotInstances } from '../vcSlotReconcile' import { cascadeRemoveVCRefs } from '../vcTreeOps' import { reconcileFrameworkClasses } from './framework/reconcile' -import type { SiteSlice, SiteSliceHelpers } from './types' +import type { RemoteSnapshot, SiteSlice, SiteSliceHelpers } from './types' type ConflictActions = Pick< SiteSlice, - 'setSaveConflicts' | 'resolveSaveConflictKeepMine' | 'resolveSaveConflictTheirs' + 'setSaveConflicts' | 'resolveSaveConflictKeepMine' | 'applyRemoteSnapshot' > function dropConflict(state: Draft, table: string, rowId: string): void { @@ -59,7 +67,38 @@ function clearUndoHistory(state: Draft): void { state.canRedo = false } -export function createConflictActions({ set, mutateSite }: SiteSliceHelpers): ConflictActions { +/** The snapshot's target rowId — the shell lives on the fixed 'default' row. */ +function snapshotRowId(snapshot: RemoteSnapshot): string { + return snapshot.table === 'site' ? 'default' : snapshot.rowId +} + +/** + * True when the remote snapshot is content-identical to the local document — + * the apply can collapse to bookkeeping (see module doc). For a remote + * deletion, "identical" means the target is already absent locally. + */ +function snapshotMatchesLocal( + site: EditorStore['site'], + snapshot: RemoteSnapshot, +): boolean { + if (!site) return false + if (snapshot.table === 'site') { + const { pages: _p, visualComponents: _v, layouts: _l, ...localShell } = site + // shellsEqual ignores `updatedAt` — bumped by every local mutation, so a + // plain deep-equal would misread every own-save echo as a remote change. + return shellsEqual(localShell, snapshot.shell) + } + const local = + snapshot.table === 'pages' + ? site.pages.find((p) => p.id === snapshot.rowId) + : snapshot.table === 'components' + ? site.visualComponents.find((vc) => vc.id === snapshot.rowId) + : site.layouts.find((layout) => layout.id === snapshot.rowId) + if (snapshot.row === null) return local === undefined + return local !== undefined && deepEqual(local, snapshot.row) +} + +export function createConflictActions({ set, get, mutateSite }: SiteSliceHelpers): ConflictActions { return { setSaveConflicts: (conflicts) => set((state) => { @@ -81,7 +120,41 @@ export function createConflictActions({ set, mutateSite }: SiteSliceHelpers): Co // next save ships the same rows and now passes the seq check. }), - resolveSaveConflictTheirs: (snapshot) => { + applyRemoteSnapshot: (snapshot) => { + // Echo / no-op detection BEFORE any mutation: identical content means + // this editor is already synchronized (typically its own save echoing + // back through the socket) — sync the bookkeeping, keep the history. + if (snapshotMatchesLocal(get().site, snapshot)) { + set((state) => { + dropConflict(state, snapshot.table, snapshotRowId(snapshot)) + if (snapshot.table === 'site') { + state.shellBaseSeq = Math.max(state.shellBaseSeq, snapshot.seq) + } else if (snapshot.row === null) { + delete state.baseSeqs[snapshot.rowId] + // Aligned deletion (both sides deleted) — nothing left to ship. + const deletedMarks = + snapshot.table === 'pages' + ? state._dirtySave.deletedPageIds + : snapshot.table === 'components' + ? state._dirtySave.deletedComponentIds + : state._dirtySave.deletedLayoutIds + deletedMarks.delete(snapshot.rowId) + } else { + state.baseSeqs[snapshot.rowId] = Math.max( + state.baseSeqs[snapshot.rowId] ?? 0, + snapshot.seq, + ) + } + }) + return { applied: false, clearedHistory: false } + } + + // Read BEFORE the VC propagation below — `clearedHistory` reports + // whether the USER's undo entries were discarded, not the propagation's + // own transient entry. + const hadHistory = + get()._historyPast.length > 0 || get()._historyFuture.length > 0 + // Consumer propagation FIRST (see module doc): mutateSite gives the // affected pages real patch-derived dirty marks. Neither helper needs // the VC swapped in yet — the sync takes the remote VC as an argument, @@ -112,7 +185,7 @@ export function createConflictActions({ set, mutateSite }: SiteSliceHelpers): Co set((state) => { const site = state.site if (!site) return - dropConflict(state, snapshot.table, snapshot.table === 'site' ? 'default' : snapshot.rowId) + dropConflict(state, snapshot.table, snapshotRowId(snapshot)) switch (snapshot.table) { case 'site': { @@ -205,6 +278,8 @@ export function createConflictActions({ set, mutateSite }: SiteSliceHelpers): Co clearUndoHistory(state) }) + + return { applied: true, clearedHistory: hadHistory } }, } } diff --git a/src/admin/pages/site/store/slices/site/dirtyTracking.ts b/src/admin/pages/site/store/slices/site/dirtyTracking.ts index 6d8c7ad0e..c7db34ad5 100644 --- a/src/admin/pages/site/store/slices/site/dirtyTracking.ts +++ b/src/admin/pages/site/store/slices/site/dirtyTracking.ts @@ -42,6 +42,15 @@ import type { SiteDocument } from '@core/page-tree' export interface DirtyMarks { all: boolean + /** + * A shell field (settings, style rules, files, breakpoints, explorer, …) + * changed since the last successful save. The shell still SHIPS with every + * save regardless (the server skips writing an unchanged shell) — this + * mark exists for the live-sync merge rule: a remote `shell-changed` event + * applies silently when the local shell is untouched, and surfaces as a + * conflict when it isn't. + */ + shell: boolean pageIds: Set componentIds: Set layoutIds: Set @@ -53,6 +62,7 @@ export interface DirtyMarks { export function emptyDirtyMarks(): DirtyMarks { return { all: false, + shell: false, pageIds: new Set(), componentIds: new Set(), layoutIds: new Set(), @@ -122,7 +132,18 @@ export function collectDirtyFromSitePatches( for (const patch of patches) { const head = patch.path[0] - if (!TRACKED_COLLECTIONS.includes(head as TrackedCollection)) continue // shell field — always saved + if (!TRACKED_COLLECTIONS.includes(head as TrackedCollection)) { + // `updatedAt` is bumped by EVERY historic mutation (page edits + // included) — it is bookkeeping, not shell content, and must not make + // every edit look like a shell edit (the server's `shellsEqual` + // ignores it for the same reason). + if (head !== 'updatedAt') { + // Shell field — always shipped with the save; the mark feeds the + // live-sync merge rule (see the DirtyMarks doc). + marks.shell = true + } + continue + } const collection = head as TrackedCollection // Membership-shaped ops → pre/post id-set diff, once per collection. @@ -162,6 +183,7 @@ export function collectDirtyFromSitePatches( /** Merge `incoming` into a draft's accumulated dirty state, in place. */ export function mergeDirtyMarks(target: DirtyMarks, incoming: DirtyMarks): void { if (incoming.all) target.all = true + if (incoming.shell) target.shell = true for (const id of incoming.pageIds) target.pageIds.add(id) for (const id of incoming.componentIds) target.componentIds.add(id) for (const id of incoming.layoutIds) target.layoutIds.add(id) diff --git a/src/admin/pages/site/store/slices/site/lifecycleActions.ts b/src/admin/pages/site/store/slices/site/lifecycleActions.ts index 2a8d200d6..59d65996b 100644 --- a/src/admin/pages/site/store/slices/site/lifecycleActions.ts +++ b/src/admin/pages/site/store/slices/site/lifecycleActions.ts @@ -70,6 +70,7 @@ export function createLifecycleActions({ state.baseSeqs = {} state.shellBaseSeq = 0 state.saveConflicts = [] + state.syncCursor = 0 }) return site }, @@ -106,6 +107,7 @@ export function createLifecycleActions({ state.baseSeqs = {} state.shellBaseSeq = 0 state.saveConflicts = [] + state.syncCursor = 0 }) }, @@ -127,6 +129,7 @@ export function createLifecycleActions({ state.baseSeqs = {} state.shellBaseSeq = 0 state.saveConflicts = [] + state.syncCursor = 0 }) }, diff --git a/src/admin/pages/site/store/slices/site/types.ts b/src/admin/pages/site/store/slices/site/types.ts index c22c425ec..ea14e393e 100644 --- a/src/admin/pages/site/store/slices/site/types.ts +++ b/src/admin/pages/site/store/slices/site/types.ts @@ -36,16 +36,25 @@ import type { FrameworkChangeImpact, FrameworkPreset } from '@core/framework' import type { EditorStore } from '@site/store/types' /** - * The remote ("theirs") state of one conflicted target, fetched by the - * conflict banner for `resolveSaveConflictTheirs`. `row: null` means the - * target was deleted remotely — resolution removes it locally. + * The fetched remote state of one sync target — consumed by + * `applyRemoteSnapshot`, which serves both the conflict banner's + * "Load theirs" and the live-sync socket's clean-row pull. `row: null` + * means the target was deleted remotely — applying removes it locally. */ -export type RemoteConflictSnapshot = +export type RemoteSnapshot = | { table: 'site'; shell: SiteShell; seq: number } | { table: 'pages'; rowId: string; row: Page | null; seq: number } | { table: 'components'; rowId: string; row: VisualComponent | null; seq: number } | { table: 'layouts'; rowId: string; row: SavedLayout | null; seq: number } +/** What `applyRemoteSnapshot` actually did — the socket hook toasts on it. */ +export interface RemoteApplyResult { + /** False when the snapshot deep-equaled local state (echo) — bookkeeping only. */ + applied: boolean + /** True when undo entries existed and were discarded by the apply. */ + clearedHistory: boolean +} + // --------------------------------------------------------------------------- // Public action surface — every method below appears as a top-level entry on @@ -153,12 +162,16 @@ export interface SiteSlice { */ resolveSaveConflictKeepMine: (conflict: SaveConflict) => void /** - * Adopt the remote version: swap it into the document (or remove the - * target when deleted remotely) without pushing undo history, clear the - * target's dirty marks, and sync the base seq. Clears the undo history — - * site-relative patches are undefined across a remotely swapped tree. + * Adopt a remote version: swap it into the document (or remove the target + * when deleted remotely) without pushing undo history, clear the target's + * dirty marks and any pending conflict for it, and sync the base seq. + * Clears the undo history — site-relative patches are undefined across a + * remotely swapped tree — EXCEPT when the remote content deep-equals the + * local copy (the echo of one's own save), which is bookkeeping-only. + * Serves the conflict banner's "Load theirs" AND the live-sync socket's + * clean-target pull. */ - resolveSaveConflictTheirs: (snapshot: RemoteConflictSnapshot) => void + applyRemoteSnapshot: (snapshot: RemoteSnapshot) => RemoteApplyResult // Page mutations addPage: (title: string, slug?: string) => Page diff --git a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx index 75a0e840d..1581f94ab 100644 --- a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx +++ b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx @@ -8,7 +8,7 @@ * the two resolutions: * * Load theirs — fetch the remote state (single-row `?id=` fetch, or the - * shell GET) and adopt it via `resolveSaveConflictTheirs`, + * shell GET) and adopt it via `applyRemoteSnapshot`, * discarding the local unsaved edits to that target only. * Keep mine — `resolveSaveConflictKeepMine` bumps the base seq; the next * save overwrites the remote version as a stated decision. @@ -25,15 +25,11 @@ import { useState } from 'react' import type { SiteDocument } from '@core/page-tree' import type { SaveConflict } from '@core/persistence/saveConflict' -import { cmsAdapter } from '@core/persistence/cms' -import { pageFromRow } from '@core/data/pageFromRow' -import { visualComponentFromRow } from '@core/data/componentFromRow' -import { savedLayoutFromRow } from '@core/data/layoutFromRow' import { getErrorMessage } from '@core/utils/errorMessage' import { Button } from '@ui/components/Button' import { pushToast } from '@ui/components/Toast' import { useEditorStore } from '@site/store/store' -import type { RemoteConflictSnapshot } from '@site/store/slices/site/types' +import { fetchRemoteSnapshot } from '@site/hooks/remoteSnapshot' import { flushEditorSave } from '@site/hooks/editorSaveRef' import styles from './SaveConflictBanner.module.css' @@ -57,29 +53,6 @@ function conflictLabel(site: SiteDocument | null, conflict: SaveConflict): strin return site.layouts.find((layout) => layout.id === conflict.rowId)?.name ?? conflict.rowId } -/** Fetch the remote ("theirs") state of one conflicted target. */ -async function fetchRemoteSnapshot(conflict: SaveConflict): Promise { - if (conflict.table === 'site') { - const remote = await cmsAdapter.loadSiteShell() - if (!remote) throw new Error('The site shell no longer exists on the server.') - return { table: 'site', shell: remote.shell, seq: remote.seq } - } - const row = await cmsAdapter.loadSiteRow(conflict.table, conflict.rowId) - const seq = row?.seq ?? conflict.seq - if (conflict.table === 'pages') { - return { table: 'pages', rowId: conflict.rowId, row: row ? pageFromRow(row) : null, seq } - } - if (conflict.table === 'components') { - return { - table: 'components', - rowId: conflict.rowId, - row: row ? visualComponentFromRow(row) : null, - seq, - } - } - return { table: 'layouts', rowId: conflict.rowId, row: row ? savedLayoutFromRow(row) : null, seq } -} - /** Ship the surviving local edits once the last conflict is decided. */ async function flushIfResolved(): Promise { const { saveConflicts, hasUnsavedChanges } = useEditorStore.getState() @@ -99,7 +72,7 @@ export function SaveConflictBanner() { setBusyKey(`${conflict.table}:${conflict.rowId}`) try { const snapshot = await fetchRemoteSnapshot(conflict) - useEditorStore.getState().resolveSaveConflictTheirs(snapshot) + useEditorStore.getState().applyRemoteSnapshot(snapshot) await flushIfResolved() } catch (err) { console.error('[SaveConflictBanner] failed to load the remote version:', err) diff --git a/src/core/persistence/shellsEqual.ts b/src/core/persistence/shellsEqual.ts new file mode 100644 index 000000000..31810cc8a --- /dev/null +++ b/src/core/persistence/shellsEqual.ts @@ -0,0 +1,18 @@ +/** + * Shell content equality — shared by the transactional save (server skips + * the shell write + seq stamp when nothing changed) and the editor's + * remote-apply echo detection (identical shells are bookkeeping-only, no + * undo-history reset). + * + * `updatedAt` is deliberately EXCLUDED: the editor bumps it on every historic + * mutation (page edits included), so treating it as content would turn every + * save into a "shell change" and destroy the shell seq as a conflict signal. + * The stored shell's `updatedAt` consequently means "when shell CONTENT last + * changed". + */ +import type { SiteShell } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' + +export function shellsEqual(a: SiteShell, b: SiteShell): boolean { + return deepEqual({ ...a, updatedAt: 0 }, { ...b, updatedAt: 0 }) +} diff --git a/src/core/persistence/syncEvents.ts b/src/core/persistence/syncEvents.ts new file mode 100644 index 000000000..897654fe6 --- /dev/null +++ b/src/core/persistence/syncEvents.ts @@ -0,0 +1,93 @@ +/** + * Site sync-event protocol — the wire shapes of the multi-admin live-pull + * channel (`GET /admin/api/cms/site-socket`, level B of the live-sync plan). + * + * Design rules (from the live-sync plan): + * - Events carry **ids + seqs, never row payloads**. They are idempotent + * HINTS — the client fetches current rows itself, so a dropped, replayed, + * or reordered event can never corrupt state. The seq-cursor delta on + * (re)connect is the truth; the socket only makes it prompt. + * - One save's rows share that save's seq; delta-synthesized events (sent + * in response to `subscribe`) may span many saves, hence per-row seqs. + * - `actor` is display-level identity for the awareness UI. Absent on + * delta-synthesized events (the originating save is no longer known). + * + * The server constructs these events (server/events/siteEvents.ts emits them + * post-commit from the transactional save); the client validates every + * incoming frame against `SiteSyncEventSchema` before acting — an unknown or + * malformed frame is logged and dropped, never applied. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' + +/** The socket endpoint — shared by the server upgrade dispatch and the client hook. */ +export const SITE_SOCKET_PATH = '/admin/api/cms/site-socket' + +/** The three row-backed site collections that sync per row. */ +export const SiteSyncTableSchema = Type.Union([ + Type.Literal('pages'), + Type.Literal('components'), + Type.Literal('layouts'), +]) +export type SiteSyncTable = Static + +/** Display-level identity of the admin (or connector-driven agent) who saved. */ +export const SiteSyncActorSchema = Type.Object({ + userId: Type.Union([Type.String(), Type.Null()]), + name: Type.String(), +}) +export type SiteSyncActor = Static + +const RowsChangedEventSchema = Type.Object({ + kind: Type.Literal('rows-changed'), + table: SiteSyncTableSchema, + /** rowId → the seq stamped on that row by the save that changed it. */ + seqs: Type.Record(Type.String(), Type.Number()), + actor: Type.Optional(SiteSyncActorSchema), +}) + +const RowsDeletedEventSchema = Type.Object({ + kind: Type.Literal('rows-deleted'), + table: SiteSyncTableSchema, + /** rowId → the seq stamped on that row by the save that soft-deleted it. */ + seqs: Type.Record(Type.String(), Type.Number()), + actor: Type.Optional(SiteSyncActorSchema), +}) + +const ShellChangedEventSchema = Type.Object({ + kind: Type.Literal('shell-changed'), + seq: Type.Number(), + actor: Type.Optional(SiteSyncActorSchema), +}) + +/** A replace-mode save (import / bootstrap) rewrote the site wholesale. */ +const SiteReloadedEventSchema = Type.Object({ + kind: Type.Literal('site-reloaded'), + seq: Type.Number(), + actor: Type.Optional(SiteSyncActorSchema), +}) + +const PublishedEventSchema = Type.Object({ + kind: Type.Literal('published'), + publishVersion: Type.Number(), + actor: Type.Optional(SiteSyncActorSchema), +}) + +export const SiteSyncEventSchema = Type.Union([ + RowsChangedEventSchema, + RowsDeletedEventSchema, + ShellChangedEventSchema, + SiteReloadedEventSchema, + PublishedEventSchema, +]) +export type SiteSyncEvent = Static + +/** + * The one client→server message: sent on (re)connect with the client's seq + * cursor. The server replies with delta events synthesized from + * `rows where seq > cursor` (soft-deleted rows included) plus the shell — + * which is why a missed live event can never cause drift. + */ +export const SiteSocketSubscribeSchema = Type.Object({ + kind: Type.Literal('subscribe'), + cursor: Type.Number(), +}) diff --git a/src/core/utils/deepEqual.ts b/src/core/utils/deepEqual.ts new file mode 100644 index 000000000..939f0685e --- /dev/null +++ b/src/core/utils/deepEqual.ts @@ -0,0 +1,37 @@ +/** + * Structural deep equality for plain JSON-shaped values (objects, arrays, + * primitives) — key order insensitive, no prototype/cycle handling (persisted + * CMS documents are acyclic plain data by construction). + * + * Shared by the server's shell diff (`server/handlers/cms/siteDiff.ts`) and + * the editor's remote-apply echo detection (`applyRemoteSnapshot` skips the + * swap — and the undo-history clear — when the fetched remote content is + * identical to the local copy, which is exactly what an echo of one's own + * save looks like). + */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a === null || b === null) return a === b + if (typeof a !== typeof b) return false + if (typeof a !== 'object') return false + if (Array.isArray(a)) { + if (!Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + if (Array.isArray(b)) return false + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) return false + for (const k of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false + if (!deepEqual( + (a as Record)[k], + (b as Record)[k], + )) return false + } + return true +} diff --git a/vite.config.ts b/vite.config.ts index 24432c221..3a108dba9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -235,12 +235,13 @@ export default defineConfig({ // Bun backend. Agent endpoints live under `/admin/api/agent` (and // `/admin/api/agent/tool-result`) so the admin session cookie — // scoped to `Path=/admin` to keep it off the public site — actually - // accompanies the request. The `ws: false` default suffices; we do - // not need WebSocket upgrades for the agent (NDJSON streams over a - // standard HTTP response). + // accompanies the request. `ws: true` forwards the live-sync socket + // upgrade (`/admin/api/cms/site-socket`); the agent itself still + // streams NDJSON over standard HTTP responses. '/admin/api': { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, + ws: true, }, '/uploads': { target: CMS_DEV_SERVER_ORIGIN, From ab4ab93300ebf99d307b7cb2d109ea47da457d8b Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:40:35 +0200 Subject: [PATCH 03/49] chore(collab): add yjs + y-protocols + lib0 Co-Authored-By: Claude Fable 5 --- bun.lock | 11 +++++++++++ package.json | 3 +++ 2 files changed, 14 insertions(+) diff --git a/bun.lock b/bun.lock index 08baae5a5..c42826be3 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "fflate": "^0.8.2", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", + "lib0": "^0.2.117", "lru-cache": "^11.3.5", "marked": "^18.0.3", "mutative": "^1.3.0", @@ -47,6 +48,8 @@ "semver": "^7.7.4", "sharp": "^0.34.5", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1", }, @@ -1151,6 +1154,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1195,6 +1200,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1681,10 +1688,14 @@ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/package.json b/package.json index f7f6c4bae..a65662a03 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "fflate": "^0.8.2", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", + "lib0": "^0.2.117", "lru-cache": "^11.3.5", "marked": "^18.0.3", "mutative": "^1.3.0", @@ -100,6 +101,8 @@ "semver": "^7.7.4", "sharp": "^0.34.5", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1" }, From 95ffc2a45227a1a4350a67e081c452e2feb39047 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:45:21 +0200 Subject: [PATCH 04/49] feat(collab): core doc ids, Y schema, seeding + projection with integrity reconcile Co-Authored-By: Claude Fable 5 --- .../no-core-barrel-deep-imports.test.ts | 1 + src/__tests__/collab/seedProject.test.ts | 195 ++++++++++++++++++ src/core/collab/docIds.ts | 33 +++ src/core/collab/index.ts | 39 ++++ src/core/collab/integrity.ts | 54 +++++ src/core/collab/nodeY.ts | 90 ++++++++ src/core/collab/project.ts | 108 ++++++++++ src/core/collab/schema.ts | 47 +++++ src/core/collab/seed.ts | 113 ++++++++++ 9 files changed, 680 insertions(+) create mode 100644 src/__tests__/collab/seedProject.test.ts create mode 100644 src/core/collab/docIds.ts create mode 100644 src/core/collab/index.ts create mode 100644 src/core/collab/integrity.ts create mode 100644 src/core/collab/nodeY.ts create mode 100644 src/core/collab/project.ts create mode 100644 src/core/collab/schema.ts create mode 100644 src/core/collab/seed.ts diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 6ee37600c..8c97aefda 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -36,6 +36,7 @@ const BARRELLED_MODULES = [ 'framework', 'framework-schema', 'fonts', + 'collab', ] // Scan production + test sources in both the app and the server. diff --git a/src/__tests__/collab/seedProject.test.ts b/src/__tests__/collab/seedProject.test.ts new file mode 100644 index 000000000..176362a88 --- /dev/null +++ b/src/__tests__/collab/seedProject.test.ts @@ -0,0 +1,195 @@ +/** + * Collab core — seed → project round-trips (Yjs docs ⇄ domain JSON). + * + * The projection is the ONLY reader of Y state and must reproduce the domain + * shape exactly (minus `parentId`, which is derived and re-indexed), heal + * tree-integrity damage deterministically (duplicate child ids, orphan + * nodes — possible under rare concurrent-merge interleavings), and reconcile + * the shell's roster order against roster membership. + * + * `import '@modules/base'` populates the module registry so `base.text`'s + * inline-text prop (`text`) seeds as Y.Text — the character-merge substrate. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, + treeMap, +} from '@core/collab' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +function fixturePage() { + return makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'Hello', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }) +} + +describe('page doc seed → project round-trip', () => { + it('projects back exactly the seeded page (parentId re-derived)', () => { + const page = fixturePage() + const doc = new Y.Doc() + seedPageDoc(doc, page) + const projected = projectPageDoc(doc, 'p1') + + expect(projected.id).toBe('p1') + expect(projected.title).toBe('Home') + expect(projected.slug).toBe('index') + expect(projected.rootNodeId).toBe(page.rootNodeId) + expect(projected.nodes.t1.props.text).toBe('Hello') + expect(projected.nodes.t1.props.tag).toBe('p') + expect(projected.nodes.root.children).toEqual(['t1', 'c1']) + expect(projected.nodes.t1.parentId).toBe('root') // reindexed, not stored + }) + + it('stores the inline-text prop as Y.Text (character-merge substrate)', () => { + const doc = new Y.Doc() + seedPageDoc(doc, fixturePage()) + const nodes = treeMap(doc).get('nodes') as Y.Map + const t1 = nodes.get('t1') as Y.Map + const props = t1.get('props') as Y.Map + expect(props.get('text')).toBeInstanceOf(Y.Text) + expect((props.get('text') as Y.Text).toString()).toBe('Hello') + expect(props.get('tag')).toBe('p') // non-inline props stay plain + }) + + it('round-trips breakpoint overrides and scalar node fields', () => { + const page = makePage({ + id: 'p2', + slug: 'about', + title: 'About', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['b1'] }), + b1: makeNode({ + id: 'b1', + moduleId: 'base.container', + label: 'Hero', + locked: true, + classIds: ['cls-1'], + breakpointOverrides: { mobile: { gap: '8px' } }, + }), + }, + }) + const doc = new Y.Doc() + seedPageDoc(doc, page) + const projected = projectPageDoc(doc, 'p2') + expect(projected.nodes.b1.label).toBe('Hero') + expect(projected.nodes.b1.locked).toBe(true) + expect(projected.nodes.b1.classIds).toEqual(['cls-1']) + expect(projected.nodes.b1.breakpointOverrides).toEqual({ mobile: { gap: '8px' } }) + }) +}) + +describe('component + layout doc round-trips', () => { + it('projects a VC back with tree, params and meta intact', () => { + const vc = makeVC({ id: 'vc1', name: 'Card' }) + const doc = new Y.Doc() + seedComponentDoc(doc, vc) + const projected = projectComponentDoc(doc, 'vc1') + expect(projected.id).toBe('vc1') + expect(projected.name).toBe('Card') + expect(projected.tree.rootNodeId).toBe(vc.tree.rootNodeId) + expect(Object.keys(projected.tree.nodes)).toEqual(Object.keys(vc.tree.nodes)) + expect(projected.params).toEqual(vc.params) + }) + + it('projects a layout back from its whole-snapshot storage', () => { + const layout = { + id: 'l1', + name: 'Hero section', + rootNodeId: 'root', + nodes: { root: makeNode({ id: 'root', moduleId: 'base.container' }) }, + classes: {}, + createdAt: 1_700_000_000_000, + } + const doc = new Y.Doc() + seedLayoutDoc(doc, layout) + const projected = projectLayoutDoc(doc, 'l1') + expect(projected.name).toBe('Hero section') + expect(projected.rootNodeId).toBe('root') + expect(Object.keys(projected.nodes)).toEqual(['root']) + }) +}) + +describe('site doc (shell + rosters)', () => { + it('projects shell fields, per-entry maps, and rosters back', () => { + const site = makeSite({ + name: 'Acme', + pages: [makePage({ id: 'a', slug: 'index' }), makePage({ id: 'b', slug: 'b' })], + visualComponents: [makeVC({ id: 'vc1', name: 'Card' })], + styleRules: { + r1: { + id: 'r1', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'var(--text)' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + const projected = projectSiteDoc(doc) + expect(projected.shell.name).toBe('Acme') + expect(projected.shell.styleRules.r1.selector).toBe('.hero') + expect(projected.shell.settings).toEqual(site.settings) + expect(projected.rosters.pages).toEqual(['a', 'b']) + expect(projected.rosters.components).toEqual(['vc1']) + }) + + it('reconciles roster order against membership (dedupe, append missing, drop deleted)', () => { + const site = makeSite({ + pages: [makePage({ id: 'a', slug: 'index' }), makePage({ id: 'b', slug: 'b' })], + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + doc.transact(() => { + const rosters = doc.getMap('rosters') + const order = rosters.get('pageOrder') as Y.Array + order.push(['a']) // duplicate + order.push(['ghost']) // not a member + const pages = rosters.get('pages') as Y.Map + pages.set('c', true) // member missing from order + }) + const projected = projectSiteDoc(doc) + expect(projected.rosters.pages).toEqual(['a', 'b', 'c']) + }) +}) + +describe('tree-integrity reconcile', () => { + it('heals duplicate child ids and re-attaches orphan nodes deterministically', () => { + const doc = new Y.Doc() + seedPageDoc(doc, fixturePage()) + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const root = nodes.get('root') as Y.Map + ;(root.get('children') as Y.Array).push(['t1']) // duplicate child + // Orphan node referenced by nothing: + const ghost = new Y.Map() + ghost.set('id', 'ghost') + ghost.set('moduleId', 'base.text') + ghost.set('props', new Y.Map()) + ghost.set('breakpointOverrides', new Y.Map()) + ghost.set('children', new Y.Array()) + nodes.set('ghost', ghost) + // Dangling child id with no node entry: + ;(root.get('children') as Y.Array).push(['missing-node']) + }) + const projected = projectPageDoc(doc, 'p1') + expect(projected.nodes.root.children.filter((c) => c === 't1')).toHaveLength(1) + expect(projected.nodes.root.children).not.toContain('missing-node') + expect(projected.nodes.root.children).toContain('ghost') + expect(projected.nodes.ghost.parentId).toBe('root') + }) +}) diff --git a/src/core/collab/docIds.ts b/src/core/collab/docIds.ts new file mode 100644 index 000000000..540dd22b6 --- /dev/null +++ b/src/core/collab/docIds.ts @@ -0,0 +1,33 @@ +/** + * Collab document addressing — one Yjs document per logical row/shell. + * + * The doc id is the unit the whole collaboration stack speaks: the client + * provider binds by doc id, the server relay registers and persists by doc + * id, the wire protocol prefixes every frame with it, and the + * `collab_documents` table keys on it. + */ + +export type CollabDocKind = 'site' | 'page' | 'component' | 'layout' + +export interface CollabDocId { + kind: CollabDocKind + /** The backing row id; the shell uses the fixed site row id `default`. */ + rowId: string +} + +export const SITE_DOC_ID = 'site:default' + +export function encodeCollabDocId(id: CollabDocId): string { + return `${id.kind}:${id.rowId}` +} + +const KINDS: readonly CollabDocKind[] = ['site', 'page', 'component', 'layout'] + +export function parseCollabDocId(raw: string): CollabDocId | null { + const sep = raw.indexOf(':') + if (sep <= 0) return null + const kind = raw.slice(0, sep) + const rowId = raw.slice(sep + 1) + if (!rowId || !KINDS.includes(kind as CollabDocKind)) return null + return { kind: kind as CollabDocKind, rowId } +} diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts new file mode 100644 index 000000000..ebf4876ac --- /dev/null +++ b/src/core/collab/index.ts @@ -0,0 +1,39 @@ +/** + * @core/collab — the CRDT collaboration engine (Yjs). + * + * One Y document per logical row/shell; JSON⇄Y seeding + projection; + * Mutative-patch → Y translation; deterministic integrity reconciles. + * Consumed by the editor store (write path + mirror), the server relay + * (seed/persist), and the wire protocol. Barrel-gated: import ONLY from + * `@core/collab` outside this folder. + */ +export { + encodeCollabDocId, + parseCollabDocId, + SITE_DOC_ID, + type CollabDocId, + type CollabDocKind, +} from './docIds' +export { + dataMap, + inlineTextPropOf, + LOCAL_ORIGIN, + metaMap, + RECONCILE_ORIGIN, + REMOTE_ORIGIN, + rostersMap, + SEED_CLIENT_ID, + SEED_ORIGIN, + shellMap, + treeMap, +} from './schema' +export { buildNodeMap, buildPropsMap, projectNodeMap } from './nodeY' +export { reconcileTreeIntegrity } from './integrity' +export { seedComponentDoc, seedLayoutDoc, seedPageDoc, seedSiteDoc } from './seed' +export { + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + type ProjectedSiteDoc, +} from './project' diff --git a/src/core/collab/integrity.ts b/src/core/collab/integrity.ts new file mode 100644 index 000000000..052039283 --- /dev/null +++ b/src/core/collab/integrity.ts @@ -0,0 +1,54 @@ +/** + * Tree-integrity reconcile — deterministic healing of the rare tree damage + * concurrent CRDT merges can produce (Yjs guarantees CONVERGENCE, not + * domain-level tree invariants): + * + * - duplicate ids inside a `children` array (concurrent move interleavings) + * - dangling child ids whose node entry was deleted concurrently + * - orphan nodes reachable from no children array (re-attached under the + * root, sorted by id for determinism) + * - a node listed in two parents (first traversal wins; later duplicates + * dropped) + * + * MUST be deterministic: it runs in every peer's projection on identical + * converged input, so identical output keeps all peers rendering the same + * tree without another round-trip. + */ +import type { BaseNode } from '@core/page-tree' + +export function reconcileTreeIntegrity( + nodes: Record, + rootNodeId: string, +): void { + const claimed = new Set([rootNodeId]) + + // Walk from root, healing children arrays in place. + const queue: string[] = [rootNodeId] + while (queue.length > 0) { + const id = queue.shift()! + const node = nodes[id] + if (!node) continue + const healed: string[] = [] + for (const childId of node.children) { + if (childId === rootNodeId) continue // a root can never be a child + if (!nodes[childId]) continue // dangling reference + if (claimed.has(childId)) continue // duplicate / second parent + claimed.add(childId) + healed.push(childId) + queue.push(childId) + } + if (healed.length !== node.children.length) node.children = healed + } + + // Orphans: nodes never reached from the root — re-attach under root, + // sorted by id so every peer picks the same order. + const root = nodes[rootNodeId] + if (!root) return + const orphans = Object.keys(nodes) + .filter((id) => !claimed.has(id)) + .sort() + if (orphans.length > 0) { + root.children = [...root.children, ...orphans] + for (const id of orphans) claimed.add(id) + } +} diff --git a/src/core/collab/nodeY.ts b/src/core/collab/nodeY.ts new file mode 100644 index 000000000..094a36595 --- /dev/null +++ b/src/core/collab/nodeY.ts @@ -0,0 +1,90 @@ +/** + * Plain node ⇄ node Y.Map conversion — the one place that knows how a + * `BaseNode`/`PageNode` maps onto Y types. Used by seeding (whole trees), + * the patch translator (node creation), and the projection (reading back). + * + * Structured fields get collaborative Y types; every OTHER node field is a + * plain LWW value set by key — new node fields flow through automatically: + * props → Y.Map; the module's inline-text prop → Y.Text + * breakpointOverrides → Y.Map> + * children → Y.Array + * parentId → NEVER stored (derived cache; projection reindexes) + */ +import * as Y from 'yjs' +import type { BaseNode } from '@core/page-tree' +import { inlineTextPropOf } from './schema' + +const STRUCTURED_KEYS = new Set(['props', 'breakpointOverrides', 'children', 'parentId']) + +export function buildPropsMap( + moduleId: string, + props: Record, +): Y.Map { + const inlineProp = inlineTextPropOf(moduleId) + const map = new Y.Map() + for (const [key, value] of Object.entries(props)) { + if (key === inlineProp && typeof value === 'string') { + map.set(key, new Y.Text(value)) + } else { + map.set(key, value) + } + } + return map +} + +export function buildBreakpointOverridesMap( + overrides: Record>, +): Y.Map { + const map = new Y.Map() + for (const [bpId, bag] of Object.entries(overrides)) { + const bpMap = new Y.Map() + for (const [key, value] of Object.entries(bag)) bpMap.set(key, value) + map.set(bpId, bpMap) + } + return map +} + +export function buildNodeMap(node: BaseNode): Y.Map { + const map = new Y.Map() + map.set('props', buildPropsMap(node.moduleId, node.props)) + map.set('breakpointOverrides', buildBreakpointOverridesMap(node.breakpointOverrides)) + const children = new Y.Array() + children.push([...node.children]) + map.set('children', children) + for (const [key, value] of Object.entries(node)) { + if (STRUCTURED_KEYS.has(key)) continue + if (value === undefined) continue + map.set(key, value) + } + return map +} + +/** Read a node Y.Map back to a plain node (without `parentId` — reindexed later). */ +export function projectNodeMap(nodeMap: Y.Map): BaseNode { + const out: Record = {} + for (const [key, value] of nodeMap.entries()) { + if (key === 'props') { + const props: Record = {} + for (const [pk, pv] of (value as Y.Map).entries()) { + props[pk] = pv instanceof Y.Text ? pv.toString() : pv + } + out.props = props + } else if (key === 'breakpointOverrides') { + const overrides: Record> = {} + for (const [bp, bag] of (value as Y.Map).entries()) { + overrides[bp] = Object.fromEntries((bag as Y.Map).entries()) + } + out.breakpointOverrides = overrides + } else if (key === 'children') { + out.children = (value as Y.Array).toArray() + } else { + out[key] = value + } + } + // Structured fields always exist on a well-formed node; default defensively + // for docs damaged by partial concurrent construction. + out.props ??= {} + out.breakpointOverrides ??= {} + out.children ??= [] + return out as unknown as BaseNode +} diff --git a/src/core/collab/project.ts b/src/core/collab/project.ts new file mode 100644 index 000000000..6a4e356fd --- /dev/null +++ b/src/core/collab/project.ts @@ -0,0 +1,108 @@ +/** + * Projection — the ONLY reader of collab Y state. Turns a doc back into the + * domain JSON the editor store, the persist layer, and the publisher inputs + * consume. Runs the deterministic integrity/roster reconciles so every peer + * projects an identical, invariant-respecting document from converged state. + */ +import * as Y from 'yjs' +import type { BaseNode, Page, PageNode } from '@core/page-tree' +import { reindexNodeParents } from '@core/page-tree' +import type { VisualComponent } from '@core/visualComponents' +import type { SavedLayout } from '@core/layouts' +import { dataMap, metaMap, rostersMap, shellMap, treeMap } from './schema' +import { projectNodeMap } from './nodeY' +import { reconcileTreeIntegrity } from './integrity' + +function projectTree(doc: Y.Doc): { rootNodeId: string; nodes: Record } { + const t = treeMap(doc) + const rootNodeId = typeof t.get('rootNodeId') === 'string' ? (t.get('rootNodeId') as string) : '' + const nodesMap = t.get('nodes') + const nodes: Record = {} + if (nodesMap instanceof Y.Map) { + for (const [id, nodeMap] of nodesMap.entries()) { + if (nodeMap instanceof Y.Map) nodes[id] = projectNodeMap(nodeMap) + } + } + reconcileTreeIntegrity(nodes, rootNodeId) + reindexNodeParents(nodes) + return { rootNodeId, nodes } +} + +function projectMeta(doc: Y.Doc): Record { + return Object.fromEntries(metaMap(doc).entries()) +} + +export function projectPageDoc(doc: Y.Doc, rowId: string): Page { + const { rootNodeId, nodes } = projectTree(doc) + return { + ...projectMeta(doc), + id: rowId, + rootNodeId, + nodes: nodes as Record, + } as Page +} + +export function projectComponentDoc(doc: Y.Doc, rowId: string): VisualComponent { + const { rootNodeId, nodes } = projectTree(doc) + return { + ...projectMeta(doc), + id: rowId, + tree: { rootNodeId, nodes }, + } as VisualComponent +} + +export function projectLayoutDoc(doc: Y.Doc, rowId: string): SavedLayout { + const meta = projectMeta(doc) + const snapshot = dataMap(doc).get('snapshot') + const snapshotFields = + snapshot && typeof snapshot === 'object' ? (snapshot as Record) : {} + return { + ...snapshotFields, + id: rowId, + name: typeof meta.name === 'string' ? meta.name : '', + createdAt: typeof meta.createdAt === 'number' ? meta.createdAt : 0, + } as SavedLayout +} + +export interface ProjectedSiteDoc { + /** Every shell field the doc holds (name, settings, styleRules, …) — no rows. */ + shell: Record + rosters: { pages: string[]; components: string[]; layouts: string[] } +} + +function rosterIds(rosters: Y.Map, key: string): Set { + const map = rosters.get(key) + return map instanceof Y.Map ? new Set(map.keys()) : new Set() +} + +export function projectSiteDoc(doc: Y.Doc): ProjectedSiteDoc { + const shellSrc = shellMap(doc) + const shell: Record = {} + for (const [key, value] of shellSrc.entries()) { + shell[key] = value instanceof Y.Map ? Object.fromEntries(value.entries()) : value + } + + const rosters = rostersMap(doc) + const pageMembers = rosterIds(rosters, 'pages') + const orderSrc = rosters.get('pageOrder') + const rawOrder = orderSrc instanceof Y.Array ? (orderSrc.toArray() as string[]) : [] + // Roster-order reconcile: dedupe, drop non-members, append missing members + // (sorted for determinism) — mirrors the tree-integrity philosophy. + const seen = new Set() + const pages: string[] = [] + for (const id of rawOrder) { + if (!pageMembers.has(id) || seen.has(id)) continue + seen.add(id) + pages.push(id) + } + pages.push(...[...pageMembers].filter((id) => !seen.has(id)).sort()) + + return { + shell, + rosters: { + pages, + components: [...rosterIds(rosters, 'components')].sort(), + layouts: [...rosterIds(rosters, 'layouts')].sort(), + }, + } +} diff --git a/src/core/collab/schema.ts b/src/core/collab/schema.ts new file mode 100644 index 000000000..2aedd3d86 --- /dev/null +++ b/src/core/collab/schema.ts @@ -0,0 +1,47 @@ +/** + * Y-document layout shared by every collab doc kind, plus transaction + * origins and the inline-text lookup. + * + * Layouts (see docs/plans/2026-07-03-collab-crdt-coediting.md): + * page/component docs → getMap('meta') + getMap('tree') + * tree: { rootNodeId: string, nodes: Y.Map } + * node: { props: Y.Map (inline-text prop as Y.Text), + * breakpointOverrides: Y.Map, children: Y.Array, + * every other node field a plain LWW value; parentId NEVER stored } + * layout docs → getMap('meta') + getMap('data') { snapshot: plain JSON } + * site doc → getMap('shell') + getMap('rosters') + * + * Origins tag every transaction so observers and the UndoManager can tell + * who caused a change: + * LOCAL_ORIGIN — this user's edits (undoable) + * REMOTE_ORIGIN — updates applied from the wire (never undoable) + * RECONCILE_ORIGIN — derived corrections (slot sync, integrity) that run + * identically on every peer (never undoable) + * SEED_ORIGIN — initial construction from persisted JSON + */ +import * as Y from 'yjs' +import { registry } from '@core/module-engine' + +export const LOCAL_ORIGIN = Symbol('collab-local') +export const REMOTE_ORIGIN = 'collab-remote' +export const RECONCILE_ORIGIN = 'collab-reconcile' +export const SEED_ORIGIN = 'collab-seed' + +/** Deterministic clientID for seeding — the server is the only seeder. */ +export const SEED_CLIENT_ID = 1 + +export const treeMap = (doc: Y.Doc): Y.Map => doc.getMap('tree') +export const metaMap = (doc: Y.Doc): Y.Map => doc.getMap('meta') +export const dataMap = (doc: Y.Doc): Y.Map => doc.getMap('data') +export const shellMap = (doc: Y.Doc): Y.Map => doc.getMap('shell') +export const rostersMap = (doc: Y.Doc): Y.Map => doc.getMap('rosters') + +/** + * The one prop of a module stored as Y.Text (character-level merge). + * Resolved from the module registry — callers must have registered modules + * (`import '@modules/base'`); unregistered modules degrade to plain-string + * props, which still merge whole-value LWW. + */ +export function inlineTextPropOf(moduleId: string): string | null { + return registry.get(moduleId)?.inlineTextEdit?.prop ?? null +} diff --git a/src/core/collab/seed.ts b/src/core/collab/seed.ts new file mode 100644 index 000000000..b0c23af5e --- /dev/null +++ b/src/core/collab/seed.ts @@ -0,0 +1,113 @@ +/** + * Doc seeding — deterministic construction of a collab doc from persisted + * domain JSON. The SERVER is the only seeder (fixed `SEED_CLIENT_ID`), so + * two clients can never build divergent initial histories for the same + * content — the classic double-seed duplication pitfall. + * + * Page/VC meta stores authored fields only; server-owned row metadata + * (owner/created-by/updated-by) never enters the doc — the persist layer + * preserves it on the row. + */ +import * as Y from 'yjs' +import type { Page, SiteDocument } from '@core/page-tree' +import type { VisualComponent } from '@core/visualComponents' +import type { SavedLayout } from '@core/layouts' +import { dataMap, metaMap, rostersMap, SEED_CLIENT_ID, SEED_ORIGIN, shellMap, treeMap } from './schema' +import { buildNodeMap } from './nodeY' + +function seeding(doc: Y.Doc, fn: () => void): void { + const previousClientId = doc.clientID + doc.clientID = SEED_CLIENT_ID + try { + doc.transact(fn, SEED_ORIGIN) + } finally { + doc.clientID = previousClientId + } +} + +function seedTree( + doc: Y.Doc, + tree: { rootNodeId: string; nodes: Record }, +): void { + const t = treeMap(doc) + t.set('rootNodeId', tree.rootNodeId) + const nodes = new Y.Map() + t.set('nodes', nodes) + for (const [id, node] of Object.entries(tree.nodes)) { + nodes.set(id, buildNodeMap(node)) + } +} + +/** Page-owned meta fields — everything on `Page` that is not the tree or server-owned. */ +const PAGE_SERVER_OWNED = new Set(['ownerUserId', 'createdByUserId', 'updatedByUserId']) + +export function seedPageDoc(doc: Y.Doc, page: Page): void { + seeding(doc, () => { + const meta = metaMap(doc) + for (const [key, value] of Object.entries(page)) { + if (key === 'nodes' || key === 'rootNodeId' || key === 'id') continue + if (PAGE_SERVER_OWNED.has(key) || value === undefined) continue + meta.set(key, value) + } + seedTree(doc, page) + }) +} + +export function seedComponentDoc(doc: Y.Doc, vc: VisualComponent): void { + seeding(doc, () => { + const meta = metaMap(doc) + for (const [key, value] of Object.entries(vc)) { + if (key === 'tree' || key === 'id' || value === undefined) continue + meta.set(key, value) + } + seedTree(doc, vc.tree) + }) +} + +export function seedLayoutDoc(doc: Y.Doc, layout: SavedLayout): void { + seeding(doc, () => { + const { id: _id, name, createdAt, ...snapshot } = layout + const meta = metaMap(doc) + meta.set('name', name) + meta.set('createdAt', createdAt) + // Whole-snapshot LWW — layouts have save/rename/delete semantics, not + // node-level co-editing. + dataMap(doc).set('snapshot', snapshot) + }) +} + +/** Shell keys stored as per-entry Y.Maps (granular co-editing); the rest are plain LWW values. */ +const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) +const SHELL_SKIPPED_KEYS = new Set(['pages', 'visualComponents', 'layouts', 'id', 'updatedAt']) + +export function seedSiteDoc(doc: Y.Doc, site: SiteDocument): void { + seeding(doc, () => { + const shell = shellMap(doc) + for (const [key, value] of Object.entries(site)) { + if (SHELL_SKIPPED_KEYS.has(key) || value === undefined) continue + if (SHELL_PER_ENTRY_KEYS.has(key)) { + const entryMap = new Y.Map() + for (const [entryKey, entryValue] of Object.entries(value as Record)) { + if (entryValue !== undefined) entryMap.set(entryKey, entryValue) + } + shell.set(key, entryMap) + } else { + shell.set(key, value) + } + } + + const rosters = rostersMap(doc) + const pages = new Y.Map() + for (const page of site.pages) pages.set(page.id, true) + rosters.set('pages', pages) + const order = new Y.Array() + order.push(site.pages.map((p) => p.id)) + rosters.set('pageOrder', order) + const components = new Y.Map() + for (const vc of site.visualComponents) components.set(vc.id, true) + rosters.set('components', components) + const layouts = new Y.Map() + for (const layout of site.layouts) layouts.set(layout.id, true) + rosters.set('layouts', layouts) + }) +} From 4f9ea2bd2553a0d9e140d43a51aadbde1679e722 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:46:18 +0200 Subject: [PATCH 05/49] feat(collab): text splice diff + granular merge scenario coverage Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/merge.test.ts | 143 +++++++++++++++++++++++++++++ src/core/collab/index.ts | 1 + src/core/collab/textDiff.ts | 32 +++++++ 3 files changed, 176 insertions(+) create mode 100644 src/__tests__/collab/merge.test.ts create mode 100644 src/core/collab/textDiff.ts diff --git a/src/__tests__/collab/merge.test.ts b/src/__tests__/collab/merge.test.ts new file mode 100644 index 000000000..dbf006ec9 --- /dev/null +++ b/src/__tests__/collab/merge.test.ts @@ -0,0 +1,143 @@ +/** + * CRDT merge scenarios — two docs exchanging updates must converge with the + * GRANULARITY the product promises: different nodes never collide, different + * props of one node never collide, and the inline-text prop merges + * character-level. `applyTextDiff` is the minimal-splice bridge between + * contentEditable string snapshots and Y.Text operations. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { applyTextDiff, projectPageDoc, seedPageDoc, treeMap } from '@core/collab' +import { makeNode, makePage } from '../fixtures' + +function fixturePage() { + return makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello world', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }) +} + +/** Seed once, clone into a second doc — the shared-history starting point. */ +function seededPair(): [Y.Doc, Y.Doc] { + const a = new Y.Doc() + seedPageDoc(a, fixturePage()) + const b = new Y.Doc() + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) + return [a, b] +} + +function syncDocs(a: Y.Doc, b: Y.Doc): void { + Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b))) + Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a))) +} + +function nodeMap(doc: Y.Doc, id: string): Y.Map { + return (treeMap(doc).get('nodes') as Y.Map).get(id) as Y.Map +} + +describe('granular merge', () => { + it('concurrent edits to different props of the same node both survive', () => { + const [a, b] = seededPair() + ;(nodeMap(a, 't1').get('props') as Y.Map).set('tag', 'h1') + ;(nodeMap(b, 't1').get('props') as Y.Map).set('align', 'center') + syncDocs(a, b) + for (const doc of [a, b]) { + const projected = projectPageDoc(doc, 'p1') + expect(projected.nodes.t1.props.tag).toBe('h1') + expect(projected.nodes.t1.props.align).toBe('center') + } + }) + + it('concurrent Y.Text typing merges character-level and identically on both peers', () => { + const [a, b] = seededPair() + const textA = (nodeMap(a, 't1').get('props') as Y.Map).get('text') as Y.Text + const textB = (nodeMap(b, 't1').get('props') as Y.Map).get('text') as Y.Text + textA.insert(0, 'A: ') // prepend on peer A + textB.insert(textB.length, ' :B') // append on peer B + syncDocs(a, b) + const merged = textA.toString() + expect(merged).toContain('A: ') + expect(merged).toContain(' :B') + expect(merged).toContain('hello world') + expect(textB.toString()).toBe(merged) + }) + + it('concurrent child insert + child delete converge and reconcile identically', () => { + const [a, b] = seededPair() + a.transact(() => { + const nodes = treeMap(a).get('nodes') as Y.Map + const fresh = new Y.Map() + fresh.set('id', 'n2') + fresh.set('moduleId', 'base.container') + fresh.set('props', new Y.Map()) + fresh.set('breakpointOverrides', new Y.Map()) + fresh.set('children', new Y.Array()) + nodes.set('n2', fresh) + const rootChildren = (nodes.get('root') as Y.Map).get('children') as Y.Array + rootChildren.insert(rootChildren.length, ['n2']) + }) + b.transact(() => { + const nodes = treeMap(b).get('nodes') as Y.Map + nodes.delete('c1') + const rootChildren = (nodes.get('root') as Y.Map).get('children') as Y.Array + rootChildren.delete(1, 1) // remove 'c1' + }) + syncDocs(a, b) + const pa = projectPageDoc(a, 'p1') + const pb = projectPageDoc(b, 'p1') + expect(pa.nodes.root.children).toEqual(pb.nodes.root.children) + expect(pa.nodes.root.children).toContain('n2') + expect(pa.nodes.root.children).not.toContain('c1') + }) +}) + +describe('applyTextDiff', () => { + it('applies a minimal middle splice', () => { + const t = new Y.Doc().getText('t') + t.insert(0, 'hello world') + applyTextDiff(t, 'hello world', 'hello brave world') + expect(t.toString()).toBe('hello brave world') + }) + + it('handles pure insertion, pure deletion, and full replacement', () => { + const t = new Y.Doc().getText('t') + t.insert(0, 'abc') + applyTextDiff(t, 'abc', 'abXc') + expect(t.toString()).toBe('abXc') + applyTextDiff(t, 'abXc', 'ac') + expect(t.toString()).toBe('ac') + applyTextDiff(t, 'ac', 'zz') + expect(t.toString()).toBe('zz') + }) + + it('no-ops on identical strings (no spurious Y operations)', () => { + const doc = new Y.Doc() + const t = doc.getText('t') + t.insert(0, 'same') + const before = Y.encodeStateVector(doc) + applyTextDiff(t, 'same', 'same') + expect(Y.encodeStateVector(doc)).toEqual(before) + }) + + it('keeps a concurrent remote insertion when splicing (the granularity proof)', () => { + const a = new Y.Doc() + a.getText('t').insert(0, 'hello world') + const b = new Y.Doc() + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) + // A splices via diff (panel edit), B types at the end concurrently. + applyTextDiff(a.getText('t'), 'hello world', 'hello brave world') + b.getText('t').insert(11, '!!') + syncDocs(a, b) + const merged = a.getText('t').toString() + expect(merged).toContain('brave') + expect(merged).toContain('!!') + expect(b.getText('t').toString()).toBe(merged) + }) +}) diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index ebf4876ac..af186fadb 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -29,6 +29,7 @@ export { } from './schema' export { buildNodeMap, buildPropsMap, projectNodeMap } from './nodeY' export { reconcileTreeIntegrity } from './integrity' +export { applyTextDiff } from './textDiff' export { seedComponentDoc, seedLayoutDoc, seedPageDoc, seedSiteDoc } from './seed' export { projectComponentDoc, diff --git a/src/core/collab/textDiff.ts b/src/core/collab/textDiff.ts new file mode 100644 index 000000000..51f0e8c50 --- /dev/null +++ b/src/core/collab/textDiff.ts @@ -0,0 +1,32 @@ +/** + * Minimal-splice bridge between string snapshots and Y.Text operations. + * + * The editor's text surfaces (contentEditable inline editing, the + * properties-panel textarea) produce whole-string snapshots per input event. + * Replacing the whole Y.Text would destroy concurrent remote edits; instead + * the common prefix/suffix is preserved and only the changed middle is + * spliced — so a remote insertion outside the splice survives the merge. + */ +import type * as Y from 'yjs' + +export function applyTextDiff(text: Y.Text, oldValue: string, newValue: string): void { + if (oldValue === newValue) return + + let prefix = 0 + const maxPrefix = Math.min(oldValue.length, newValue.length) + while (prefix < maxPrefix && oldValue[prefix] === newValue[prefix]) prefix++ + + let suffix = 0 + const maxSuffix = Math.min(oldValue.length, newValue.length) - prefix + while ( + suffix < maxSuffix && + oldValue[oldValue.length - 1 - suffix] === newValue[newValue.length - 1 - suffix] + ) { + suffix++ + } + + const deleteCount = oldValue.length - prefix - suffix + if (deleteCount > 0) text.delete(prefix, deleteCount) + const inserted = newValue.slice(prefix, newValue.length - suffix) + if (inserted.length > 0) text.insert(prefix, inserted) +} From 7a58e75bc5ee382765b983f6d1ece7603973916b Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:50:34 +0200 Subject: [PATCH 06/49] =?UTF-8?q?feat(collab):=20mutative-patch=20?= =?UTF-8?q?=E2=86=92=20Y=20translation=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches identify targets; values write from the post-mutation site (idempotent final-state writes). Inline-text props splice Y.Text, children arrays splice via prefix/suffix diff, rosters derive from pre/post id-set diffs — the granular-merge substrate for co-editing. Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/applyPatches.test.ts | 207 +++++++++ src/core/collab/applyPatches.ts | 492 ++++++++++++++++++++++ src/core/collab/docSet.ts | 34 ++ src/core/collab/index.ts | 12 +- src/core/collab/seed.ts | 64 +-- 5 files changed, 782 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/collab/applyPatches.test.ts create mode 100644 src/core/collab/applyPatches.ts create mode 100644 src/core/collab/docSet.ts diff --git a/src/__tests__/collab/applyPatches.test.ts b/src/__tests__/collab/applyPatches.test.ts new file mode 100644 index 000000000..64cd1d049 --- /dev/null +++ b/src/__tests__/collab/applyPatches.test.ts @@ -0,0 +1,207 @@ +/** + * Patch→Y translation — the collaborative write path. + * + * The editor keeps producing Mutative patches (same recipes, same mutation + * API); `applySitePatchesToDocs` translates them into Y operations on the + * right documents. The invariant under test: after translating a mutation's + * patches, PROJECTING the docs reproduces the post-mutation site — while + * inline-text edits go through minimal Y.Text splices (so concurrent remote + * edits survive) and roster ops land in the site doc. + * + * Tests drive REAL patches: `create(site, recipe, { enablePatches })` with + * actual @core/page-tree mutations, exactly like the store's mutation engine. + */ +import { describe, expect, it } from 'bun:test' +import { create, type Patches } from 'mutative' +import * as Y from 'yjs' +import '@modules/base' +import type { SiteDocument } from '@core/page-tree' +import { addPage, deletePage, moveNode, renamePage, updateNodeProps } from '@core/page-tree' +import { + applySitePatchesToDocs, + createCollabDocSet, + LOCAL_ORIGIN, + projectPageDoc, + projectSiteDoc, + seedPageDoc, + seedSiteDoc, + treeMap, + type CollabDocSet, +} from '@core/collab' +import { makeNode, makePage, makeSite } from '../fixtures' + +function fixtureSite(): SiteDocument { + return makeSite({ + pages: [ + makePage({ + id: 'p1', + slug: 'index', + title: 'Home', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1', 'c1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello world', tag: 'p' } }), + c1: makeNode({ id: 'c1', moduleId: 'base.container', children: [] }), + }, + }), + makePage({ id: 'p2', slug: 'about', title: 'About' }), + ], + styleRules: { + r1: { + id: 'r1', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'var(--text)' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) +} + +/** Seed a doc set exactly like the server would, then hand it to the translator. */ +function seededDocSet(site: SiteDocument): CollabDocSet { + const docs = createCollabDocSet() + seedSiteDoc(docs.ensure('site:default'), site) + for (const page of site.pages) seedPageDoc(docs.ensure(`page:${page.id}`), page) + return docs +} + +function mutate( + site: SiteDocument, + recipe: (draft: SiteDocument) => void, +): { next: SiteDocument; patches: Patches } { + const [next, patches] = create(site, recipe, { enablePatches: true }) + return { next, patches } +} + +function translate(site: SiteDocument, docs: CollabDocSet, recipe: (d: SiteDocument) => void): SiteDocument { + const { next, patches } = mutate(site, recipe) + applySitePatchesToDocs(patches, site, next, docs, LOCAL_ORIGIN) + return next +} + +describe('applySitePatchesToDocs — page tree edits', () => { + it('prop set projects back identical to the post-mutation page', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const next = translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { tag: 'h2' }) + }) + const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + expect(projected.nodes.t1.props.tag).toBe('h2') + expect(projected.nodes.t1.props.text).toBe('hello world') + expect(projected.nodes.root.children).toEqual(next.pages[0].nodes.root.children) + }) + + it('inline-text edit splices Y.Text so a concurrent remote insertion survives', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const local = docs.ensure('page:p1') + // Remote peer shares history and types at the end concurrently. + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local)) + const remoteText = ( + (treeMap(remote).get('nodes') as Y.Map).get('t1') as Y.Map + ).get('props') as Y.Map + ;(remoteText.get('text') as Y.Text).insert(11, ' [remote]') + + translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { text: 'hello brave world' }) + }) + + // Exchange updates both ways. + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + Y.applyUpdate(local, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(local))) + + const merged = projectPageDoc(local, 'p1').nodes.t1.props.text as string + expect(merged).toContain('brave') + expect(merged).toContain('[remote]') + expect(projectPageDoc(remote, 'p1').nodes.t1.props.text).toBe(merged) + }) + + it('node move and delete project back identical children', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const next = translate(site, docs, (d) => { + moveNode(d.pages[0], 'c1', 'root', 0) // move c1 before t1 + }) + expect(projectPageDoc(docs.ensure('page:p1'), 'p1').nodes.root.children) + .toEqual(next.pages[0].nodes.root.children) + + const next2 = translate(next, docs, (d) => { + // deleteNode is tree-level; page IS a NodeTree + d.pages[0].nodes.root.children = d.pages[0].nodes.root.children.filter((c) => c !== 'c1') + delete d.pages[0].nodes.c1 + }) + const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + expect(projected.nodes.c1).toBeUndefined() + expect(projected.nodes.root.children).toEqual(next2.pages[0].nodes.root.children) + }) + + it('page rename lands in the page meta', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + renamePage(d, 'p1', 'Homepage', 'index') + }) + expect(projectPageDoc(docs.ensure('page:p1'), 'p1').title).toBe('Homepage') + }) +}) + +describe('applySitePatchesToDocs — rosters', () => { + it('addPage creates the roster entry AND populates a fresh page doc', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + let newId = '' + translate(site, docs, (d) => { + newId = addPage(d, 'Fresh', 'fresh').id + }) + const projectedSite = projectSiteDoc(docs.ensure('site:default')) + expect(projectedSite.rosters.pages).toContain(newId) + const projectedPage = projectPageDoc(docs.ensure(`page:${newId}`), newId) + expect(projectedPage.title).toBe('Fresh') + expect(Object.keys(projectedPage.nodes).length).toBeGreaterThan(0) + }) + + it('deletePage removes the roster entry (the doc is left for relay GC)', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + deletePage(d, 'p2') + }) + const projected = projectSiteDoc(docs.ensure('site:default')) + expect(projected.rosters.pages).not.toContain('p2') + expect(projected.rosters.pages).toContain('p1') + }) + + it('page reorder rewrites pageOrder', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + const [a, b] = d.pages + d.pages[0] = b + d.pages[1] = a + }) + expect(projectSiteDoc(docs.ensure('site:default')).rosters.pages).toEqual(['p2', 'p1']) + }) +}) + +describe('applySitePatchesToDocs — shell', () => { + it('style rule edits land per-rule; settings edits per top-level key', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + d.styleRules.r1.styles.color = 'var(--text-muted)' + d.settings.metaTitle = 'Acme rules' + }) + const projected = projectSiteDoc(docs.ensure('site:default')) + const rule = projected.shell.styleRules as Record }> + expect(rule.r1.styles.color).toBe('var(--text-muted)') + expect((projected.shell.settings as Record).metaTitle).toBe('Acme rules') + }) + + it('site rename is a plain shell field write', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + translate(site, docs, (d) => { + d.name = 'Renamed' + }) + expect(projectSiteDoc(docs.ensure('site:default')).shell.name).toBe('Renamed') + }) +}) diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts new file mode 100644 index 000000000..c7bb15434 --- /dev/null +++ b/src/core/collab/applyPatches.ts @@ -0,0 +1,492 @@ +/** + * Mutative-patch → Y translation — the collaborative WRITE path. + * + * The editor's mutation engine keeps producing site-relative Mutative + * patches (unchanged recipes, unchanged mutation API); this module turns one + * mutation's patch set into Y operations on the right documents, inside one + * transaction per touched doc, tagged with the caller's origin. + * + * Translation philosophy: patches identify TARGETS; values are written from + * the post-mutation site (`nextSite`) — final-state writes are idempotent + * under duplicate patches and immune to op-ordering subtleties. Exceptions + * where granularity matters: + * - inline-text props go through `applyTextDiff` (minimal Y.Text splices, + * so concurrent remote edits survive), + * - children arrays go through a prefix/suffix array diff (targeted + * inserts/deletes, so concurrent child insertions survive), + * - roster membership is derived from a pre/post id-set diff (the same + * robust-to-recipe-style approach as save-dirty tracking). + * + * Anything unattributable falls back to repopulating the affected doc from + * `nextSite` — the conservative escape hatch, mirroring dirty-tracking's + * `all` rule (over-writing is safe; silently dropping an edit is not). + */ +import * as Y from 'yjs' +import type { Patches } from 'mutative' +import type { BaseNode, Page, SiteDocument } from '@core/page-tree' +import type { VisualComponent } from '@core/visualComponents' +import type { SavedLayout } from '@core/layouts' +import { dataMap, inlineTextPropOf, metaMap, rostersMap, shellMap, treeMap } from './schema' +import { buildBreakpointOverridesMap, buildNodeMap, buildPropsMap } from './nodeY' +import { populateComponentDoc, populateLayoutDoc, populatePageDoc } from './seed' +import { applyTextDiff } from './textDiff' +import type { CollabDocSet } from './docSet' + +type Collection = 'pages' | 'visualComponents' | 'layouts' +const COLLECTIONS: readonly Collection[] = ['pages', 'visualComponents', 'layouts'] +const COLLECTION_KIND: Record = { + pages: 'page', + visualComponents: 'component', + layouts: 'layout', +} +const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) +const SHELL_SKIPPED_KEYS = new Set(['updatedAt', 'id']) + +type Row = Page | VisualComponent | SavedLayout + +function rowsOf(site: SiteDocument, col: Collection): readonly Row[] { + return site[col] +} + +function rowsById(site: SiteDocument, col: Collection): Map { + return new Map(rowsOf(site, col).map((row) => [row.id, row])) +} + +/** Same predicate as dirty tracking: only ops at collection depth ≤ 2, a + * `length` bookkeeping op, or any remove can change membership/order. */ +function isMembershipShapedOp(patch: Patches[number]): boolean { + return patch.path.length <= 2 +} + +function clearMap(map: Y.Map): void { + for (const key of [...map.keys()]) map.delete(key) +} + +function clearRowDoc(doc: Y.Doc): void { + clearMap(metaMap(doc)) + clearMap(treeMap(doc)) + clearMap(dataMap(doc)) +} + +function repopulateRowDoc(doc: Y.Doc, kind: 'page' | 'component' | 'layout', row: Row): void { + clearRowDoc(doc) + if (kind === 'page') populatePageDoc(doc, row as Page) + else if (kind === 'component') populateComponentDoc(doc, row as VisualComponent) + else populateLayoutDoc(doc, row as SavedLayout) +} + +/** Prefix/suffix splice of a Y.Array toward `next` — preserves + * concurrent remote insertions outside the changed window. */ +function applyChildrenDiff(arr: Y.Array, pre: readonly string[], next: readonly string[]): void { + let prefix = 0 + const maxPrefix = Math.min(pre.length, next.length) + while (prefix < maxPrefix && pre[prefix] === next[prefix]) prefix++ + let suffix = 0 + const maxSuffix = Math.min(pre.length, next.length) - prefix + while (suffix < maxSuffix && pre[pre.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix++ + const deleteCount = pre.length - prefix - suffix + if (deleteCount > 0) arr.delete(prefix, deleteCount) + const inserted = next.slice(prefix, next.length - suffix) + if (inserted.length > 0) arr.insert(prefix, [...inserted]) +} + +// --------------------------------------------------------------------------- +// Per-row target application (pages + components share the tree writer) +// --------------------------------------------------------------------------- + +interface TreeShapes { + nodes: Record + rootNodeId: string +} + +function treeOf(kind: 'page' | 'component', row: Row): TreeShapes { + return kind === 'page' + ? (row as Page) + : (row as VisualComponent).tree +} + +/** + * Apply one row's collected sub-paths (relative to the row) by writing the + * affected targets from the FINAL row state. `preRow` feeds the text/children + * diffs. Runs inside the row doc's transaction. + */ +function applyRowTargets( + doc: Y.Doc, + kind: 'page' | 'component', + subPaths: readonly (readonly (string | number)[])[], + preRow: Row | undefined, + nextRow: Row, +): void { + const tree = treeMap(doc) + const meta = metaMap(doc) + const nextTree = treeOf(kind, nextRow) + const preTree = preRow ? treeOf(kind, preRow) : undefined + const yNodes = tree.get('nodes') as Y.Map | undefined + if (!yNodes) { + repopulateRowDoc(doc, kind, nextRow) + return + } + + // Collected targets — dedupe so the final state is written exactly once. + const wholeNodes = new Set() // set/delete whole node + const childrenDiff = new Set() + const propKeys = new Map>() // nodeId → prop keys + const bpTargets = new Map>() // nodeId → bp ids ('' = whole map) + const scalarFields = new Map>() // nodeId → field names + const metaKeys = new Set() + let rebuildAllNodes = false + let rootNodeIdChanged = false + + for (const sub of subPaths) { + // Normalize component paths: the row nests its tree under `tree`. + let path = sub + if (kind === 'component' && path[0] === 'tree') path = path.slice(1) + else if (kind === 'component' && path.length > 0 && path[0] !== 'tree') { + metaKeys.add(String(path[0])) + continue + } + + const head = path[0] + if (head === undefined) { + rebuildAllNodes = true + metaKeys.add('*') + continue + } + if (head === 'rootNodeId') { + rootNodeIdChanged = true + continue + } + if (head !== 'nodes') { + if (kind === 'page') metaKeys.add(String(head)) + continue + } + const nodeId = path[1] + if (nodeId === undefined) { + rebuildAllNodes = true + continue + } + if (typeof nodeId !== 'string') { + rebuildAllNodes = true + continue + } + const field = path[2] + if (field === undefined) { + wholeNodes.add(nodeId) + continue + } + if (field === 'parentId') continue // derived — never stored + if (field === 'children') { + childrenDiff.add(nodeId) + } else if (field === 'props') { + const key = path[3] + if (key === undefined) { + scalarFields.get(nodeId) // no-op read for lint symmetry + propKeys.set(nodeId, propKeys.get(nodeId) ?? new Set()) + propKeys.get(nodeId)!.add('*') + } else { + propKeys.set(nodeId, propKeys.get(nodeId) ?? new Set()) + propKeys.get(nodeId)!.add(String(key)) + } + } else if (field === 'breakpointOverrides') { + const bp = path[3] + bpTargets.set(nodeId, bpTargets.get(nodeId) ?? new Set()) + bpTargets.get(nodeId)!.add(bp === undefined ? '*' : String(bp)) + } else { + scalarFields.set(nodeId, scalarFields.get(nodeId) ?? new Set()) + scalarFields.get(nodeId)!.add(String(field)) + } + } + + if (rebuildAllNodes) { + repopulateRowDoc(doc, kind, nextRow) + return + } + + // Meta (page: row-level extra keys; component: non-tree keys). + if (metaKeys.has('*')) { + clearMap(meta) + const metaSource: Record = { ...nextRow } + delete metaSource.id + if (kind === 'page') { + delete metaSource.nodes + delete metaSource.rootNodeId + } else { + delete (metaSource as { tree?: unknown }).tree + } + for (const [key, value] of Object.entries(metaSource)) { + if (value !== undefined) meta.set(key, value) + } + } else { + for (const key of metaKeys) { + const value = (nextRow as unknown as Record)[key] + if (value === undefined) meta.delete(key) + else meta.set(key, value) + } + } + + if (rootNodeIdChanged) tree.set('rootNodeId', nextTree.rootNodeId) + + for (const nodeId of wholeNodes) { + const nextNode = nextTree.nodes[nodeId] + if (!nextNode) { + yNodes.delete(nodeId) + continue + } + const preNode = preTree?.nodes[nodeId] + if (preNode && yNodes.has(nodeId)) { + // Whole-node replace of an EXISTING node (paste-over, template ops): + // repopulate that node only. + yNodes.set(nodeId, buildNodeMap(nextNode)) + } else { + yNodes.set(nodeId, buildNodeMap(nextNode)) + } + } + + const nodeYMap = (nodeId: string): Y.Map | undefined => { + const value = yNodes.get(nodeId) + return value instanceof Y.Map ? value : undefined + } + + for (const nodeId of childrenDiff) { + if (wholeNodes.has(nodeId)) continue // already rebuilt wholesale + const yNode = nodeYMap(nodeId) + const nextNode = nextTree.nodes[nodeId] + if (!yNode || !nextNode) continue + const arr = yNode.get('children') as Y.Array + const pre = preTree?.nodes[nodeId]?.children ?? arr.toArray() + applyChildrenDiff(arr, pre, nextNode.children) + } + + for (const [nodeId, keys] of propKeys) { + if (wholeNodes.has(nodeId)) continue + const yNode = nodeYMap(nodeId) + const nextNode = nextTree.nodes[nodeId] + if (!yNode || !nextNode) continue + if (keys.has('*')) { + yNode.set('props', buildPropsMap(nextNode.moduleId, nextNode.props)) + continue + } + const props = yNode.get('props') as Y.Map + const inlineProp = inlineTextPropOf(nextNode.moduleId) + for (const key of keys) { + const nextValue = nextNode.props[key] + if (nextValue === undefined) { + props.delete(key) + continue + } + if (key === inlineProp && typeof nextValue === 'string') { + const existing = props.get(key) + if (existing instanceof Y.Text) { + const preValue = preTree?.nodes[nodeId]?.props[key] + applyTextDiff(existing, typeof preValue === 'string' ? preValue : existing.toString(), nextValue) + } else { + props.set(key, new Y.Text(nextValue)) + } + } else { + props.set(key, nextValue) + } + } + } + + for (const [nodeId, bps] of bpTargets) { + if (wholeNodes.has(nodeId)) continue + const yNode = nodeYMap(nodeId) + const nextNode = nextTree.nodes[nodeId] + if (!yNode || !nextNode) continue + if (bps.has('*')) { + yNode.set('breakpointOverrides', buildBreakpointOverridesMap(nextNode.breakpointOverrides)) + continue + } + const overrides = yNode.get('breakpointOverrides') as Y.Map + for (const bp of bps) { + const nextBag = nextNode.breakpointOverrides[bp] + if (nextBag === undefined) { + overrides.delete(bp) + continue + } + const bpMap = new Y.Map() + for (const [key, value] of Object.entries(nextBag)) bpMap.set(key, value) + overrides.set(bp, bpMap) + } + } + + for (const [nodeId, fields] of scalarFields) { + if (wholeNodes.has(nodeId)) continue + const yNode = nodeYMap(nodeId) + const nextNode = nextTree.nodes[nodeId] as unknown as Record | undefined + if (!yNode || !nextNode) continue + for (const field of fields) { + const value = nextNode[field] + if (value === undefined) yNode.delete(field) + else yNode.set(field, value) + } + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export function applySitePatchesToDocs( + patches: Patches, + preSite: SiteDocument, + nextSite: SiteDocument, + docs: CollabDocSet, + origin: unknown, +): void { + const shellHeads = new Set() + const shellEntryTargets = new Map>() // head → entry keys ('*' = whole) + const collectionPatches = new Map() + + for (const patch of patches) { + const head = String(patch.path[0]) + if (COLLECTIONS.includes(head as Collection)) { + const col = head as Collection + collectionPatches.set(col, collectionPatches.get(col) ?? []) + collectionPatches.get(col)!.push(patch) + continue + } + if (SHELL_SKIPPED_KEYS.has(head)) continue + if (SHELL_PER_ENTRY_KEYS.has(head)) { + shellEntryTargets.set(head, shellEntryTargets.get(head) ?? new Set()) + shellEntryTargets.get(head)!.add(patch.path.length === 1 ? '*' : String(patch.path[1])) + } else { + shellHeads.add(head) + } + } + + // ── Shell + rosters (the site doc) ──────────────────────────────────────── + const rosterWork: Array<() => void> = [] + const collectionsWithMembershipOps: Collection[] = [] + for (const [col, colPatches] of collectionPatches) { + if (colPatches.some(isMembershipShapedOp)) collectionsWithMembershipOps.push(col) + } + + if (shellHeads.size > 0 || shellEntryTargets.size > 0 || collectionsWithMembershipOps.length > 0) { + const siteDoc = docs.ensure('site:default') + siteDoc.transact(() => { + const shell = shellMap(siteDoc) + for (const head of shellHeads) { + const value = (nextSite as unknown as Record)[head] + if (value === undefined) shell.delete(head) + else shell.set(head, value) + } + for (const [head, targets] of shellEntryTargets) { + const nextEntries = (nextSite as unknown as Record>)[head] ?? {} + let entryMap = shell.get(head) + if (!(entryMap instanceof Y.Map)) { + entryMap = new Y.Map() + shell.set(head, entryMap) + } + const map = entryMap as Y.Map + if (targets.has('*')) { + clearMap(map) + for (const [key, value] of Object.entries(nextEntries)) { + if (value !== undefined) map.set(key, value) + } + continue + } + for (const key of targets) { + const value = nextEntries[key] + if (value === undefined) map.delete(key) + else map.set(key, value) + } + } + + const rosters = rostersMap(siteDoc) + for (const col of collectionsWithMembershipOps) { + const rosterKey = col === 'pages' ? 'pages' : col === 'visualComponents' ? 'components' : 'layouts' + let membership = rosters.get(rosterKey) + if (!(membership instanceof Y.Map)) { + membership = new Y.Map() + rosters.set(rosterKey, membership) + } + const memberMap = membership as Y.Map + const preIds = new Set(rowsOf(preSite, col).map((r) => r.id)) + const nextById = rowsById(nextSite, col) + for (const id of nextById.keys()) { + if (!preIds.has(id)) { + memberMap.set(id, true) + // Client-created row: populate a fresh doc (single author — safe). + const kind = COLLECTION_KIND[col] + rosterWork.push(() => { + const rowDoc = docs.ensure(`${kind}:${id}`) + rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, nextById.get(id)!), origin) + }) + } + } + for (const id of preIds) { + if (!nextById.has(id)) memberMap.delete(id) + } + if (col === 'pages') { + let order = rosters.get('pageOrder') + if (!(order instanceof Y.Array)) { + order = new Y.Array() + rosters.set('pageOrder', order) + } + applyChildrenDiff( + order as Y.Array, + preSite.pages.map((p) => p.id), + nextSite.pages.map((p) => p.id), + ) + } + } + }, origin) + } + for (const work of rosterWork) work() + + // ── Per-row content ─────────────────────────────────────────────────────── + for (const [col, colPatches] of collectionPatches) { + const kind = COLLECTION_KIND[col] + const preById = rowsById(preSite, col) + const nextById = rowsById(nextSite, col) + + // Wholesale collection replacement (imports) → repopulate every row doc. + if (colPatches.some((p) => p.path.length === 1)) { + for (const [id, row] of nextById) { + const rowDoc = docs.ensure(`${kind}:${id}`) + rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row), origin) + } + continue + } + + // Whole-row replaces at depth 2: repopulate rows whose object identity + // changed (a reorder moves the SAME objects — skipped by the ref check). + const rowSubPaths = new Map() + for (const patch of colPatches) { + const index = patch.path[1] + if (typeof index !== 'number') continue + const rest = patch.path.slice(2) + const row = patch.op === 'remove' && rest.length === 0 + ? undefined // row removal — handled by the roster diff above + : nextSite[col][index] + if (!row) continue + const id = (row as Row).id + if (rest.length === 0) { + if (preById.get(id) !== nextById.get(id)) { + const rowDoc = docs.ensure(`${kind}:${id}`) + rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row as Row), origin) + } + continue + } + if (!preById.has(id)) continue // freshly created — already populated + rowSubPaths.set(id, rowSubPaths.get(id) ?? []) + rowSubPaths.get(id)!.push(rest) + } + + for (const [id, subPaths] of rowSubPaths) { + const nextRow = nextById.get(id) + if (!nextRow) continue + const rowDoc = docs.ensure(`${kind}:${id}`) + if (kind === 'layout') { + // Whole-snapshot LWW — any layout content change rewrites the snapshot. + rowDoc.transact(() => repopulateRowDoc(rowDoc, 'layout', nextRow), origin) + continue + } + rowDoc.transact( + () => applyRowTargets(rowDoc, kind, subPaths, preById.get(id), nextRow), + origin, + ) + } + } +} diff --git a/src/core/collab/docSet.ts b/src/core/collab/docSet.ts new file mode 100644 index 000000000..23f5a000c --- /dev/null +++ b/src/core/collab/docSet.ts @@ -0,0 +1,34 @@ +/** + * CollabDocSet — the doc registry handed to the patch translator. The store + * layer (client) and tests own the instances; `ensure` lazily creates a doc + * for client-created rows (brand-new content — single author, no double-seed + * risk; see seed.ts). + */ +import * as Y from 'yjs' + +export interface CollabDocSet { + get(docId: string): Y.Doc | undefined + ensure(docId: string): Y.Doc + delete(docId: string): void + entries(): IterableIterator<[string, Y.Doc]> +} + +export function createCollabDocSet(): CollabDocSet { + const docs = new Map() + return { + get: (docId) => docs.get(docId), + ensure: (docId) => { + let doc = docs.get(docId) + if (!doc) { + doc = new Y.Doc() + docs.set(docId, doc) + } + return doc + }, + delete: (docId) => { + docs.get(docId)?.destroy() + docs.delete(docId) + }, + entries: () => docs.entries(), + } +} diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index af186fadb..6cf1ed2f3 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -30,7 +30,17 @@ export { export { buildNodeMap, buildPropsMap, projectNodeMap } from './nodeY' export { reconcileTreeIntegrity } from './integrity' export { applyTextDiff } from './textDiff' -export { seedComponentDoc, seedLayoutDoc, seedPageDoc, seedSiteDoc } from './seed' +export { createCollabDocSet, type CollabDocSet } from './docSet' +export { applySitePatchesToDocs } from './applyPatches' +export { + populateComponentDoc, + populateLayoutDoc, + populatePageDoc, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, +} from './seed' export { projectComponentDoc, projectLayoutDoc, diff --git a/src/core/collab/seed.ts b/src/core/collab/seed.ts index b0c23af5e..11d380d22 100644 --- a/src/core/collab/seed.ts +++ b/src/core/collab/seed.ts @@ -41,39 +41,51 @@ function seedTree( /** Page-owned meta fields — everything on `Page` that is not the tree or server-owned. */ const PAGE_SERVER_OWNED = new Set(['ownerUserId', 'createdByUserId', 'updatedByUserId']) +/** + * populate* — write a row's full content into its doc WITHOUT owning the + * transaction. Two callers: the server seeder below (SEED origin, fixed + * clientID) and the patch translator (client-created rows, LOCAL origin — + * brand-new content has exactly one author, so no double-seed risk). + */ +export function populatePageDoc(doc: Y.Doc, page: Page): void { + const meta = metaMap(doc) + for (const [key, value] of Object.entries(page)) { + if (key === 'nodes' || key === 'rootNodeId' || key === 'id') continue + if (PAGE_SERVER_OWNED.has(key) || value === undefined) continue + meta.set(key, value) + } + seedTree(doc, page) +} + +export function populateComponentDoc(doc: Y.Doc, vc: VisualComponent): void { + const meta = metaMap(doc) + for (const [key, value] of Object.entries(vc)) { + if (key === 'tree' || key === 'id' || value === undefined) continue + meta.set(key, value) + } + seedTree(doc, vc.tree) +} + +export function populateLayoutDoc(doc: Y.Doc, layout: SavedLayout): void { + const { id: _id, name, createdAt, ...snapshot } = layout + const meta = metaMap(doc) + meta.set('name', name) + meta.set('createdAt', createdAt) + // Whole-snapshot LWW — layouts have save/rename/delete semantics, not + // node-level co-editing. + dataMap(doc).set('snapshot', snapshot) +} + export function seedPageDoc(doc: Y.Doc, page: Page): void { - seeding(doc, () => { - const meta = metaMap(doc) - for (const [key, value] of Object.entries(page)) { - if (key === 'nodes' || key === 'rootNodeId' || key === 'id') continue - if (PAGE_SERVER_OWNED.has(key) || value === undefined) continue - meta.set(key, value) - } - seedTree(doc, page) - }) + seeding(doc, () => populatePageDoc(doc, page)) } export function seedComponentDoc(doc: Y.Doc, vc: VisualComponent): void { - seeding(doc, () => { - const meta = metaMap(doc) - for (const [key, value] of Object.entries(vc)) { - if (key === 'tree' || key === 'id' || value === undefined) continue - meta.set(key, value) - } - seedTree(doc, vc.tree) - }) + seeding(doc, () => populateComponentDoc(doc, vc)) } export function seedLayoutDoc(doc: Y.Doc, layout: SavedLayout): void { - seeding(doc, () => { - const { id: _id, name, createdAt, ...snapshot } = layout - const meta = metaMap(doc) - meta.set('name', name) - meta.set('createdAt', createdAt) - // Whole-snapshot LWW — layouts have save/rename/delete semantics, not - // node-level co-editing. - dataMap(doc).set('snapshot', snapshot) - }) + seeding(doc, () => populateLayoutDoc(doc, layout)) } /** Shell keys stored as per-entry Y.Maps (granular co-editing); the rest are plain LWW values. */ From b24a08a4c4f2b5b23e04bc137079b8ad998175b9 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:52:20 +0200 Subject: [PATCH 07/49] feat(collab): collab_documents storage (additive migration, both dialects) Co-Authored-By: Claude Fable 5 --- server/db/migrations-pg.ts | 16 +++++++ server/db/migrations-sqlite.ts | 16 +++++++ server/repositories/collabDocuments.ts | 49 ++++++++++++++++++++ src/__tests__/server/collabDocuments.test.ts | 48 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 server/repositories/collabDocuments.ts create mode 100644 src/__tests__/server/collabDocuments.test.ts diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index ba5397ec2..5debea4bd 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1071,4 +1071,20 @@ export const pgMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // Real-time co-editing (Yjs): one CRDT state blob per collab document + // (site shell, page, component, layout — doc_id is ':'). + // The blob is the live-editing source of truth; derived JSON keeps + // flowing into data_rows/site for the publisher and non-editor reads. + // `seq` counts persists (future delta APIs / diagnostics). + id: '021_collab_documents', + sql: ` + create table if not exists collab_documents ( + doc_id text primary key, + state_blob bytea not null, + seq bigint not null default 0, + updated_at timestamptz not null default now() + ); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index f723e3d7a..f235ae31b 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1135,4 +1135,20 @@ export const sqliteMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // Real-time co-editing (Yjs): one CRDT state blob per collab document + // (site shell, page, component, layout — doc_id is ':'). + // The blob is the live-editing source of truth; derived JSON keeps + // flowing into data_rows/site for the publisher and non-editor reads. + // `seq` counts persists (future delta APIs / diagnostics). + id: '021_collab_documents', + sql: ` + create table if not exists collab_documents ( + doc_id text primary key, + state_blob blob not null, + seq integer not null default 0, + updated_at text not null default current_timestamp + ); + `, + }, ] diff --git a/server/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts new file mode 100644 index 000000000..cadd5764a --- /dev/null +++ b/server/repositories/collabDocuments.ts @@ -0,0 +1,49 @@ +/** + * Collab document storage — one Yjs CRDT state blob per collab doc + * (`:`, see @core/collab). The blob is the live-editing source + * of truth; the relay (server/collab) derives JSON into `data_rows` / `site` + * on every persist so the publisher and non-editor reads never touch CRDT + * state. `seq` counts persists (diagnostics + future delta APIs). + */ +import { placeholder, type DbClient } from '../db/client' + +export async function getCollabDocumentState( + db: DbClient, + docId: string, +): Promise { + const { rows } = await db<{ state_blob: Uint8Array }>` + select state_blob from collab_documents + where doc_id = ${docId} + limit 1 + ` + const blob = rows[0]?.state_blob + if (!blob) return null + return blob instanceof Uint8Array ? blob : new Uint8Array(blob) +} + +export async function putCollabDocumentState( + db: DbClient, + docId: string, + state: Uint8Array, +): Promise { + await db` + insert into collab_documents (doc_id, state_blob, seq) + values (${docId}, ${state}, 1) + on conflict (doc_id) do update + set state_blob = excluded.state_blob, + seq = collab_documents.seq + 1, + updated_at = current_timestamp + ` +} + +export async function deleteCollabDocuments( + db: DbClient, + docIds: readonly string[], +): Promise { + if (docIds.length === 0) return + const placeholders = docIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') + await db.unsafe( + `delete from collab_documents where doc_id in (${placeholders})`, + [...docIds], + ) +} diff --git a/src/__tests__/server/collabDocuments.test.ts b/src/__tests__/server/collabDocuments.test.ts new file mode 100644 index 000000000..af7987695 --- /dev/null +++ b/src/__tests__/server/collabDocuments.test.ts @@ -0,0 +1,48 @@ +/** + * collab_documents repository — CRDT state blob round-trips through both the + * upsert path and the delete path on a real migrated database. + */ +import { describe, expect, it } from 'bun:test' +import { + deleteCollabDocuments, + getCollabDocumentState, + putCollabDocumentState, +} from '../../../server/repositories/collabDocuments' +import { createTestDb } from '../helpers/createTestDb' + +describe('collab_documents repository', () => { + it('put → get round-trips bytes and increments seq on upsert', async () => { + const { db, cleanup } = await createTestDb() + try { + expect(await getCollabDocumentState(db, 'page:x')).toBeNull() + + const first = new Uint8Array([1, 2, 3, 250]) + await putCollabDocumentState(db, 'page:x', first) + expect([...(await getCollabDocumentState(db, 'page:x'))!]).toEqual([1, 2, 3, 250]) + + const second = new Uint8Array([9, 9]) + await putCollabDocumentState(db, 'page:x', second) + expect([...(await getCollabDocumentState(db, 'page:x'))!]).toEqual([9, 9]) + + const { rows } = await db<{ seq: number }>` + select seq from collab_documents where doc_id = ${'page:x'} + ` + expect(Number(rows[0].seq)).toBe(2) + } finally { + await cleanup() + } + }) + + it('deleteCollabDocuments removes exactly the named docs', async () => { + const { db, cleanup } = await createTestDb() + try { + await putCollabDocumentState(db, 'page:a', new Uint8Array([1])) + await putCollabDocumentState(db, 'page:b', new Uint8Array([2])) + await deleteCollabDocuments(db, ['page:a', 'page:missing']) + expect(await getCollabDocumentState(db, 'page:a')).toBeNull() + expect(await getCollabDocumentState(db, 'page:b')).not.toBeNull() + } finally { + await cleanup() + } + }) +}) From e13f129f2ec067a219f5f625478b9f047159b0a9 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 10:59:45 +0200 Subject: [PATCH 08/49] =?UTF-8?q?feat(collab):=20server=20relay=20?= =?UTF-8?q?=E2=80=94=20doc=20registry,=20deterministic=20seeding,=20dual?= =?UTF-8?q?=20persistence,=20reset=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay hydrates/seeds one Y.Doc per collab document, fans updates out, and persists both the CRDT blob and derived JSON (publisher untouched). Out-of-relay row/shell writes reset affected docs via the new rowWriteEvents seam; the transactional save notifies post-commit. Co-Authored-By: Claude Fable 5 --- server/collab/relay.ts | 394 +++++++++++++++++++++ server/handlers/cms/siteDocument.ts | 24 +- server/repositories/data/rows/apply.ts | 12 +- server/repositories/data/rows/mutations.ts | 18 +- server/repositories/rowWriteEvents.ts | 57 +++ server/repositories/site.ts | 6 + src/__tests__/server/collabRelay.test.ts | 155 ++++++++ src/core/collab/applyPatches.ts | 22 +- src/core/collab/index.ts | 2 + src/core/collab/seed.ts | 37 +- 10 files changed, 710 insertions(+), 17 deletions(-) create mode 100644 server/collab/relay.ts create mode 100644 server/repositories/rowWriteEvents.ts create mode 100644 src/__tests__/server/collabRelay.test.ts diff --git a/server/collab/relay.ts b/server/collab/relay.ts new file mode 100644 index 000000000..50aeddd65 --- /dev/null +++ b/server/collab/relay.ts @@ -0,0 +1,394 @@ +/** + * Collab relay — the server half of real-time co-editing. + * + * Owns a registry of live Y documents (one per collab doc id): + * - `openDoc` hydrates the CRDT blob from `collab_documents`, or — first + * ever open — SEEDS the doc deterministically from the current persisted + * JSON (fixed SEED_CLIENT_ID; the server is the ONLY seeder, so two + * clients can never build divergent initial histories). A doc with + * neither blob nor row starts empty: that is the client-created-row + * flow, whose content arrives as ordinary updates. + * - `applyUpdate` merges a client update and schedules a debounced persist. + * - every local doc update fans out to `subscribeUpdates` listeners (the + * socket layer broadcasts to the other connections). + * - persistence writes BOTH the CRDT blob (source of truth for editing) + * and the derived JSON into `data_rows` / `site` — the publisher and all + * non-editor reads stay untouched. Derived-JSON writes are tagged + * `collabInternal` so the row-write reset seam ignores them. + * - rows deleted from the site doc's roster are soft-deleted on persist + * (publish version bumped when a published page goes). + * - `resetDocs` (and the row-write listener wired in `attachResetSources`) + * drop CRDT state whose backing JSON was rewritten OUT-of-relay (plugin + * pack installs, HTTP site saves, data-workspace edits): blob deleted, + * doc evicted, reset broadcast — clients rebind and the doc reseeds from + * the fresh JSON. + * + * Single Bun process by product definition — an in-memory registry is + * correct, not a shortcut (multi-process would need a shared bus; out of + * scope, documented in docs/features/site-shell.md). + */ +import * as Y from 'yjs' +import { + encodeCollabDocId, + parseCollabDocId, + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDocFromParts, + SITE_DOC_ID, + type CollabDocKind, +} from '@core/collab' +import '@modules/base' // registry population — inline-text props seed as Y.Text +import type { SiteShell } from '@core/page-tree' +import { pageFromRow, pageToCells } from '@core/data/pageFromRow' +import { visualComponentFromRow, visualComponentToCells } from '@core/data/componentFromRow' +import { savedLayoutFromRow, savedLayoutToCells } from '@core/data/layoutFromRow' +import { vcSlugFromName } from '@core/visualComponents' +import { layoutSlugFromName } from '@core/layouts' +import { validateSite } from '@core/persistence/validate' +import type { DbClient } from '../db/client' +import { + createDataRow, + getDataRow, + listDataRowIdSlugs, + saveDataRowDraft, + softDeleteDataRow, +} from '../repositories/data' +import { getDraftSite, saveDraftSite } from '../repositories/site' +import { + deleteCollabDocuments, + getCollabDocumentState, + putCollabDocumentState, +} from '../repositories/collabDocuments' +import { + registerRowWriteListener, + registerShellWriteListener, +} from '../repositories/rowWriteEvents' +import { bumpPublishVersionSerialized } from '../publish/publishState' + +const KIND_TABLE: Record, string> = { + page: 'pages', + component: 'components', + layout: 'layouts', +} +const TABLE_KIND: Record> = { + pages: 'page', + components: 'component', + layouts: 'layout', +} + +interface RelayEntry { + doc: Y.Doc + refs: number + dirty: boolean + persistTimer: ReturnType | null + /** Serializes persists per doc so row writes never overlap. */ + persistChain: Promise + detachUpdateHandler: () => void +} + +export type RelayUpdateListener = (docId: string, update: Uint8Array, origin: unknown) => void +export type RelayResetListener = (docId: string) => void + +export interface CollabRelay { + openDoc(docId: string): Promise + retain(docId: string): Promise + release(docId: string): void + applyUpdate(docId: string, update: Uint8Array, origin: unknown): Promise + subscribeUpdates(listener: RelayUpdateListener): () => void + onReset(listener: RelayResetListener): () => void + resetDocs(docIds: readonly string[]): Promise + /** Flush every dirty doc now (tests + shutdown). */ + flushAll(): Promise + /** Detach the row-write reset sources and drop all docs (tests). */ + destroy(): Promise +} + +export function createCollabRelay( + db: DbClient, + opts: { persistDebounceMs?: number } = {}, +): CollabRelay { + const persistDebounceMs = opts.persistDebounceMs ?? 800 + const entries = new Map() + const opening = new Map>() + const updateListeners = new Set() + const resetListeners = new Set() + + // ── Seeding ─────────────────────────────────────────────────────────────── + + async function seedFromJson(docId: string, doc: Y.Doc): Promise { + const parsed = parseCollabDocId(docId) + if (!parsed) return + if (parsed.kind === 'site') { + const shell = await getDraftSite(db) + if (!shell) return // pre-setup — nothing to seed + const [pages, components, layouts] = await Promise.all([ + listDataRowIdSlugs(db, 'pages'), + listDataRowIdSlugs(db, 'components'), + listDataRowIdSlugs(db, 'layouts'), + ]) + seedSiteDocFromParts(doc, shell as unknown as Record, { + pages: pages.map((r) => r.id), + components: components.map((r) => r.id), + layouts: layouts.map((r) => r.id), + }) + return + } + const row = await getDataRow(db, parsed.rowId) + if (!row || row.tableId !== KIND_TABLE[parsed.kind]) return // client-created flow + if (parsed.kind === 'page') { + seedPageDoc(doc, pageFromRow(row)) + } else if (parsed.kind === 'component') { + const vc = visualComponentFromRow(row) + if (vc) seedComponentDoc(doc, vc) + } else { + const layout = savedLayoutFromRow(row) + if (layout) seedLayoutDoc(doc, layout) + } + } + + // ── Persistence ─────────────────────────────────────────────────────────── + + async function persistDerivedJson(docId: string, doc: Y.Doc): Promise { + const parsed = parseCollabDocId(docId) + if (!parsed) return + if (parsed.kind === 'site') { + const projected = projectSiteDoc(doc) + if (Object.keys(projected.shell).length === 0) return // never seeded + let shell: SiteShell + try { + // `id` and `updatedAt` are deliberately NOT collaborative (fixed row / + // per-mutation noise) — inject them at the persistence boundary. + shell = validateSite({ + ...projected.shell, + id: 'default', + updatedAt: + typeof projected.shell.updatedAt === 'number' ? projected.shell.updatedAt : Date.now(), + }) + } catch (err) { + // The blob stays authoritative; JSON write is skipped until the doc + // heals — never persist an invalid shell for the publisher to read. + console.error('[collab] projected shell failed validation — JSON write skipped:', err) + return + } + await saveDraftSite(db, shell, null, { collabInternal: true }) + + // Roster-driven deletions: live rows missing from the roster are gone. + let deletedPublished = false + for (const [table, ids] of [ + ['pages', projected.rosters.pages], + ['components', projected.rosters.components], + ['layouts', projected.rosters.layouts], + ] as const) { + const live = await listDataRowIdSlugs(db, table) + const keep = new Set(ids) + for (const row of live) { + if (keep.has(row.id)) continue + const deleted = await softDeleteDataRow(db, row.id, null, { collabInternal: true }) + if (deleted?.status === 'published') deletedPublished = true + } + } + if (deletedPublished) await bumpPublishVersionSerialized() + return + } + + const table = KIND_TABLE[parsed.kind] + let cells: Record + let slug: string + if (parsed.kind === 'page') { + const page = projectPageDoc(doc, parsed.rowId) + if (!page.rootNodeId) return // never seeded / still assembling + cells = pageToCells(page) + slug = page.slug + } else if (parsed.kind === 'component') { + const vc = projectComponentDoc(doc, parsed.rowId) + if (!vc.tree.rootNodeId || typeof vc.name !== 'string' || vc.name === '') return + cells = visualComponentToCells(vc) + slug = vcSlugFromName(vc.name) + } else { + const layout = projectLayoutDoc(doc, parsed.rowId) + if (!layout.rootNodeId || layout.name === '') return + cells = savedLayoutToCells(layout) + slug = layoutSlugFromName(layout.name) + } + + const existing = await getDataRow(db, parsed.rowId) + if (existing) { + await saveDataRowDraft(db, parsed.rowId, { cells, slug }, null, null, { collabInternal: true }) + } else { + await createDataRow( + db, + { id: parsed.rowId, tableId: table, cells, slug }, + null, + null, + { collabInternal: true }, + ) + } + } + + async function persistNow(docId: string): Promise { + const entry = entries.get(docId) + if (!entry || !entry.dirty) return + entry.dirty = false + try { + await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc)) + await persistDerivedJson(docId, entry.doc) + } catch (err) { + entry.dirty = true // retry on the next schedule + console.error(`[collab] persist failed for ${docId}:`, err) + } + } + + function schedulePersist(docId: string): void { + const entry = entries.get(docId) + if (!entry) return + entry.dirty = true + if (entry.persistTimer) return + entry.persistTimer = setTimeout(() => { + entry.persistTimer = null + entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + }, persistDebounceMs) + } + + // ── Registry ────────────────────────────────────────────────────────────── + + async function openDoc(docId: string): Promise { + const existing = entries.get(docId) + if (existing) return existing.doc + const inFlight = opening.get(docId) + if (inFlight) return inFlight + + const open = (async () => { + const doc = new Y.Doc() + const blob = await getCollabDocumentState(db, docId) + if (blob) { + Y.applyUpdate(doc, blob, 'hydrate') + } else { + await seedFromJson(docId, doc) + } + const updateHandler = (update: Uint8Array, origin: unknown) => { + for (const listener of updateListeners) listener(docId, update, origin) + schedulePersist(docId) + } + doc.on('update', updateHandler) + entries.set(docId, { + doc, + refs: 0, + dirty: !blob, // freshly seeded state should persist its blob + persistTimer: null, + persistChain: Promise.resolve(), + detachUpdateHandler: () => doc.off('update', updateHandler), + }) + if (!blob) schedulePersist(docId) + return doc + })() + + opening.set(docId, open) + try { + return await open + } finally { + opening.delete(docId) + } + } + + async function evict(docId: string, opts2: { persist: boolean }): Promise { + const entry = entries.get(docId) + if (!entry) return + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + if (opts2.persist) { + entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + await entry.persistChain + } + entry.detachUpdateHandler() + entry.doc.destroy() + entries.delete(docId) + } + + async function resetDocs(docIds: readonly string[]): Promise { + const affected = docIds.filter((id) => parseCollabDocId(id) !== null) + if (affected.length === 0) return + for (const docId of affected) { + await evict(docId, { persist: false }) + } + await deleteCollabDocuments(db, affected) + for (const docId of affected) { + for (const listener of resetListeners) listener(docId) + } + } + + // ── Out-of-relay write sources → resets ─────────────────────────────────── + + const detachRowListener = registerRowWriteListener((event) => { + const kind = TABLE_KIND[event.tableId] + if (!kind) return + const docIds = event.rowIds.map((rowId) => encodeCollabDocId({ kind, rowId })) + // Creations/deletions also change the roster — the site doc must reseed. + if (event.kind !== 'update') docIds.push(SITE_DOC_ID) + void resetDocs(docIds).catch((err) => { + console.error('[collab] reset after out-of-relay row write failed:', err) + }) + }) + const detachShellListener = registerShellWriteListener(() => { + void resetDocs([SITE_DOC_ID]).catch((err) => { + console.error('[collab] reset after out-of-relay shell write failed:', err) + }) + }) + + return { + openDoc, + retain: async (docId) => { + const doc = await openDoc(docId) + entries.get(docId)!.refs += 1 + return doc + }, + release: (docId) => { + const entry = entries.get(docId) + if (!entry) return + entry.refs -= 1 + if (entry.refs <= 0) { + void evict(docId, { persist: true }).catch((err) => { + console.error(`[collab] final persist for ${docId} failed:`, err) + }) + } + }, + applyUpdate: async (docId, update, origin) => { + const doc = await openDoc(docId) + Y.applyUpdate(doc, update, origin) + }, + subscribeUpdates: (listener) => { + updateListeners.add(listener) + return () => updateListeners.delete(listener) + }, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + resetDocs, + flushAll: async () => { + for (const docId of [...entries.keys()]) { + const entry = entries.get(docId) + if (!entry) continue + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + await entry.persistChain + } + }, + destroy: async () => { + detachRowListener() + detachShellListener() + for (const docId of [...entries.keys()]) { + await evict(docId, { persist: true }) + } + }, + } +} diff --git a/server/handlers/cms/siteDocument.ts b/server/handlers/cms/siteDocument.ts index c7836f702..bce8115ad 100644 --- a/server/handlers/cms/siteDocument.ts +++ b/server/handlers/cms/siteDocument.ts @@ -80,6 +80,11 @@ import { SaveConflictError, type SaveConflict } from '@core/persistence/saveConf import { shellsEqual } from '@core/persistence/shellsEqual' import type { SiteSyncActor, SiteSyncTable } from '@core/persistence/syncEvents' import { publishSiteEvent } from '../../events/siteEvents' +import { + notifyRowWrite, + notifyShellWrite, + type RowWriteKind, +} from '../../repositories/rowWriteEvents' import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' import { bumpPublishVersionSerialized } from '../../publish/publishState' import { Type, type Static } from '@core/utils/typeboxHelpers' @@ -387,7 +392,8 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // Shell write + seq stamp only when the shell content actually changed // — see the shellChanged comment in phase 1. if (shellChanged) { - await saveDraftSite(tx, shell, user.id) + // In-transaction — collab listeners are notified post-commit below. + await saveDraftSite(tx, shell, user.id, { collabInternal: true }) await stampDraftSiteSeq(tx, seq) } // Empty change sets skip their table entirely — a shell-only save @@ -420,6 +426,22 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // transaction chain). if (deletedPublishedPage) await bumpPublishVersionSerialized() + // Collab invalidation — this save wrote rows/shell OUTSIDE the relay, so + // affected CRDT documents must reset (post-commit; see rowWriteEvents). + if (shellChanged) notifyShellWrite() + const writtenGroups: Array<[string, Iterable, RowWriteKind]> = [ + ['pages', changedPageIdsRaw, 'update'], + ['pages', pageDeleteIds, 'delete'], + ['components', changedComponentIds, 'update'], + ['components', componentDeleteIds, 'delete'], + ['layouts', changedLayoutIds, 'update'], + ['layouts', layoutDeleteIds, 'delete'], + ] + for (const [tableId, ids, kind] of writtenGroups) { + const rowIds = [...ids] + if (rowIds.length > 0) notifyRowWrite({ tableId, rowIds, kind }) + } + // Live-sync fan-out — idempotent hints to every open editor socket // (ids + seqs only, never payloads; see @core/persistence/syncEvents). // A replace-mode save collapses to ONE site-reloaded event instead of diff --git a/server/repositories/data/rows/apply.ts b/server/repositories/data/rows/apply.ts index f3d91272e..a7c1d664e 100644 --- a/server/repositories/data/rows/apply.ts +++ b/server/repositories/data/rows/apply.ts @@ -103,7 +103,7 @@ export async function applyDataRowChangesInTx( // Already-deleted / unknown ids no-op for the same reason (idempotent). for (const rowId of deleteIds) { if (!existingSlugById.has(rowId)) continue - const deleted = await softDeleteDataRow(tx, rowId, actorUserId) + const deleted = await softDeleteDataRow(tx, rowId, actorUserId, { collabInternal: true }) if (!deleted) continue await stampDataRowSeq(tx, rowId, seq) if (deleted.status === 'published') deletedPublished = true @@ -132,7 +132,15 @@ export async function applyDataRowChangesInTx( await resurrectDataRow(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) parked.push(write) } else { - await createDataRow(tx, { id: write.id, tableId, cells: write.cells, slug: write.slug }, actorUserId) + await createDataRow( + tx, + { id: write.id, tableId, cells: write.cells, slug: write.slug }, + actorUserId, + null, + // In-transaction: the caller notifies row-write listeners post-commit + // (a mid-transaction notification would fire even on rollback). + { collabInternal: true }, + ) } await stampDataRowSeq(tx, write.id, seq) } diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index c4e49ea32..a279ede91 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -23,6 +23,7 @@ import { bumpPublishVersionSerialized } from '../../../publish/publishState' import { type InsertDataRowInput, type UpdateDataRowDraftInput } from './mapper' import { isoDateOrNull } from '@core/utils/isoDate' import { getDataRow } from './read' +import { notifyRowWrite } from '../../rowWriteEvents' type UpdateDataRowTableResult = | { ok: true; row: DataRow } @@ -33,6 +34,7 @@ export async function createDataRow( input: InsertDataRowInput, actorUserId: string | null = null, pluginActorId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { const { rows } = await db<{ id: string }>` insert into data_rows ( @@ -61,6 +63,11 @@ export async function createDataRow( ` const created = await getDataRow(db, rows[0].id) if (!created) throw new Error('data row was created but could not be re-read') + // Out-of-relay creations invalidate collab state (roster + row doc) — see + // rowWriteEvents. The relay's own persistence opts out. + if (!opts.collabInternal) { + notifyRowWrite({ tableId: created.tableId, rowIds: [created.id], kind: 'create' }) + } return created } @@ -70,9 +77,14 @@ export async function saveDataRowDraft( input: UpdateDataRowDraftInput, actorUserId: string | null = null, pluginActorId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { const updated = await updateDataRowDraftCells(db, rowId, input, actorUserId, pluginActorId) - return updated ? getDataRow(db, rowId) : null + const row = updated ? await getDataRow(db, rowId) : null + if (row && !opts.collabInternal) { + notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + } + return row } /** @@ -158,6 +170,7 @@ export async function softDeleteDataRow( db: DbClient, rowId: string, actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { const { rows } = await db<{ id: string @@ -176,6 +189,9 @@ export async function softDeleteDataRow( ` const row = rows[0] if (!row) return null + if (!opts.collabInternal) { + notifyRowWrite({ tableId: row.table_id, rowIds: [row.id], kind: 'delete' }) + } return { id: row.id, tableId: row.table_id, diff --git a/server/repositories/rowWriteEvents.ts b/server/repositories/rowWriteEvents.ts new file mode 100644 index 000000000..b50413146 --- /dev/null +++ b/server/repositories/rowWriteEvents.ts @@ -0,0 +1,57 @@ +/** + * Row-write notification seam — repositories announce writes; interested + * layers subscribe. Exists so the collab relay (server/collab) can reset + * CRDT documents when a row is written OUTSIDE the relay (plugin pack + * installs, HTTP site saves, data-workspace edits) WITHOUT repositories + * importing upward into server/collab. + * + * The relay's own persistence passes `collabInternal: true` through the + * repository write functions, which then skip the notification — otherwise + * every relay persist would reset the very documents it just persisted. + */ + +export type RowWriteKind = 'create' | 'update' | 'delete' + +export interface RowWriteEvent { + tableId: string + rowIds: readonly string[] + kind: RowWriteKind +} + +type RowWriteListener = (event: RowWriteEvent) => void + +const listeners = new Set() + +export function registerRowWriteListener(listener: RowWriteListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function notifyRowWrite(event: RowWriteEvent): void { + for (const listener of listeners) { + try { + listener(event) + } catch (err) { + console.error('[rowWriteEvents] listener failed:', err) + } + } +} + +/** The shell (site row) equivalent — same seam, no table id. */ +type ShellWriteListener = () => void +const shellListeners = new Set() + +export function registerShellWriteListener(listener: ShellWriteListener): () => void { + shellListeners.add(listener) + return () => shellListeners.delete(listener) +} + +export function notifyShellWrite(): void { + for (const listener of shellListeners) { + try { + listener() + } catch (err) { + console.error('[rowWriteEvents] shell listener failed:', err) + } + } +} diff --git a/server/repositories/site.ts b/server/repositories/site.ts index d046ca87a..6850954ff 100644 --- a/server/repositories/site.ts +++ b/server/repositories/site.ts @@ -22,6 +22,7 @@ import { validateSite } from '@core/persistence/validate' import { normalizeSitePackageJson } from '@core/site-dependencies/manifest' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' import type { DbClient } from '../db/client' +import { notifyShellWrite } from './rowWriteEvents' import type { SiteRow } from '../types' const CMS_SITE_SCHEMA_VERSION = 1 @@ -85,6 +86,7 @@ export async function saveDraftSite( db: DbClient, shell: SiteShell, _actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, ): Promise { await db` insert into site (id, name, settings_json) @@ -94,6 +96,10 @@ export async function saveDraftSite( settings_json = excluded.settings_json, updated_at = current_timestamp ` + // Out-of-relay shell writes invalidate the site collab doc — see + // rowWriteEvents. The relay's own persistence (and the transactional save, + // which notifies post-commit) opt out. + if (!opts.collabInternal) notifyShellWrite() } /** diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts new file mode 100644 index 000000000..329a7bc93 --- /dev/null +++ b/src/__tests__/server/collabRelay.test.ts @@ -0,0 +1,155 @@ +/** + * Collab relay — doc lifecycle, deterministic seeding, persistence (blob + + * derived JSON), roster-driven deletion, and out-of-relay reset wiring. + * Runs on a real migrated database via the capability harness (setup seeds + * the home page row exactly like a live install). + */ +import { afterEach, describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { + LOCAL_ORIGIN, + projectPageDoc, + rostersMap, + SITE_DOC_ID, + treeMap, +} from '@core/collab' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import { saveDataRowDraft } from '../../../server/repositories/data' +import { + createCapabilityTestHarness, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +let cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabRelay; homeId: string }> { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + return { harness, relay, homeId: rows[0].id } +} + +function editTitleUpdate(doc: Y.Doc, nodeText: string): void { + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const rootId = treeMap(doc).get('rootNodeId') as string + const root = nodes.get(rootId) as Y.Map + root.set('label', nodeText) + }, LOCAL_ORIGIN) +} + +describe('collab relay', () => { + it('seeds a page doc deterministically from the stored row (identical state on repeat)', async () => { + const { harness, relay, homeId } = await setup() + const doc = await relay.openDoc(`page:${homeId}`) + const projected = projectPageDoc(doc, homeId) + expect(projected.slug).toBe('index') + expect(projected.rootNodeId).not.toBe('') + + // A second relay (fresh registry, no blob persisted yet? force reset) — + // deterministic seeding must produce an identical state vector. + const relay2 = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay2.destroy()) + const doc2 = await relay2.openDoc(`page:${homeId}`) + expect(Y.encodeStateVector(doc2)).toEqual(Y.encodeStateVector(doc)) + }) + + it('persists the blob AND the derived JSON after an update', async () => { + const { harness, relay, homeId } = await setup() + const docId = `page:${homeId}` + const doc = await relay.openDoc(docId) + editTitleUpdate(doc, 'Hero section') + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + expect(await getCollabDocumentState(harness.db, docId)).not.toBeNull() + const { rows } = await harness.db<{ cells_json: Record }>` + select cells_json from data_rows where id = ${homeId} + ` + const body = rows[0].cells_json.body as { nodes: Record; rootNodeId: string } + expect(body.nodes[body.rootNodeId].label).toBe('Hero section') + }) + + it('roster removal soft-deletes the row on site-doc persist', async () => { + const { harness, relay, homeId } = await setup() + const siteDoc = await relay.openDoc(SITE_DOC_ID) + siteDoc.transact(() => { + const rosters = rostersMap(siteDoc) + ;(rosters.get('pages') as Y.Map).delete(homeId) + }, LOCAL_ORIGIN) + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + const { rows } = await harness.db<{ deleted_at: string | null }>` + select deleted_at from data_rows where id = ${homeId} + ` + expect(rows[0].deleted_at).not.toBeNull() + }) + + it('an out-of-relay row write resets the doc and notifies listeners', async () => { + const { harness, relay, homeId } = await setup() + const docId = `page:${homeId}` + await relay.openDoc(docId) + await relay.flushAll() + expect(await getCollabDocumentState(harness.db, docId)).not.toBeNull() + + const resets: string[] = [] + relay.onReset((id) => resets.push(id)) + + // Simulate a pack install / data-workspace edit. + const { rows } = await harness.db<{ cells_json: Record; slug: string }>` + select cells_json, slug from data_rows where id = ${homeId} + ` + await saveDataRowDraft(harness.db, homeId, { cells: rows[0].cells_json, slug: rows[0].slug }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(resets).toContain(docId) + expect(await getCollabDocumentState(harness.db, docId)).toBeNull() + }) + + it('a doc with neither blob nor row starts empty (client-created-row flow) and persists a new row', async () => { + const { harness, relay } = await setup() + const docId = 'page:fresh-row-id' + const doc = await relay.openDoc(docId) + expect(treeMap(doc).get('rootNodeId')).toBeUndefined() + + // Client update arrives with full content (translator-populated shape). + doc.transact(() => { + const tree = treeMap(doc) + tree.set('rootNodeId', 'root') + const nodes = new Y.Map() + tree.set('nodes', nodes) + const root = new Y.Map() + root.set('id', 'root') + root.set('moduleId', 'base.body') + root.set('props', new Y.Map()) + root.set('breakpointOverrides', new Y.Map()) + root.set('children', new Y.Array()) + nodes.set('root', root) + const meta = doc.getMap('meta') + meta.set('title', 'Fresh') + meta.set('slug', 'fresh') + }, LOCAL_ORIGIN) + + await new Promise((resolve) => setTimeout(resolve, 30)) + await relay.flushAll() + + const { rows } = await harness.db<{ id: string; slug: string }>` + select id, slug from data_rows where id = ${'fresh-row-id'} + ` + expect(rows[0]?.slug).toBe('fresh') + }) +}) diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts index c7bb15434..843f8d46f 100644 --- a/src/core/collab/applyPatches.ts +++ b/src/core/collab/applyPatches.ts @@ -52,10 +52,16 @@ function rowsById(site: SiteDocument, col: Collection): Map { return new Map(rowsOf(site, col).map((row) => [row.id, row])) } +/** Mutative types `path` as `(string | number)[] | string`; the store always + * produces arrays (default `pathAsArray`) — normalize once for type safety. */ +function patchPath(patch: Patches[number]): readonly (string | number)[] { + return typeof patch.path === 'string' ? patch.path.split('/').filter(Boolean) : patch.path +} + /** Same predicate as dirty tracking: only ops at collection depth ≤ 2, a * `length` bookkeeping op, or any remove can change membership/order. */ function isMembershipShapedOp(patch: Patches[number]): boolean { - return patch.path.length <= 2 + return patchPath(patch).length <= 2 } function clearMap(map: Y.Map): void { @@ -339,7 +345,8 @@ export function applySitePatchesToDocs( const collectionPatches = new Map() for (const patch of patches) { - const head = String(patch.path[0]) + const path = patchPath(patch) + const head = String(path[0]) if (COLLECTIONS.includes(head as Collection)) { const col = head as Collection collectionPatches.set(col, collectionPatches.get(col) ?? []) @@ -349,7 +356,7 @@ export function applySitePatchesToDocs( if (SHELL_SKIPPED_KEYS.has(head)) continue if (SHELL_PER_ENTRY_KEYS.has(head)) { shellEntryTargets.set(head, shellEntryTargets.get(head) ?? new Set()) - shellEntryTargets.get(head)!.add(patch.path.length === 1 ? '*' : String(patch.path[1])) + shellEntryTargets.get(head)!.add(path.length === 1 ? '*' : String(path[1])) } else { shellHeads.add(head) } @@ -442,7 +449,7 @@ export function applySitePatchesToDocs( const nextById = rowsById(nextSite, col) // Wholesale collection replacement (imports) → repopulate every row doc. - if (colPatches.some((p) => p.path.length === 1)) { + if (colPatches.some((p) => patchPath(p).length === 1)) { for (const [id, row] of nextById) { const rowDoc = docs.ensure(`${kind}:${id}`) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row), origin) @@ -452,11 +459,12 @@ export function applySitePatchesToDocs( // Whole-row replaces at depth 2: repopulate rows whose object identity // changed (a reorder moves the SAME objects — skipped by the ref check). - const rowSubPaths = new Map() + const rowSubPaths = new Map() for (const patch of colPatches) { - const index = patch.path[1] + const path = patchPath(patch) + const index = path[1] if (typeof index !== 'number') continue - const rest = patch.path.slice(2) + const rest = path.slice(2) const row = patch.op === 'remove' && rest.length === 0 ? undefined // row removal — handled by the roster diff above : nextSite[col][index] diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 6cf1ed2f3..fccb93ae2 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -40,6 +40,8 @@ export { seedLayoutDoc, seedPageDoc, seedSiteDoc, + seedSiteDocFromParts, + type SiteDocRosterIds, } from './seed' export { projectComponentDoc, diff --git a/src/core/collab/seed.ts b/src/core/collab/seed.ts index 11d380d22..5f654a83d 100644 --- a/src/core/collab/seed.ts +++ b/src/core/collab/seed.ts @@ -92,10 +92,26 @@ export function seedLayoutDoc(doc: Y.Doc, layout: SavedLayout): void { const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) const SHELL_SKIPPED_KEYS = new Set(['pages', 'visualComponents', 'layouts', 'id', 'updatedAt']) -export function seedSiteDoc(doc: Y.Doc, site: SiteDocument): void { +export interface SiteDocRosterIds { + pages: readonly string[] + components: readonly string[] + layouts: readonly string[] +} + +/** + * Seed the site doc from a shell + roster ids — the server relay's entry + * (it has the shell row and lean id projections, never a hydrated + * SiteDocument). `seedSiteDoc` below is the full-document convenience used + * by clients/tests that already hold one. + */ +export function seedSiteDocFromParts( + doc: Y.Doc, + shellFields: Record, + rosterIds: SiteDocRosterIds, +): void { seeding(doc, () => { const shell = shellMap(doc) - for (const [key, value] of Object.entries(site)) { + for (const [key, value] of Object.entries(shellFields)) { if (SHELL_SKIPPED_KEYS.has(key) || value === undefined) continue if (SHELL_PER_ENTRY_KEYS.has(key)) { const entryMap = new Y.Map() @@ -110,16 +126,25 @@ export function seedSiteDoc(doc: Y.Doc, site: SiteDocument): void { const rosters = rostersMap(doc) const pages = new Y.Map() - for (const page of site.pages) pages.set(page.id, true) + for (const id of rosterIds.pages) pages.set(id, true) rosters.set('pages', pages) const order = new Y.Array() - order.push(site.pages.map((p) => p.id)) + order.push([...rosterIds.pages]) rosters.set('pageOrder', order) const components = new Y.Map() - for (const vc of site.visualComponents) components.set(vc.id, true) + for (const id of rosterIds.components) components.set(id, true) rosters.set('components', components) const layouts = new Y.Map() - for (const layout of site.layouts) layouts.set(layout.id, true) + for (const id of rosterIds.layouts) layouts.set(id, true) rosters.set('layouts', layouts) }) } + +export function seedSiteDoc(doc: Y.Doc, site: SiteDocument): void { + const { pages, visualComponents, layouts, ...shellFields } = site + seedSiteDocFromParts(doc, shellFields, { + pages: pages.map((p) => p.id), + components: visualComponents.map((vc) => vc.id), + layouts: layouts.map((l) => l.id), + }) +} From 8955484f2e0f8cbf97213228b49b94ff6c4203ab Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 11:04:54 +0200 Subject: [PATCH 09/49] feat(collab): socket speaks multiplexed y-protocols; retire hint-event live pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One WebSocket multiplexes every collab doc (binary frames: docId + type + y-protocols payload) with a site-wide awareness channel and reset broadcasts. Upgrade gating unchanged (session + site.read + originAllowed); write capability resolved once at upgrade — update frames from read-only connections are dropped. Deletes the phase-B hint-event stack (events bus, delta computation, syncEvents schemas, client transport + merge policy) — CRDT sync subsumes it. Co-Authored-By: Claude Fable 5 --- server/collab/socket.ts | 204 ++++++++++ server/events/siteEvents.ts | 37 -- server/events/siteSocket.ts | 128 ------- server/handlers/cms/siteDocument.ts | 28 -- server/index.ts | 24 +- server/publish/publishSite.ts | 6 - src/__tests__/collab/merge.test.ts | 21 ++ .../editor-store/siteSyncMerge.test.ts | 260 ------------- src/__tests__/server/collabSocket.test.ts | 103 +++++ src/__tests__/server/siteSocket.test.ts | 356 ------------------ .../AdminCanvasLayout/AdminCanvasLayout.tsx | 6 - .../pages/site/hooks/siteSocketClient.ts | 102 ----- src/admin/pages/site/hooks/siteSyncMerge.ts | 197 ---------- src/admin/pages/site/hooks/useSiteSocket.ts | 35 -- src/core/collab/index.ts | 10 + src/core/collab/protocol.ts | 52 +++ src/core/persistence/syncEvents.ts | 93 ----- 17 files changed, 404 insertions(+), 1258 deletions(-) create mode 100644 server/collab/socket.ts delete mode 100644 server/events/siteEvents.ts delete mode 100644 server/events/siteSocket.ts delete mode 100644 src/__tests__/editor-store/siteSyncMerge.test.ts create mode 100644 src/__tests__/server/collabSocket.test.ts delete mode 100644 src/__tests__/server/siteSocket.test.ts delete mode 100644 src/admin/pages/site/hooks/siteSocketClient.ts delete mode 100644 src/admin/pages/site/hooks/siteSyncMerge.ts delete mode 100644 src/admin/pages/site/hooks/useSiteSocket.ts create mode 100644 src/core/collab/protocol.ts delete mode 100644 src/core/persistence/syncEvents.ts diff --git a/server/collab/socket.ts b/server/collab/socket.ts new file mode 100644 index 000000000..21999b22d --- /dev/null +++ b/server/collab/socket.ts @@ -0,0 +1,204 @@ +/** + * Collab socket — the WebSocket endpoint of real-time co-editing. + * + * GET /admin/api/cms/site-socket → WebSocket upgrade + * + * Auth happens at upgrade time: the session cookie must resolve to a user + * with `site.read`, and the Origin header must pass `originAllowed` — the + * browser always sends Origin on WebSocket handshakes, so this closes + * cross-origin WebSocket hijacking (CSWSH): cookies ride the handshake, but + * a foreign origin is rejected before the socket opens. Whether the user may + * WRITE is resolved once at upgrade (any site-write capability) — update + * frames from read-only connections are dropped server-side. + * + * One socket multiplexes many docs (frames in @core/collab/protocol): + * - FRAME_SYNC: y-protocols sync messages per doc. A connection's first + * sync frame for a doc retains it in the relay and subscribes the socket + * to that doc's fan-out topic; close releases everything. + * - FRAME_AWARENESS: one site-wide awareness channel (PRESENCE_DOC_ID) — + * cursors/selections; per-connection clientIDs are tracked so a closing + * socket's peers disappear immediately. + * - FRAME_RESET: server → client only; broadcast when the relay drops a + * doc whose backing JSON was rewritten out-of-relay. + * + * NOTE on fan-out: relay updates publish to every topic subscriber including + * the originator — Yjs update application is idempotent, so the echo is a + * cheap no-op and the code stays free of per-connection exclusion plumbing. + */ +import type { ServerWebSocket, WebSocketHandler } from 'bun' +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from 'y-protocols/sync' +import * as awarenessProtocol from 'y-protocols/awareness' +import { + decodeCollabFrame, + encodeCollabFrame, + FRAME_AWARENESS, + FRAME_RESET, + FRAME_SYNC, + parseCollabDocId, + PRESENCE_DOC_ID, + SITE_SOCKET_PATH, +} from '@core/collab' +import { requireCapability, userHasCapability } from '../auth/authz' +import { originAllowed } from '../auth/security' +import type { DbClient } from '../db/client' +import { jsonResponse } from '../http' +import type { CollabRelay } from './relay' + +export { SITE_SOCKET_PATH } + +const SITE_WRITE_CAPABILITIES = ['site.structure.edit', 'site.content.edit', 'site.style.edit'] as const + +/** y-protocols/sync message types (the payload's first varUint). */ +const SYNC_STEP_1 = 0 + +export interface CollabSocketData { + userId: string + canWrite: boolean + /** Doc ids this connection retained in the relay (released on close). */ + boundDocs: Set + /** Awareness clientIDs contributed by this connection. */ + awarenessClients: Set +} + +interface UpgradeCapableServer { + upgrade(req: Request, options: { data: CollabSocketData }): boolean +} + +/** + * Gate + upgrade the socket request. Returns `null` when the connection was + * upgraded (the caller must then return `undefined` from `fetch`), or an + * error `Response` (401/403/426) to send instead. + */ +export async function handleCollabSocketUpgrade( + req: Request, + db: DbClient, + server: UpgradeCapableServer, +): Promise { + // CSWSH defense — a cookie-bearing cross-origin handshake is rejected + // before auth even runs. + if (!originAllowed(req)) { + return jsonResponse({ error: 'Origin not allowed' }, { status: 403 }) + } + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + const canWrite = SITE_WRITE_CAPABILITIES.some((cap) => userHasCapability(user, cap)) + const upgraded = server.upgrade(req, { + data: { userId: user.id, canWrite, boundDocs: new Set(), awarenessClients: new Set() }, + }) + if (!upgraded) { + return jsonResponse({ error: 'WebSocket upgrade required' }, { status: 426 }) + } + return null +} + +const docTopic = (docId: string): string => `collab:${docId}` + +interface CollabPublisher { + publish(topic: string, data: Uint8Array): number +} + +/** + * Wire the relay's fan-out to Bun pub/sub. Call once after `Bun.serve` + * returns (the server handle is the publisher). Also owns the site-wide + * awareness instance. + */ +export function createCollabSocketLayer(relay: CollabRelay) { + let publisher: CollabPublisher | null = null + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + // The server never contributes its own presence state. + awareness.setLocalState(null) + + relay.subscribeUpdates((docId, update) => { + if (!publisher) return + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + publisher.publish(docTopic(docId), encodeCollabFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder))) + }) + relay.onReset((docId) => { + publisher?.publish(docTopic(docId), encodeCollabFrame(docId, FRAME_RESET, new Uint8Array())) + }) + + const handlers: WebSocketHandler = { + open(ws: ServerWebSocket) { + ws.subscribe(docTopic(PRESENCE_DOC_ID)) + // Late joiners need the current presence roster. + const known = [...awareness.getStates().keys()] + if (known.length > 0) { + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, known) + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update)) + } + }, + + async message(ws: ServerWebSocket, raw: string | Buffer) { + if (typeof raw === 'string') return // binary protocol only + const frame = decodeCollabFrame(new Uint8Array(raw)) + + if (frame.frameType === FRAME_AWARENESS) { + if (!ws.data.canWrite) return + // Track which clientIDs this connection contributes so its peers + // vanish immediately on close. + const before = new Set(awareness.getStates().keys()) + awarenessProtocol.applyAwarenessUpdate(awareness, frame.payload, ws) + for (const clientId of awareness.getStates().keys()) { + if (!before.has(clientId)) ws.data.awarenessClients.add(clientId) + } + publisher?.publish( + docTopic(PRESENCE_DOC_ID), + encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, frame.payload), + ) + // publish() excludes nobody server-side; the sender's own state is + // already local — awareness re-application is idempotent. + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, frame.payload)) + return + } + + if (frame.frameType !== FRAME_SYNC) return + if (!parseCollabDocId(frame.docId)) return + + // Read-only connections may REQUEST state (step1) but never write. + const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) + if (messageType !== SYNC_STEP_1 && !ws.data.canWrite) return + + let doc: Y.Doc + if (ws.data.boundDocs.has(frame.docId)) { + doc = await relay.openDoc(frame.docId) + } else { + doc = await relay.retain(frame.docId) + ws.data.boundDocs.add(frame.docId) + ws.subscribe(docTopic(frame.docId)) + } + + const decoder = decoding.createDecoder(frame.payload) + const encoder = encoding.createEncoder() + syncProtocol.readSyncMessage(decoder, encoder, doc, ws) + if (encoding.length(encoder) > 0) { + ws.send(encodeCollabFrame(frame.docId, FRAME_SYNC, encoding.toUint8Array(encoder))) + } + }, + + close(ws: ServerWebSocket) { + for (const docId of ws.data.boundDocs) relay.release(docId) + ws.data.boundDocs.clear() + if (ws.data.awarenessClients.size > 0) { + awarenessProtocol.removeAwarenessStates(awareness, [...ws.data.awarenessClients], 'disconnect') + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, [...ws.data.awarenessClients]) + publisher?.publish( + docTopic(PRESENCE_DOC_ID), + encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update), + ) + } + }, + } + + return { + handlers, + setPublisher(next: CollabPublisher): void { + publisher = next + }, + awareness, + } +} diff --git a/server/events/siteEvents.ts b/server/events/siteEvents.ts deleted file mode 100644 index ba7bd7094..000000000 --- a/server/events/siteEvents.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Site events bus — the in-process fan-out behind the multi-admin live-pull - * channel (level B of the live-sync plan). - * - * Instatic is self-hosted and runs as ONE Bun process by product definition, - * so an in-memory bus wrapping Bun's native pub/sub is *correct*, not a - * shortcut — multi-process deployments would need a shared bus and are - * explicitly out of scope (documented in docs/features/site-shell.md). - * - * The publisher is the `Bun.Server` instance, registered at boot - * (server/index.ts) AFTER `Bun.serve` returns. Until then — and in tests - * that exercise handlers without a listening server — `publishSiteEvent` - * drops events silently, which is safe by design: events are idempotent - * HINTS; the seq-cursor delta on (re)connect is the truth - * (see @core/persistence/syncEvents). - */ -import type { SiteSyncEvent } from '@core/persistence/syncEvents' - -/** The one durable topic every open editor subscribes to. */ -export const SITE_EVENTS_TOPIC = 'site' - -interface SiteEventPublisher { - publish(topic: string, data: string): number -} - -let publisher: SiteEventPublisher | null = null - -/** Register the boot-time publisher (the Bun server). Pass null to detach (tests). */ -export function setSiteEventPublisher(next: SiteEventPublisher | null): void { - publisher = next -} - -/** Broadcast one sync event to every subscribed editor socket. */ -export function publishSiteEvent(event: SiteSyncEvent): void { - if (!publisher) return - publisher.publish(SITE_EVENTS_TOPIC, JSON.stringify(event)) -} diff --git a/server/events/siteSocket.ts b/server/events/siteSocket.ts deleted file mode 100644 index 938b27921..000000000 --- a/server/events/siteSocket.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Site socket — the WebSocket endpoint of the multi-admin live-pull channel. - * - * GET /admin/api/cms/site-socket → WebSocket upgrade - * - * Auth happens at upgrade time: the session cookie must resolve to a user - * with `site.read`, and the Origin header must pass `originAllowed` — the - * browser always sends Origin on WebSocket handshakes, so this closes - * cross-origin WebSocket hijacking (CSWSH): cookies ride the handshake, but - * a foreign origin is rejected before the socket opens. - * - * Protocol (shapes in @core/persistence/syncEvents): - * 1. On open the socket subscribes to the `site` topic — every - * post-commit save event published through server/events/siteEvents - * fans out to it via Bun's native pub/sub (subscriptions die with the - * socket; no cleanup bookkeeping to leak). - * 2. The client sends `{ kind: 'subscribe', cursor }` with the highest - * seq it has synchronized. The server replies with DELTA events - * synthesized from `rows where seq > cursor` (soft-deleted included — - * a missed deletion surfaces as `rows-deleted`, not silence) plus a - * `shell-changed` when the shell seq is past the cursor. This makes - * the socket self-healing: live events are hints; the delta is truth. - * 3. Malformed or non-string frames are logged and dropped — never acted - * on. - */ -import type { ServerWebSocket, WebSocketHandler } from 'bun' -import type { SiteSyncEvent, SiteSyncTable } from '@core/persistence/syncEvents' -import { SITE_SOCKET_PATH, SiteSocketSubscribeSchema } from '@core/persistence/syncEvents' -import { safeParseJson } from '@core/utils/jsonValidate' -import { requireCapability } from '../auth/authz' -import { originAllowed } from '../auth/security' -import type { DbClient } from '../db/client' -import { jsonResponse } from '../http' -import { listChangedDataRowRefsSince } from '../repositories/data' -import { getDraftSiteSeq } from '../repositories/site' -import { SITE_EVENTS_TOPIC } from './siteEvents' - -export { SITE_SOCKET_PATH } - -export interface SiteSocketData { - userId: string -} - -const SITE_SYNC_TABLES: readonly SiteSyncTable[] = ['pages', 'components', 'layouts'] - -interface UpgradeCapableServer { - upgrade(req: Request, options: { data: SiteSocketData }): boolean -} - -/** - * Gate + upgrade the socket request. Returns `null` when the connection was - * upgraded (the caller must then return `undefined` from `fetch`), or an - * error `Response` (401/403/426) to send instead. - */ -export async function handleSiteSocketUpgrade( - req: Request, - db: DbClient, - server: UpgradeCapableServer, -): Promise { - // CSWSH defense — a cookie-bearing cross-origin handshake is rejected - // before auth even runs. - if (!originAllowed(req)) { - return jsonResponse({ error: 'Origin not allowed' }, { status: 403 }) - } - const user = await requireCapability(req, db, 'site.read') - if (user instanceof Response) return user - const upgraded = server.upgrade(req, { data: { userId: user.id } }) - if (!upgraded) { - return jsonResponse({ error: 'WebSocket upgrade required' }, { status: 426 }) - } - return null -} - -/** - * Reconnect delta: every sync event the client with this cursor has missed, - * in seq order, ready to run through the client's ordinary merge rule. - * Delta events carry no actor — the originating saves are no longer known. - */ -export async function computeDeltaEvents(db: DbClient, cursor: number): Promise { - const refs = await listChangedDataRowRefsSince(db, SITE_SYNC_TABLES, cursor) - - const changed = new Map>() - const deleted = new Map>() - for (const ref of refs) { - const table = ref.tableId as SiteSyncTable // query is scoped to SITE_SYNC_TABLES - const bucket = ref.deleted ? deleted : changed - const seqs = bucket.get(table) ?? {} - seqs[ref.id] = ref.seq - bucket.set(table, seqs) - } - - const events: SiteSyncEvent[] = [] - for (const [table, seqs] of changed) events.push({ kind: 'rows-changed', table, seqs }) - for (const [table, seqs] of deleted) events.push({ kind: 'rows-deleted', table, seqs }) - - const shellSeq = await getDraftSiteSeq(db) - if (shellSeq > cursor) events.push({ kind: 'shell-changed', seq: shellSeq }) - - return events -} - -/** The `websocket` config for `Bun.serve` — one instance per boot, bound to the db. */ -export function createSiteSocketHandlers(db: DbClient): WebSocketHandler { - return { - open(ws: ServerWebSocket) { - ws.subscribe(SITE_EVENTS_TOPIC) - }, - - async message(ws: ServerWebSocket, raw: string | Buffer) { - if (typeof raw !== 'string') return // binary frames are not part of the protocol - const parsed = safeParseJson(raw, SiteSocketSubscribeSchema) - if (!parsed.ok) { - console.warn('[siteSocket] dropping malformed client message') - return - } - try { - const events = await computeDeltaEvents(db, parsed.value.cursor) - for (const event of events) ws.send(JSON.stringify(event)) - } catch (err) { - console.error('[siteSocket] delta computation failed:', err) - } - }, - - close() { - // Bun drops the socket's topic subscriptions automatically. - }, - } -} diff --git a/server/handlers/cms/siteDocument.ts b/server/handlers/cms/siteDocument.ts index bce8115ad..bc2a789ad 100644 --- a/server/handlers/cms/siteDocument.ts +++ b/server/handlers/cms/siteDocument.ts @@ -78,8 +78,6 @@ import { SavedLayoutSchema, layoutSlugFromName, type SavedLayout } from '@core/l import type { Page } from '@core/page-tree' import { SaveConflictError, type SaveConflict } from '@core/persistence/saveConflict' import { shellsEqual } from '@core/persistence/shellsEqual' -import type { SiteSyncActor, SiteSyncTable } from '@core/persistence/syncEvents' -import { publishSiteEvent } from '../../events/siteEvents' import { notifyRowWrite, notifyShellWrite, @@ -442,32 +440,6 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom if (rowIds.length > 0) notifyRowWrite({ tableId, rowIds, kind }) } - // Live-sync fan-out — idempotent hints to every open editor socket - // (ids + seqs only, never payloads; see @core/persistence/syncEvents). - // A replace-mode save collapses to ONE site-reloaded event instead of - // thousands of row events. - const actor: SiteSyncActor = { userId: user.id, name: user.displayName || user.email } - if (body.mode === 'replace') { - publishSiteEvent({ kind: 'site-reloaded', seq, actor }) - } else { - if (shellChanged) publishSiteEvent({ kind: 'shell-changed', seq, actor }) - const rowGroups: Array<{ table: SiteSyncTable; changedIds: Iterable; deleteIds: Iterable }> = [ - { table: 'pages', changedIds: changedPageIdsRaw, deleteIds: pageDeleteIds }, - { table: 'components', changedIds: changedComponentIds, deleteIds: componentDeleteIds }, - { table: 'layouts', changedIds: changedLayoutIds, deleteIds: layoutDeleteIds }, - ] - for (const { table, changedIds, deleteIds } of rowGroups) { - const changedSeqs = Object.fromEntries([...changedIds].map((id) => [id, seq])) - if (Object.keys(changedSeqs).length > 0) { - publishSiteEvent({ kind: 'rows-changed', table, seqs: changedSeqs, actor }) - } - const deletedSeqs = Object.fromEntries([...deleteIds].map((id) => [id, seq])) - if (Object.keys(deletedSeqs).length > 0) { - publishSiteEvent({ kind: 'rows-deleted', table, seqs: deletedSeqs, actor }) - } - } - } - return jsonResponse({ ok: true, seq }) } catch (err) { if (err instanceof SiteValidationError) return badRequest(err.message) diff --git a/server/index.ts b/server/index.ts index 85def8fa5..e8daf5dcd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,9 +10,9 @@ await import('./richtextSanitizer') const { handleServerRequest } = await import('./router') const { activateInstalledServerPlugins } = await import('./plugins/runtime') const { mediaStorageRegistry } = await import('@core/plugins/mediaStorageRegistry') -const { setSiteEventPublisher } = await import('./events/siteEvents') -const { SITE_SOCKET_PATH, createSiteSocketHandlers, handleSiteSocketUpgrade } = - await import('./events/siteSocket') +const { createCollabRelay } = await import('./collab/relay') +const { SITE_SOCKET_PATH, createCollabSocketLayer, handleCollabSocketUpgrade } = + await import('./collab/socket') const config = readServerConfig() configureTrustedProxyCidrs(config.trustedProxyCidrs) @@ -32,6 +32,11 @@ await activateInstalledServerPlugins(db, config.uploadsDir) // AI runtime: start the nightly conversation-purge tick. Operators add // their own provider credentials via /admin/ai/providers on first install. startConversationPurgeTick(db) +// Real-time co-editing: the relay owns live Y documents, their persistence, +// and the reset protocol for out-of-relay writes. The socket layer speaks +// the multiplexed y-protocols wire (see server/collab/socket.ts). +const collabRelay = createCollabRelay(db) +const collabSocket = createCollabSocketLayer(collabRelay) /** * Build the CORS response headers for an incoming request. @@ -91,13 +96,13 @@ const server = Bun.serve({ ) } - // Multi-admin live-sync socket — a WebSocket upgrade is a different + // Real-time co-editing socket — a WebSocket upgrade is a different // protocol lifecycle from the request/response router, so it dispatches // here at the `Bun.serve` boundary (the only place `server.upgrade` is // available). Returning `undefined` hands the connection to the // `websocket` handlers below. if (pathname === SITE_SOCKET_PATH) { - const rejection = await handleSiteSocketUpgrade(req, db, server) + const rejection = await handleCollabSocketUpgrade(req, db, server) if (rejection === null) return undefined return applySecurityHeaders(rejection, pathname) } @@ -130,7 +135,7 @@ const server = Bun.serve({ } }, - websocket: createSiteSocketHandlers(db), + websocket: collabSocket.handlers, error(err: Error) { console.error('[server] Unhandled error:', err) @@ -138,9 +143,8 @@ const server = Bun.serve({ }, }) -// The bus needs the live server handle for Bun pub/sub fan-out — register it -// now that `Bun.serve` returned. Save events emitted before this line (none -// in practice) would drop harmlessly: they are hints, the delta is truth. -setSiteEventPublisher(server) +// The collab fan-out publishes through Bun pub/sub — register the live +// server handle now that `Bun.serve` returned. +collabSocket.setPublisher(server) console.log(`[server] Listening on http://localhost:${config.port}`) diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 6ecdb7274..925249bc9 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -50,7 +50,6 @@ import { import { buildPublishedSiteCssBundle } from './siteCssBundle' import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' -import { publishSiteEvent } from '../events/siteEvents' interface PublishResult { publishedPages: number @@ -303,10 +302,5 @@ async function publishDraftSiteLocked( // live while the version counter still reads the old value. bumpPublishVersion() - // Live-sync hint: open editors learn the site was published without - // polling. No actor — the publish lock context only carries the user id, - // and the event is informational. - publishSiteEvent({ kind: 'published', publishVersion: getPublishVersion() }) - return { publishedPages } } diff --git a/src/__tests__/collab/merge.test.ts b/src/__tests__/collab/merge.test.ts index dbf006ec9..2cc73e30b 100644 --- a/src/__tests__/collab/merge.test.ts +++ b/src/__tests__/collab/merge.test.ts @@ -141,3 +141,24 @@ describe('applyTextDiff', () => { expect(b.getText('t').toString()).toBe(merged) }) }) + +describe('wire frame codec', () => { + it('round-trips docId + frameType + payload', async () => { + const { encodeCollabFrame, decodeCollabFrame, FRAME_SYNC } = await import('@core/collab') + const frame = encodeCollabFrame('page:p1', FRAME_SYNC, new Uint8Array([1, 2, 3])) + const decoded = decodeCollabFrame(frame) + expect([decoded.docId, decoded.frameType, [...decoded.payload]]).toEqual([ + 'page:p1', + FRAME_SYNC, + [1, 2, 3], + ]) + }) + + it('round-trips an empty payload (reset frames)', async () => { + const { encodeCollabFrame, decodeCollabFrame, FRAME_RESET } = await import('@core/collab') + const decoded = decodeCollabFrame(encodeCollabFrame('site:default', FRAME_RESET, new Uint8Array())) + expect(decoded.docId).toBe('site:default') + expect(decoded.frameType).toBe(FRAME_RESET) + expect(decoded.payload.length).toBe(0) + }) +}) diff --git a/src/__tests__/editor-store/siteSyncMerge.test.ts b/src/__tests__/editor-store/siteSyncMerge.test.ts deleted file mode 100644 index 6547eaa22..000000000 --- a/src/__tests__/editor-store/siteSyncMerge.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Live-pull merge policy (multi-admin level B) — processSiteSyncEvent. - * - * The rule, per event target: - * 1. base seq ≥ event seq → skipped entirely (own-save echo, no fetch), - * 2. dirty locally → pending conflict, content untouched, - * 3. clean → fetch + applyRemoteSnapshot; identical content (echo that - * outran the save response) is bookkeeping-only and PRESERVES history; - * genuinely newer content swaps in and clears history, - * 4. dirtiness is re-checked after the fetch — an edit landing mid-wire - * degrades to a conflict, never a silent overwrite. - * - * Plus: the shell path keys off the dedicated `shell` dirty mark, deletions - * remove rows, and the cursor advances with every processed event. The - * snapshot fetch is injected (`SiteSyncMergeDeps`) so no HTTP or module - * mocking is involved. - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import type { Page } from '@core/page-tree' -import { useEditorStore } from '@site/store/store' -import { processSiteSyncEvent, type SiteSyncMergeDeps } from '@site/hooks/siteSyncMerge' -import type { RemoteSnapshot } from '@site/store/slices/site/types' -import { makePage, makeSite } from '../fixtures' - -function depsReturning(snapshot: RemoteSnapshot): SiteSyncMergeDeps & { calls: number } { - const deps = { - calls: 0, - fetchSnapshot: async () => { - deps.calls += 1 - return snapshot - }, - } - return deps -} - -function depsThatMustNotFetch(): SiteSyncMergeDeps { - return { - fetchSnapshot: () => { - throw new Error('fetchSnapshot must not be called for this event') - }, - } -} - -function loadTwoPageSite(): void { - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - }), - ) - useEditorStore.getState().seedBaseSeqs({ 'page-a': 5, 'page-b': 5 }, 5) -} - -function remotePage(title: string): Page { - return makePage({ id: 'page-a', slug: 'index', title }) -} - -beforeEach(() => { - useEditorStore.getState().clearSite() -}) - -describe('processSiteSyncEvent — rows-changed', () => { - it('applies a newer remote row to a clean target and advances the cursor', async () => { - loadTwoPageSite() - const deps = depsReturning({ - table: 'pages', - rowId: 'page-a', - row: remotePage('Home (remote v2)'), - seq: 9, - }) - - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, - deps, - ) - - const state = useEditorStore.getState() - expect(deps.calls).toBe(1) - expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (remote v2)') - expect(state.baseSeqs['page-a']).toBe(9) - expect(state.syncCursor).toBe(9) - expect(state.saveConflicts).toEqual([]) - }) - - it('skips events at or below the base seq without fetching (own-save echo, fast path)', async () => { - loadTwoPageSite() - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 5 } }, - depsThatMustNotFetch(), - ) - expect(useEditorStore.getState().site!.pages[0].title).toBe('Home') - }) - - it('degrades to a pending conflict when the target is dirty locally — content untouched, no fetch', async () => { - loadTwoPageSite() - useEditorStore.getState().renamePage('page-a', 'Home (my edit)') - - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, - depsThatMustNotFetch(), - ) - - const state = useEditorStore.getState() - expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: 9 }]) - expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (my edit)') - // The conflict does not stop the cursor — the pending entry carries the info. - expect(state.syncCursor).toBe(9) - }) - - it('re-checks dirtiness AFTER the fetch — an edit landing mid-wire becomes a conflict, not an overwrite', async () => { - loadTwoPageSite() - const deps: SiteSyncMergeDeps = { - fetchSnapshot: async () => { - // The user edits while the fetch is on the wire. - useEditorStore.getState().renamePage('page-a', 'Home (raced edit)') - return { table: 'pages', rowId: 'page-a', row: remotePage('Home (remote)'), seq: 9 } - }, - } - - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, - deps, - ) - - const state = useEditorStore.getState() - expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Home (raced edit)') - expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-a', seq: 9 }]) - }) - - it('an echo with identical content is bookkeeping-only — undo history survives', async () => { - loadTwoPageSite() - // Real local history on ANOTHER page — must survive the echo. - useEditorStore.getState().renamePage('page-b', 'About (edited)') - expect(useEditorStore.getState().canUndo).toBe(true) - - // The fetched remote page-a equals the local one byte-for-byte. - const local = useEditorStore.getState().site!.pages.find((p) => p.id === 'page-a')! - const deps = depsReturning({ - table: 'pages', - rowId: 'page-a', - row: structuredClone(local) as Page, - seq: 9, - }) - - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, - deps, - ) - - const state = useEditorStore.getState() - expect(state.canUndo).toBe(true) // history preserved - expect(state.baseSeqs['page-a']).toBe(9) // bookkeeping synced - }) -}) - -describe('processSiteSyncEvent — rows-deleted', () => { - it('removes a remotely-deleted clean row without any fetch', async () => { - loadTwoPageSite() - await processSiteSyncEvent( - { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, - depsThatMustNotFetch(), - ) - - const state = useEditorStore.getState() - expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) - expect(state.baseSeqs['page-b']).toBeUndefined() - expect(state.syncCursor).toBe(9) - }) - - it('a remote deletion of a locally-DIRTY row becomes a conflict', async () => { - loadTwoPageSite() - useEditorStore.getState().renamePage('page-b', 'About (my edit)') - - await processSiteSyncEvent( - { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, - depsThatMustNotFetch(), - ) - - const state = useEditorStore.getState() - expect(state.site!.pages).toHaveLength(2) - expect(state.saveConflicts).toEqual([{ table: 'pages', rowId: 'page-b', seq: 9 }]) - }) -}) - -describe('processSiteSyncEvent — shell-changed', () => { - it('applies a remote shell when the local shell is untouched', async () => { - loadTwoPageSite() - const { pages: _p, visualComponents: _v, layouts: _l, ...shell } = makeSite({ - name: 'Renamed remotely', - }) - const deps = depsReturning({ table: 'site', shell, seq: 9 }) - - await processSiteSyncEvent({ kind: 'shell-changed', seq: 9 }, deps) - - const state = useEditorStore.getState() - expect(state.site!.name).toBe('Renamed remotely') - expect(state.shellBaseSeq).toBe(9) - expect(state.syncCursor).toBe(9) - }) - - it('a dirty local shell degrades to a conflict — the dedicated shell mark gates it', async () => { - loadTwoPageSite() - // A shell-field mutation sets the shell dirty mark via patch tracking. - useEditorStore.getState().updateSiteName('Renamed locally') - expect(useEditorStore.getState()._dirtySave.shell).toBe(true) - - await processSiteSyncEvent({ kind: 'shell-changed', seq: 9 }, depsThatMustNotFetch()) - - const state = useEditorStore.getState() - expect(state.site!.name).toBe('Renamed locally') - expect(state.saveConflicts).toEqual([{ table: 'site', rowId: 'default', seq: 9 }]) - }) - - it('a page edit does NOT mark the shell dirty — sibling shell changes still apply live', async () => { - loadTwoPageSite() - useEditorStore.getState().renamePage('page-a', 'Home (edited)') - expect(useEditorStore.getState()._dirtySave.shell).toBe(false) - }) -}) - -describe('processSiteSyncEvent — conflict dedupe', () => { - it('repeated events for the same dirty target keep ONE pending conflict at the newest seq', async () => { - loadTwoPageSite() - useEditorStore.getState().renamePage('page-a', 'Home (my edit)') - - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 9 } }, - depsThatMustNotFetch(), - ) - await processSiteSyncEvent( - { kind: 'rows-changed', table: 'pages', seqs: { 'page-a': 12 } }, - depsThatMustNotFetch(), - ) - - expect(useEditorStore.getState().saveConflicts).toEqual([ - { table: 'pages', rowId: 'page-a', seq: 12 }, - ]) - }) -}) - -describe('processSiteSyncEvent — aligned deletions', () => { - it('a remote deletion of a row this editor ALSO deleted merges silently (agreement, not conflict)', async () => { - loadTwoPageSite() - useEditorStore.getState().deletePage('page-b') - expect(useEditorStore.getState()._dirtySave.deletedPageIds.has('page-b')).toBe(true) - - await processSiteSyncEvent( - { kind: 'rows-deleted', table: 'pages', seqs: { 'page-b': 9 } }, - depsThatMustNotFetch(), - ) - - const state = useEditorStore.getState() - expect(state.saveConflicts).toEqual([]) - // The now-moot local deletion mark is cleared — nothing left to ship. - expect(state._dirtySave.deletedPageIds.has('page-b')).toBe(false) - expect(state.baseSeqs['page-b']).toBeUndefined() - }) -}) diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts new file mode 100644 index 000000000..e8b6ca7fb --- /dev/null +++ b/src/__tests__/server/collabSocket.test.ts @@ -0,0 +1,103 @@ +/** + * Collab socket — upgrade gating (session + Origin + write capability) for + * the co-editing WebSocket. The full two-client convergence round-trip runs + * in collabRelayIntegration.test.ts against a real Bun.serve. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { SITE_SOCKET_PATH } from '@core/collab' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { + handleCollabSocketUpgrade, + type CollabSocketData, +} from '../../../server/collab/socket' +import { + createCapabilityTestHarness, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +let cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabRelay; cookie: string }> { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: 10 }) + cleanups.push(() => relay.destroy()) + return { harness, relay, cookie } +} + +/** `cookie`/`origin` are fetch-forbidden constructor headers — set after construction. */ +function socketRequest(headers: Record): Request { + const req = new Request(`http://localhost${SITE_SOCKET_PATH}`) + for (const [name, value] of Object.entries(headers)) req.headers.set(name, value) + return req +} + +function fakeServer(upgradeResult: boolean): { + upgrade: (req: Request, options: { data: CollabSocketData }) => boolean + data: () => CollabSocketData | null +} { + let captured: CollabSocketData | null = null + return { + upgrade: (_req, options) => { + captured = options.data + return upgradeResult + }, + data: () => captured, + } +} + +describe('collab socket — upgrade gating', () => { + it('rejects an unauthenticated handshake with 401 before upgrading', async () => { + const { harness } = await setup() + const server = fakeServer(true) + const res = await handleCollabSocketUpgrade(socketRequest({}), harness.db, server) + expect(res?.status).toBe(401) + expect(server.data()).toBeNull() + }) + + it('rejects a cross-origin handshake with 403 (CSWSH defense) even with a valid session', async () => { + const { harness, cookie } = await setup() + const server = fakeServer(true) + const res = await handleCollabSocketUpgrade( + socketRequest({ cookie, origin: 'https://evil.example' }), + harness.db, + server, + ) + expect(res?.status).toBe(403) + expect(server.data()).toBeNull() + }) + + it('upgrades an authenticated same-origin handshake with canWrite resolved from capabilities', async () => { + const { harness, cookie } = await setup() + const server = fakeServer(true) + expect(await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, server)).toBeNull() + expect(server.data()?.canWrite).toBe(true) // the owner holds every site-write cap + + const readOnly = await harness.createRoleUser({ + name: 'Site Viewer', + slug: 'site-viewer-only', + capabilities: ['site.read'], + }) + const readOnlyServer = fakeServer(true) + expect( + await handleCollabSocketUpgrade( + socketRequest({ cookie: readOnly.cookie }), + harness.db, + readOnlyServer, + ), + ).toBeNull() + expect(readOnlyServer.data()?.canWrite).toBe(false) + }) + + it('returns 426 when the request is not an upgradable WebSocket handshake', async () => { + const { harness, cookie } = await setup() + const res = await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, fakeServer(false)) + expect(res?.status).toBe(426) + }) +}) diff --git a/src/__tests__/server/siteSocket.test.ts b/src/__tests__/server/siteSocket.test.ts deleted file mode 100644 index b78a1b6e8..000000000 --- a/src/__tests__/server/siteSocket.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Site socket — the live-pull channel's server half (multi-admin level B). - * - * Covered here: - * - upgrade gating: no session → 401; wrong Origin → 403 (CSWSH defense); - * authenticated non-WS request → 426; authenticated WS handshake → - * upgraded (null), - * - post-commit event emission from the transactional save: rows-changed / - * rows-deleted / shell-changed carry the save's seq and actor; a - * replace-mode save emits ONE site-reloaded; row-only saves emit no - * shell-changed, - * - the reconnect delta (`computeDeltaEvents`): rows past the cursor - * surface as changed/deleted hints (soft-deleted included), the shell - * only when its seq is past the cursor, nothing at the live cursor, - * - a REAL WebSocket round-trip against `Bun.serve`: subscribe → delta, - * then a live save fans out through the bus to the open socket. - * - * Uses the capability harness for auth/session/save plumbing; the publisher - * is injected per test via `setSiteEventPublisher` and detached afterwards - * (the module-global default is null — handlers drop events silently). - */ -import { afterEach, describe, expect, it } from 'bun:test' -import type { SiteShell } from '@core/page-tree' -import type { SiteSyncEvent } from '@core/persistence/syncEvents' -import { publishSiteEvent, setSiteEventPublisher, SITE_EVENTS_TOPIC } from '../../../server/events/siteEvents' -import { - computeDeltaEvents, - createSiteSocketHandlers, - handleSiteSocketUpgrade, - SITE_SOCKET_PATH, - type SiteSocketData, -} from '../../../server/events/siteSocket' -import { - createCapabilityTestHarness, - readJson, - type CapabilityTestHarness, -} from '../helpers/capabilityHarness' - -afterEach(() => { - setSiteEventPublisher(null) -}) - -// --------------------------------------------------------------------------- -// Shared save plumbing (mirrors siteDocumentSave.test.ts, trimmed) -// --------------------------------------------------------------------------- - -function pagePayload(id: string, slug: string, title = slug): Record { - const rootId = `root-${id}` - return { - id, - slug, - title, - rootNodeId: rootId, - nodes: { - [rootId]: { id: rootId, moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [] }, - }, - } -} - -interface Ctx { - harness: CapabilityTestHarness - cookie: string - shell: SiteShell -} - -async function setupHarness(): Promise { - const harness = await createCapabilityTestHarness() - const cookie = await harness.setupOwner() - const shellRes = await harness.cms('/admin/api/cms/site', { method: 'GET', cookie }) - const { site: shell } = await readJson<{ site: SiteShell }>(shellRes) - return { harness, cookie, shell } -} - -async function liveBaseSeqs(harness: CapabilityTestHarness, ids: string[]): Promise> { - const { rows } = await harness.db<{ id: string; seq: number }>`select id, seq from data_rows` - const wanted = new Set(ids) - return Object.fromEntries(rows.filter((r) => wanted.has(r.id)).map((r) => [r.id, Number(r.seq)])) -} - -async function putDoc( - ctx: Ctx, - overrides: { - mode?: 'incremental' | 'replace' - site?: unknown - changedPages?: unknown[] - deletedPageIds?: string[] - } = {}, -): Promise { - const json = { - mode: 'incremental' as const, - site: ctx.shell, - changedPages: [] as unknown[], - deletedPageIds: [] as string[], - changedComponents: [], - deletedComponentIds: [], - changedLayouts: [], - deletedLayoutIds: [], - ...overrides, - } - const ids = [ - ...json.changedPages - .map((p) => (p && typeof p === 'object' ? (p as { id?: unknown }).id : undefined)) - .filter((id): id is string => typeof id === 'string'), - ...json.deletedPageIds, - ] - const res = await ctx.harness.cms('/admin/api/cms/site-document', { - method: 'PUT', - cookie: ctx.cookie, - json: { ...json, baseSeqs: await liveBaseSeqs(ctx.harness, ids), shellBaseSeq: 0 }, - }) - expect(res.status).toBe(200) - const body = await readJson<{ seq: number }>(res) - return body.seq -} - -/** Capture bus output for one test. */ -function captureEvents(): SiteSyncEvent[] { - const events: SiteSyncEvent[] = [] - setSiteEventPublisher({ - publish: (_topic, data) => { - events.push(JSON.parse(data) as SiteSyncEvent) - return 0 - }, - }) - return events -} - -// --------------------------------------------------------------------------- -// Upgrade gating -// --------------------------------------------------------------------------- - -describe('site socket — upgrade gating', () => { - function fakeServer(upgradeResult: boolean): { upgrade: () => boolean; upgraded: () => boolean } { - let upgraded = false - return { - upgrade: () => { - upgraded = true - return upgradeResult - }, - upgraded: () => upgraded, - } - } - - it('rejects an unauthenticated handshake with 401 before upgrading', async () => { - const ctx = await setupHarness() - try { - const server = fakeServer(true) - const res = await handleSiteSocketUpgrade( - new Request(`http://localhost${SITE_SOCKET_PATH}`), - ctx.harness.db, - server, - ) - expect(res?.status).toBe(401) - expect(server.upgraded()).toBe(false) - } finally { - await ctx.harness.cleanup() - } - }) - - /** `cookie`/`origin` are fetch-forbidden constructor headers — set after construction. */ - function socketRequest(headers: Record): Request { - const req = new Request(`http://localhost${SITE_SOCKET_PATH}`) - for (const [name, value] of Object.entries(headers)) req.headers.set(name, value) - return req - } - - it('rejects a cross-origin handshake with 403 (CSWSH defense) even with a valid session', async () => { - const ctx = await setupHarness() - try { - const server = fakeServer(true) - const req = socketRequest({ cookie: ctx.cookie, origin: 'https://evil.example' }) - const res = await handleSiteSocketUpgrade(req, ctx.harness.db, server) - expect(res?.status).toBe(403) - expect(server.upgraded()).toBe(false) - } finally { - await ctx.harness.cleanup() - } - }) - - it('upgrades an authenticated same-origin handshake; a non-WS request gets 426', async () => { - const ctx = await setupHarness() - try { - const req = socketRequest({ cookie: ctx.cookie }) - expect(await handleSiteSocketUpgrade(req, ctx.harness.db, fakeServer(true))).toBeNull() - const rejected = await handleSiteSocketUpgrade( - socketRequest({ cookie: ctx.cookie }), - ctx.harness.db, - fakeServer(false), - ) - expect(rejected?.status).toBe(426) - } finally { - await ctx.harness.cleanup() - } - }) -}) - -// --------------------------------------------------------------------------- -// Save emission -// --------------------------------------------------------------------------- - -describe('site socket — save event emission', () => { - it('an incremental save emits rows-changed/rows-deleted with the save seq and actor, and NO shell event on row-only saves', async () => { - const ctx = await setupHarness() - try { - const events = captureEvents() - const createSeq = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) - const deleteSeq = await putDoc(ctx, { deletedPageIds: ['page-a'] }) - - expect(events).toEqual([ - { - kind: 'rows-changed', - table: 'pages', - seqs: { 'page-a': createSeq }, - actor: { userId: expect.any(String), name: expect.any(String) }, - }, - { - kind: 'rows-deleted', - table: 'pages', - seqs: { 'page-a': deleteSeq }, - actor: { userId: expect.any(String), name: expect.any(String) }, - }, - ]) - } finally { - await ctx.harness.cleanup() - } - }) - - it('a shell change emits shell-changed; a replace-mode save emits ONE site-reloaded', async () => { - const ctx = await setupHarness() - try { - const events = captureEvents() - const shellSeq = await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed' } }) - expect(events).toEqual([ - expect.objectContaining({ kind: 'shell-changed', seq: shellSeq }), - ]) - - events.length = 0 - const replaceSeq = await putDoc(ctx, { - mode: 'replace', - site: { ...ctx.shell, name: 'Imported site' }, - changedPages: [pagePayload('page-b', 'contact')], - }) - expect(events).toEqual([ - expect.objectContaining({ kind: 'site-reloaded', seq: replaceSeq }), - ]) - } finally { - await ctx.harness.cleanup() - } - }) -}) - -// --------------------------------------------------------------------------- -// Reconnect delta -// --------------------------------------------------------------------------- - -describe('site socket — reconnect delta', () => { - it('synthesizes changed + deleted row hints past the cursor, and nothing at the live cursor', async () => { - const ctx = await setupHarness() - try { - const seqA = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) - const seqB = await putDoc(ctx, { changedPages: [pagePayload('page-b', 'contact')] }) - const seqDel = await putDoc(ctx, { deletedPageIds: ['page-a'] }) - - // Cursor 0 → everything: page-a surfaces as DELETED (its latest state), - // page-b as changed. No shell event — row-only saves never stamped it. - const fromZero = await computeDeltaEvents(ctx.harness.db, 0) - expect(fromZero).toEqual([ - { kind: 'rows-changed', table: 'pages', seqs: { 'page-b': seqB } }, - { kind: 'rows-deleted', table: 'pages', seqs: { 'page-a': seqDel } }, - ]) - - // Mid cursor → only what came after. - const fromA = await computeDeltaEvents(ctx.harness.db, seqA) - expect(fromA).toEqual([ - { kind: 'rows-changed', table: 'pages', seqs: { 'page-b': seqB } }, - { kind: 'rows-deleted', table: 'pages', seqs: { 'page-a': seqDel } }, - ]) - - // Live cursor → empty delta. - expect(await computeDeltaEvents(ctx.harness.db, seqDel)).toEqual([]) - } finally { - await ctx.harness.cleanup() - } - }) - - it('includes shell-changed only when the shell seq is past the cursor', async () => { - const ctx = await setupHarness() - try { - const shellSeq = await putDoc(ctx, { site: { ...ctx.shell, name: 'Renamed' } }) - const delta = await computeDeltaEvents(ctx.harness.db, 0) - expect(delta).toEqual([{ kind: 'shell-changed', seq: shellSeq }]) - expect(await computeDeltaEvents(ctx.harness.db, shellSeq)).toEqual([]) - } finally { - await ctx.harness.cleanup() - } - }) -}) - -// --------------------------------------------------------------------------- -// Real WebSocket round-trip -// --------------------------------------------------------------------------- - -describe('site socket — WebSocket round-trip', () => { - it('subscribe returns the delta, and a live publish fans out to the open socket', async () => { - const ctx = await setupHarness() - const seqA = await putDoc(ctx, { changedPages: [pagePayload('page-a', 'about')] }) - - const server = Bun.serve>({ - port: 0, - async fetch(req, srv) { - const rejection = await handleSiteSocketUpgrade(req, ctx.harness.db, srv) - return rejection === null ? undefined : rejection - }, - websocket: createSiteSocketHandlers(ctx.harness.db), - }) - - try { - setSiteEventPublisher(server) - - const received: SiteSyncEvent[] = [] - const gotDelta = Promise.withResolvers() - const gotLive = Promise.withResolvers() - - const ws = new WebSocket(`ws://localhost:${server.port}${SITE_SOCKET_PATH}`, { - headers: { cookie: ctx.cookie }, - }) - ws.onmessage = (msg) => { - received.push(JSON.parse(String(msg.data)) as SiteSyncEvent) - if (received.length === 1) gotDelta.resolve() - if (received.length === 2) gotLive.resolve() - } - await new Promise((resolve, reject) => { - ws.onopen = () => resolve() - ws.onerror = () => reject(new Error('socket failed to open')) - }) - - ws.send(JSON.stringify({ kind: 'subscribe', cursor: 0 })) - await gotDelta.promise - expect(received[0]).toEqual({ - kind: 'rows-changed', - table: 'pages', - seqs: { 'page-a': seqA }, - }) - - // Live fan-out through the bus (same path the save handler uses). - publishSiteEvent({ kind: 'shell-changed', seq: seqA + 1 }) - await gotLive.promise - expect(received[1]).toEqual({ kind: 'shell-changed', seq: seqA + 1 }) - - ws.close() - } finally { - server.stop(true) - await ctx.harness.cleanup() - } - }) -}) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 6a13af502..3944900f0 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -44,7 +44,6 @@ import { useEditorAppearancePreferences } from '@admin/pages/site/preferences/ed import { usePersistence } from '@admin/pages/site/hooks/usePersistence' import { useSiteEditorUrlSync } from '@admin/pages/site/hooks/useSiteEditorUrlSync' import { useEditorLayoutPersistence } from '@admin/pages/site/hooks/useEditorLayoutPersistence' -import { useSiteSocket } from '@admin/pages/site/hooks/useSiteSocket' import { useEditorStore } from '@admin/pages/site/store/store' import { cmsAdapter } from '@core/persistence/cms' import { useAdminUi } from '@admin/state/adminUi' @@ -182,11 +181,6 @@ export function AdminCanvasLayout() { enabled: true, loaded: persistence.saveStatus.state !== 'loading', }) - // Multi-admin live pull — sibling admins' saves merge into this editor as - // they land (or surface in the conflict banner when they collide with - // local unsaved edits). Gated on the document being loaded: before that - // there is no sync cursor to subscribe with. - useSiteSocket(site !== null) useEditorLayoutPersistence() useInstalledEditorPlugins(pluginBackgroundWorkEnabled) // Mount the SSE bridge ONCE per admin tab — gives toasts on plugin diff --git a/src/admin/pages/site/hooks/siteSocketClient.ts b/src/admin/pages/site/hooks/siteSocketClient.ts deleted file mode 100644 index 1dcff6c5e..000000000 --- a/src/admin/pages/site/hooks/siteSocketClient.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * siteSocketClient — the live-pull TRANSPORT of the multi-admin sync channel - * (level B of the live-sync plan). Lazy-imported by `useSiteSocket` so the - * whole sync machinery (event schemas, merge policy, snapshot fetching) - * stays out of the site route's first-paint chunk. - * - * One WebSocket to `/admin/api/cms/site-socket` with exponential-backoff - * reconnect. On every (re)connect it sends the store's seq cursor and the - * server replies with the missed delta as ordinary events — the socket is a - * HINT channel; the delta query is the truth, so dropped events can never - * cause drift (self-healing by design). - * - * Incoming frames are TypeBox-validated (malformed frames are logged and - * dropped) and processed strictly in arrival order through one promise - * chain — a fetch for event N never interleaves with the apply of event - * N+1. What an event DOES to the store is the merge policy in - * `siteSyncMerge.ts`. - */ -import { SITE_SOCKET_PATH, SiteSyncEventSchema } from '@core/persistence/syncEvents' -import { safeParseJson } from '@core/utils/jsonValidate' -import { getErrorMessage } from '@core/utils/errorMessage' -import { useEditorStore } from '@site/store/store' -import { processSiteSyncEvent } from './siteSyncMerge' - -const RECONNECT_BASE_DELAY_MS = 1_000 -const RECONNECT_MAX_DELAY_MS = 30_000 - -/** - * Open (and own) the live-sync socket. Returns the disposer that tears the - * connection down and stops reconnecting. - */ -export function connectSiteSocket(): () => void { - let socket: WebSocket | null = null - let disposed = false - let attempts = 0 - let reconnectTimer: ReturnType | undefined - let chain: Promise = Promise.resolve() - - function scheduleReconnect() { - if (disposed) return - // Exponential backoff with jitter, capped — the delta-on-reconnect - // protocol makes aggressive retry unnecessary. - const delay = - Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** attempts) + - Math.random() * 500 - attempts += 1 - reconnectTimer = setTimeout(connect, delay) - } - - function connect() { - if (disposed) return - const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - socket = new WebSocket(`${protocol}://${window.location.host}${SITE_SOCKET_PATH}`) - - socket.onopen = () => { - attempts = 0 - socket?.send( - JSON.stringify({ kind: 'subscribe', cursor: useEditorStore.getState().syncCursor }), - ) - } - - socket.onmessage = (msg: MessageEvent) => { - if (typeof msg.data !== 'string') return // binary frames are not part of the protocol - const parsed = safeParseJson(msg.data, SiteSyncEventSchema) - if (!parsed.ok) { - console.warn('[siteSocketClient] dropping malformed sync event') - return - } - const event = parsed.value - chain = chain.then(() => - processSiteSyncEvent(event).catch((err) => { - console.error( - '[siteSocketClient] failed to apply sync event:', - getErrorMessage(err, 'unknown error'), - err, - ) - }), - ) - } - - // Errors always surface as a close — reconnect is scheduled there. - socket.onerror = () => {} - - socket.onclose = () => { - socket = null - scheduleReconnect() - } - } - - connect() - - return () => { - disposed = true - clearTimeout(reconnectTimer) - if (socket) { - // Detach first — the close handler would otherwise schedule a - // reconnect for a socket we are deliberately tearing down. - socket.onclose = null - socket.close() - } - } -} diff --git a/src/admin/pages/site/hooks/siteSyncMerge.ts b/src/admin/pages/site/hooks/siteSyncMerge.ts deleted file mode 100644 index 7ec6f39ef..000000000 --- a/src/admin/pages/site/hooks/siteSyncMerge.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * siteSyncMerge — the merge POLICY of the live-pull channel: what one - * incoming sync event does to the editor store. The transport (WebSocket - * lifecycle, ordering, reconnect) lives in `useSiteSocket`; keeping the - * policy separate makes it testable with a plain fake snapshot fetcher. - * - * Merge rule per event target (order matters): - * 1. base seq already ≥ event seq → skip. Absorbs the echo of this - * editor's own saves when the save response beat the event. - * 2. target dirty locally → do NOT touch it; add a pending conflict — the - * same banner as a 409'd save, one resolution UX for levels A and B. - * 3. clean → fetch the current remote state and `applyRemoteSnapshot` it. - * Dirtiness is re-checked after the fetch (an edit can land mid-wire). - * The apply deep-equal-skips identical content (own-save echoes that - * outran the response), so undo history only resets when remote content - * genuinely replaced local state — and the toast fires exactly then. - * - * `site-reloaded` (replace-mode save — import/bootstrap) hands off to the - * ordinary persistence reload path. `published` is informational. - */ -import type { SiteSyncEvent, SiteSyncTable } from '@core/persistence/syncEvents' -import { pushToast } from '@ui/components/Toast' -import { requestCmsSiteReload } from '@admin/state/adminEvents' -import { useEditorStore } from '@site/store/store' -import type { RemoteSnapshot } from '@site/store/slices/site/types' -import { fetchRemoteSnapshot, type RemoteSnapshotTarget } from './remoteSnapshot' - -/** Injectable fetch seam — tests hand in a fake that returns domain snapshots. */ -export interface SiteSyncMergeDeps { - fetchSnapshot: (target: RemoteSnapshotTarget) => Promise -} - -const defaultDeps: SiteSyncMergeDeps = { fetchSnapshot: fetchRemoteSnapshot } - -function isTargetDirty(table: SiteSyncTable, rowId: string): boolean { - const { _dirtySave } = useEditorStore.getState() - if (_dirtySave.all) return true - if (table === 'pages') { - return _dirtySave.pageIds.has(rowId) || _dirtySave.deletedPageIds.has(rowId) - } - if (table === 'components') { - return _dirtySave.componentIds.has(rowId) || _dirtySave.deletedComponentIds.has(rowId) - } - return _dirtySave.layoutIds.has(rowId) || _dirtySave.deletedLayoutIds.has(rowId) -} - -/** - * True when the target's only local dirtiness is its own pending DELETION — - * a remote deletion of the same row is then agreement, not a conflict, and - * merges silently (the apply clears the now-moot deleted mark). - */ -function isOnlyLocallyDeleted(table: SiteSyncTable, rowId: string): boolean { - const { _dirtySave } = useEditorStore.getState() - if (_dirtySave.all) return false - if (table === 'pages') { - return _dirtySave.deletedPageIds.has(rowId) && !_dirtySave.pageIds.has(rowId) - } - if (table === 'components') { - return _dirtySave.deletedComponentIds.has(rowId) && !_dirtySave.componentIds.has(rowId) - } - return _dirtySave.deletedLayoutIds.has(rowId) && !_dirtySave.layoutIds.has(rowId) -} - -/** Local display title of a row — for the remote-change toasts. */ -function localRowTitle(table: SiteSyncTable, rowId: string): string | null { - const site = useEditorStore.getState().site - if (!site) return null - if (table === 'pages') return site.pages.find((p) => p.id === rowId)?.title ?? null - if (table === 'components') { - return site.visualComponents.find((vc) => vc.id === rowId)?.name ?? null - } - return site.layouts.find((layout) => layout.id === rowId)?.name ?? null -} - -async function handleRowEvent( - table: SiteSyncTable, - rowId: string, - seq: number, - deletedRemotely: boolean, - actorName: string | undefined, - deps: SiteSyncMergeDeps, -): Promise { - const store = useEditorStore.getState() - if ((store.baseSeqs[rowId] ?? -1) >= seq) return // own write / already applied - if (isTargetDirty(table, rowId)) { - // Exception: both sides deleted the row — agreement merges silently. - if (!(deletedRemotely && isOnlyLocallyDeleted(table, rowId))) { - store.addSaveConflicts([{ table, rowId, seq }]) - return - } - } - - const wasActivePage = table === 'pages' && store.activePageId === rowId - const title = localRowTitle(table, rowId) - - let snapshot: RemoteSnapshot - if (deletedRemotely) { - // No fetch — the snapshot is synchronous, so nothing can interleave - // between the dirty check above and the apply below. - snapshot = { table, rowId, row: null, seq } - } else { - snapshot = await deps.fetchSnapshot({ table, rowId, seq }) - // An edit may have landed while the fetch was on the wire — a dirty - // target must never be silently overwritten, so it degrades to a conflict. - if (isTargetDirty(table, rowId)) { - useEditorStore.getState().addSaveConflicts([{ table, rowId, seq }]) - return - } - } - - const result = useEditorStore.getState().applyRemoteSnapshot(snapshot) - if (!result.applied) return - - const who = actorName ?? 'Another admin' - if (snapshot.table !== 'site' && snapshot.row === null && wasActivePage) { - pushToast({ - kind: 'info', - title: 'Page deleted', - body: `${who} deleted ${title ? `"${title}"` : 'the page you were viewing'}.`, - }) - } else if (result.clearedHistory) { - pushToast({ - kind: 'info', - title: 'Updated with newer changes', - body: `${who} saved ${title ? `"${title}"` : 'content you have open'} — your undo history was reset.`, - }) - } -} - -async function handleShellEvent( - seq: number, - actorName: string | undefined, - deps: SiteSyncMergeDeps, -): Promise { - const store = useEditorStore.getState() - if (store.shellBaseSeq >= seq) return - if (store._dirtySave.shell || store._dirtySave.all) { - store.addSaveConflicts([{ table: 'site', rowId: 'default', seq }]) - return - } - - const snapshot = await deps.fetchSnapshot({ table: 'site', rowId: 'default', seq }) - const after = useEditorStore.getState() - if (after._dirtySave.shell || after._dirtySave.all) { - after.addSaveConflicts([{ table: 'site', rowId: 'default', seq }]) - return - } - - const result = after.applyRemoteSnapshot(snapshot) - if (result.applied && result.clearedHistory) { - pushToast({ - kind: 'info', - title: 'Site settings updated', - body: `${actorName ?? 'Another admin'} changed site settings or styles — your undo history was reset.`, - }) - } -} - -/** Apply one validated sync event to the editor store (see module doc). */ -export async function processSiteSyncEvent( - event: SiteSyncEvent, - deps: SiteSyncMergeDeps = defaultDeps, -): Promise { - switch (event.kind) { - case 'rows-changed': - case 'rows-deleted': { - const deletedRemotely = event.kind === 'rows-deleted' - for (const [rowId, seq] of Object.entries(event.seqs)) { - await handleRowEvent(event.table, rowId, seq, deletedRemotely, event.actor?.name, deps) - } - useEditorStore.getState().advanceSyncCursor(Math.max(...Object.values(event.seqs), 0)) - break - } - case 'shell-changed': { - await handleShellEvent(event.seq, event.actor?.name, deps) - useEditorStore.getState().advanceSyncCursor(event.seq) - break - } - case 'site-reloaded': { - const store = useEditorStore.getState() - if (store.syncCursor >= event.seq) break - store.advanceSyncCursor(event.seq) - pushToast({ - kind: 'info', - title: 'Site replaced', - body: `${event.actor?.name ?? 'Another admin'} replaced the site (import) — reloading the editor.`, - }) - // The ordinary reload path refetches, revalidates, and reseeds the - // sync bases; local unsaved edits are moot against a replaced site. - requestCmsSiteReload() - break - } - case 'published': - // Informational — no editor state derives from the publish version yet. - break - } -} diff --git a/src/admin/pages/site/hooks/useSiteSocket.ts b/src/admin/pages/site/hooks/useSiteSocket.ts deleted file mode 100644 index a7dbbf436..000000000 --- a/src/admin/pages/site/hooks/useSiteSocket.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * useSiteSocket — lifecycle glue for the live-pull channel (multi-admin - * level B). The actual transport + merge machinery lives in - * `siteSocketClient.ts` / `siteSyncMerge.ts` and is LAZY-imported here so - * its chunk (event schemas, snapshot fetching, conflict plumbing) stays out - * of the site route's first-paint bundle. - * - * `enabled` gates on the site document being loaded — before that there is - * no sync cursor to subscribe with and nothing to merge into. - */ -import { useEffect } from 'react' - -export function useSiteSocket(enabled: boolean): void { - useEffect(() => { - if (!enabled) return undefined - if (typeof WebSocket === 'undefined') return undefined - - let dispose: (() => void) | null = null - let cancelled = false - - void import('./siteSocketClient') - .then((mod) => { - if (cancelled) return - dispose = mod.connectSiteSocket() - }) - .catch((err) => { - console.error('[useSiteSocket] failed to load the sync client:', err) - }) - - return () => { - cancelled = true - dispose?.() - } - }, [enabled]) -} diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index fccb93ae2..2ae96c8ad 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -30,6 +30,16 @@ export { export { buildNodeMap, buildPropsMap, projectNodeMap } from './nodeY' export { reconcileTreeIntegrity } from './integrity' export { applyTextDiff } from './textDiff' +export { + decodeCollabFrame, + encodeCollabFrame, + FRAME_AWARENESS, + FRAME_RESET, + FRAME_SYNC, + PRESENCE_DOC_ID, + SITE_SOCKET_PATH, + type CollabFrame, +} from './protocol' export { createCollabDocSet, type CollabDocSet } from './docSet' export { applySitePatchesToDocs } from './applyPatches' export { diff --git a/src/core/collab/protocol.ts b/src/core/collab/protocol.ts new file mode 100644 index 000000000..96743bd6f --- /dev/null +++ b/src/core/collab/protocol.ts @@ -0,0 +1,52 @@ +/** + * Collab wire protocol — binary frames multiplexing many docs over ONE + * WebSocket (`SITE_SOCKET_PATH`). Shared verbatim by the server socket layer + * and the client provider. + * + * frame := varString docId | varUint frameType | rest payload + * + * FRAME_SYNC payload = a y-protocols/sync message (step1/step2/update) + * FRAME_AWARENESS payload = a y-protocols/awareness update + * (docId is the fixed PRESENCE_DOC_ID — awareness is + * site-wide; clients filter peers by their active doc) + * FRAME_RESET no payload — the server dropped this doc's CRDT state + * (its backing JSON was rewritten out-of-relay); clients + * rebind and the doc reseeds server-side + */ +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' + +export const SITE_SOCKET_PATH = '/admin/api/cms/site-socket' + +/** The pseudo doc id awareness frames travel under. */ +export const PRESENCE_DOC_ID = 'presence' + +export const FRAME_SYNC = 0 +export const FRAME_AWARENESS = 1 +export const FRAME_RESET = 2 + +export interface CollabFrame { + docId: string + frameType: number + payload: Uint8Array +} + +export function encodeCollabFrame( + docId: string, + frameType: number, + payload: Uint8Array, +): Uint8Array { + const encoder = encoding.createEncoder() + encoding.writeVarString(encoder, docId) + encoding.writeVarUint(encoder, frameType) + encoding.writeUint8Array(encoder, payload) + return encoding.toUint8Array(encoder) +} + +export function decodeCollabFrame(data: Uint8Array): CollabFrame { + const decoder = decoding.createDecoder(data) + const docId = decoding.readVarString(decoder) + const frameType = decoding.readVarUint(decoder) + const payload = decoding.readTailAsUint8Array(decoder) + return { docId, frameType, payload } +} diff --git a/src/core/persistence/syncEvents.ts b/src/core/persistence/syncEvents.ts deleted file mode 100644 index 897654fe6..000000000 --- a/src/core/persistence/syncEvents.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Site sync-event protocol — the wire shapes of the multi-admin live-pull - * channel (`GET /admin/api/cms/site-socket`, level B of the live-sync plan). - * - * Design rules (from the live-sync plan): - * - Events carry **ids + seqs, never row payloads**. They are idempotent - * HINTS — the client fetches current rows itself, so a dropped, replayed, - * or reordered event can never corrupt state. The seq-cursor delta on - * (re)connect is the truth; the socket only makes it prompt. - * - One save's rows share that save's seq; delta-synthesized events (sent - * in response to `subscribe`) may span many saves, hence per-row seqs. - * - `actor` is display-level identity for the awareness UI. Absent on - * delta-synthesized events (the originating save is no longer known). - * - * The server constructs these events (server/events/siteEvents.ts emits them - * post-commit from the transactional save); the client validates every - * incoming frame against `SiteSyncEventSchema` before acting — an unknown or - * malformed frame is logged and dropped, never applied. - */ -import { Type, type Static } from '@core/utils/typeboxHelpers' - -/** The socket endpoint — shared by the server upgrade dispatch and the client hook. */ -export const SITE_SOCKET_PATH = '/admin/api/cms/site-socket' - -/** The three row-backed site collections that sync per row. */ -export const SiteSyncTableSchema = Type.Union([ - Type.Literal('pages'), - Type.Literal('components'), - Type.Literal('layouts'), -]) -export type SiteSyncTable = Static - -/** Display-level identity of the admin (or connector-driven agent) who saved. */ -export const SiteSyncActorSchema = Type.Object({ - userId: Type.Union([Type.String(), Type.Null()]), - name: Type.String(), -}) -export type SiteSyncActor = Static - -const RowsChangedEventSchema = Type.Object({ - kind: Type.Literal('rows-changed'), - table: SiteSyncTableSchema, - /** rowId → the seq stamped on that row by the save that changed it. */ - seqs: Type.Record(Type.String(), Type.Number()), - actor: Type.Optional(SiteSyncActorSchema), -}) - -const RowsDeletedEventSchema = Type.Object({ - kind: Type.Literal('rows-deleted'), - table: SiteSyncTableSchema, - /** rowId → the seq stamped on that row by the save that soft-deleted it. */ - seqs: Type.Record(Type.String(), Type.Number()), - actor: Type.Optional(SiteSyncActorSchema), -}) - -const ShellChangedEventSchema = Type.Object({ - kind: Type.Literal('shell-changed'), - seq: Type.Number(), - actor: Type.Optional(SiteSyncActorSchema), -}) - -/** A replace-mode save (import / bootstrap) rewrote the site wholesale. */ -const SiteReloadedEventSchema = Type.Object({ - kind: Type.Literal('site-reloaded'), - seq: Type.Number(), - actor: Type.Optional(SiteSyncActorSchema), -}) - -const PublishedEventSchema = Type.Object({ - kind: Type.Literal('published'), - publishVersion: Type.Number(), - actor: Type.Optional(SiteSyncActorSchema), -}) - -export const SiteSyncEventSchema = Type.Union([ - RowsChangedEventSchema, - RowsDeletedEventSchema, - ShellChangedEventSchema, - SiteReloadedEventSchema, - PublishedEventSchema, -]) -export type SiteSyncEvent = Static - -/** - * The one client→server message: sent on (re)connect with the client's seq - * cursor. The server replies with delta events synthesized from - * `rows where seq > cursor` (soft-deleted rows included) plus the shell — - * which is why a missed live event can never cause drift. - */ -export const SiteSocketSubscribeSchema = Type.Object({ - kind: Type.Literal('subscribe'), - cursor: Type.Number(), -}) From 1367393f2b6e776f7e9389e051b74f72140a5a99 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 11:06:51 +0200 Subject: [PATCH 10/49] =?UTF-8?q?feat(collab):=20client=20provider=20?= =?UTF-8?q?=E2=80=94=20multiplexed=20transport=20with=20backoff=20reconnec?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/provider.test.ts | 118 ++++++++ src/admin/pages/site/collab/collabProvider.ts | 283 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 src/__tests__/collab/provider.test.ts create mode 100644 src/admin/pages/site/collab/collabProvider.ts diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts new file mode 100644 index 000000000..a8b225914 --- /dev/null +++ b/src/__tests__/collab/provider.test.ts @@ -0,0 +1,118 @@ +/** + * CollabProvider transport behavior against an in-memory fake socket: + * step1 on bind, immediate update frames on local transactions, remote + * update application under REMOTE_ORIGIN, synced gating on step2, and the + * reset flow. The real wire runs in collabRelayIntegration.test.ts. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from 'y-protocols/sync' +import { + decodeCollabFrame, + encodeCollabFrame, + FRAME_RESET, + FRAME_SYNC, + LOCAL_ORIGIN, +} from '@core/collab' +import { + createCollabProvider, + type CollabSocketLike, +} from '@site/collab/collabProvider' + +class FakeSocket implements CollabSocketLike { + binaryType = 'arraybuffer' + readyState = 1 + sent: Uint8Array[] = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + send(data: Uint8Array): void { + this.sent.push(data) + } + close(): void { + this.readyState = 3 + } + open(): void { + this.onopen?.() + } + emit(frame: Uint8Array): void { + this.onmessage?.({ data: frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.byteLength) }) + } +} + +function lastSyncMessageType(socket: FakeSocket, docId: string): number | null { + for (let i = socket.sent.length - 1; i >= 0; i--) { + const frame = decodeCollabFrame(socket.sent[i]) + if (frame.docId === docId && frame.frameType === FRAME_SYNC) { + return decoding.readVarUint(decoding.createDecoder(frame.payload)) + } + } + return null +} + +describe('collab provider', () => { + it('sends syncStep1 for a bound doc on connect and marks it synced on step2', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const binding = provider.bind('page:p1') + expect(lastSyncMessageType(socket, 'page:p1')).toBe(0) // step1 + expect(binding.synced).toBe(false) + + // Server replies with step2 (its full state against our vector). + const serverDoc = new Y.Doc() + serverDoc.getMap('tree').set('rootNodeId', 'root') + const encoder = encoding.createEncoder() + syncProtocol.writeSyncStep2(encoder, serverDoc, Y.encodeStateVector(new Y.Doc())) + socket.emit(encodeCollabFrame('page:p1', FRAME_SYNC, encoding.toUint8Array(encoder))) + + await binding.whenSynced + expect(binding.synced).toBe(true) + expect(binding.doc.getMap('tree').get('rootNodeId')).toBe('root') + provider.destroy() + }) + + it('sends an update frame immediately on a local transaction; remote-applied updates do not echo', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const { doc } = provider.bind('page:p1') + const sentBefore = socket.sent.length + + doc.transact(() => { + doc.getMap('tree').set('rootNodeId', 'root') + }, LOCAL_ORIGIN) + expect(socket.sent.length).toBe(sentBefore + 1) + expect(lastSyncMessageType(socket, 'page:p1')).toBe(2) // update + + // A remote update applied by the provider must NOT be re-sent. + const remote = new Y.Doc() + remote.getMap('meta').set('title', 'From remote') + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(remote)) + const countBeforeRemote = socket.sent.length + socket.emit(encodeCollabFrame('page:p1', FRAME_SYNC, encoding.toUint8Array(encoder))) + expect(doc.getMap('meta').get('title')).toBe('From remote') + expect(socket.sent.length).toBe(countBeforeRemote) + provider.destroy() + }) + + it('a reset frame unbinds the doc and notifies listeners', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + provider.bind('page:p1') + const resets: string[] = [] + provider.onReset((docId) => resets.push(docId)) + + socket.emit(encodeCollabFrame('page:p1', FRAME_RESET, new Uint8Array())) + expect(resets).toEqual(['page:p1']) + // A rebind gets a FRESH doc (the old one was destroyed). + const rebound = provider.bind('page:p1') + expect(rebound.synced).toBe(false) + provider.destroy() + }) +}) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts new file mode 100644 index 000000000..a72281c72 --- /dev/null +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -0,0 +1,283 @@ +/** + * CollabProvider — the client transport of real-time co-editing. + * + * Owns ONE WebSocket to `SITE_SOCKET_PATH` and multiplexes every bound doc + * over it (frames in @core/collab/protocol): + * - bind(docId) returns the live Y.Doc plus a `whenSynced` promise that + * resolves after the server's initial sync (docs are NEVER seeded + * client-side — the server is the only seeder, so two clients can't + * build divergent initial histories; the store gates edits on synced). + * - every local transaction (origin ≠ REMOTE_ORIGIN) sends an update + * frame immediately — Yjs 'update' events fire synchronously inside the + * transaction commit, so there is no flush window to lose on unload. + * - reconnect uses exponential backoff; on every (re)connect each bound + * doc re-runs syncStep1, and Yjs state vectors make the catch-up delta + * exact — the transport is self-healing by construction. + * - FRAME_RESET (server dropped a doc whose JSON was rewritten + * out-of-relay) unbinds the doc and notifies `onReset`; the binding + * layer rebinds and the doc reseeds server-side. + * - awareness (cursors/selections) rides the same socket under + * PRESENCE_DOC_ID via y-protocols/awareness. + * + * The socket factory is injectable for tests; production uses the browser + * WebSocket against the same origin (the Vite dev proxy forwards upgrades). + */ +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from 'y-protocols/sync' +import * as awarenessProtocol from 'y-protocols/awareness' +import { + decodeCollabFrame, + encodeCollabFrame, + FRAME_AWARENESS, + FRAME_RESET, + FRAME_SYNC, + PRESENCE_DOC_ID, + REMOTE_ORIGIN, + SITE_SOCKET_PATH, +} from '@core/collab' + +const RECONNECT_BASE_DELAY_MS = 1_000 +const RECONNECT_MAX_DELAY_MS = 30_000 + +/** y-protocols/sync message types (payload's first varUint). */ +const SYNC_STEP_2 = 1 + +export type CollabStatus = 'connecting' | 'connected' | 'offline' + +export interface CollabSocketLike { + binaryType: string + readyState: number + send(data: Uint8Array): void + close(): void + onopen: (() => void) | null + onmessage: ((event: { data: unknown }) => void) | null + onclose: (() => void) | null + onerror: (() => void) | null +} + +export interface BoundCollabDoc { + doc: Y.Doc + synced: boolean + whenSynced: Promise +} + +export interface CollabProvider { + bind(docId: string): BoundCollabDoc + unbind(docId: string): void + awareness: awarenessProtocol.Awareness + status(): CollabStatus + onStatus(listener: (status: CollabStatus) => void): () => void + onReset(listener: (docId: string) => void): () => void + destroy(): void +} + +interface BoundEntry { + doc: Y.Doc + synced: boolean + whenSynced: Promise + resolveSynced: () => void + updateHandler: (update: Uint8Array, origin: unknown) => void +} + +export function createCollabProvider( + opts: { createSocket?: () => CollabSocketLike } = {}, +): CollabProvider { + const createSocket = + opts.createSocket ?? + ((): CollabSocketLike => { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + return new WebSocket( + `${protocol}://${window.location.host}${SITE_SOCKET_PATH}`, + ) as unknown as CollabSocketLike + }) + + const bound = new Map() + const statusListeners = new Set<(status: CollabStatus) => void>() + const resetListeners = new Set<(docId: string) => void>() + let socket: CollabSocketLike | null = null + let status: CollabStatus = 'connecting' + let destroyed = false + let attempts = 0 + let reconnectTimer: ReturnType | undefined + + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + + function setStatus(next: CollabStatus): void { + if (status === next) return + status = next + for (const listener of statusListeners) listener(next) + } + + function sendFrame(docId: string, frameType: number, payload: Uint8Array): void { + if (!socket || socket.readyState !== 1 /* OPEN */) return + socket.send(encodeCollabFrame(docId, frameType, payload)) + } + + function sendSyncStep1(docId: string, doc: Y.Doc): void { + const encoder = encoding.createEncoder() + syncProtocol.writeSyncStep1(encoder, doc) + sendFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder)) + } + + function attachDoc(docId: string, doc: Y.Doc): BoundEntry { + let resolveSynced!: () => void + const whenSynced = new Promise((resolve) => { + resolveSynced = resolve + }) + const entry: BoundEntry = { + doc, + synced: false, + whenSynced, + resolveSynced, + updateHandler: (update, origin) => { + if (origin === REMOTE_ORIGIN) return + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + sendFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder)) + }, + } + doc.on('update', entry.updateHandler) + return entry + } + + const awarenessUpdateHandler = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => { + if (origin === REMOTE_ORIGIN) return + const changed = [...added, ...updated, ...removed] + if (changed.length === 0) return + sendFrame( + PRESENCE_DOC_ID, + FRAME_AWARENESS, + awarenessProtocol.encodeAwarenessUpdate(awareness, changed), + ) + } + awareness.on('update', awarenessUpdateHandler) + + function handleFrame(data: Uint8Array): void { + const frame = decodeCollabFrame(data) + + if (frame.docId === PRESENCE_DOC_ID && frame.frameType === FRAME_AWARENESS) { + awarenessProtocol.applyAwarenessUpdate(awareness, frame.payload, REMOTE_ORIGIN) + return + } + + const entry = bound.get(frame.docId) + if (!entry) return + + if (frame.frameType === FRAME_RESET) { + unbind(frame.docId) + for (const listener of resetListeners) listener(frame.docId) + return + } + if (frame.frameType !== FRAME_SYNC) return + + const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) + const decoder = decoding.createDecoder(frame.payload) + const encoder = encoding.createEncoder() + syncProtocol.readSyncMessage(decoder, encoder, entry.doc, REMOTE_ORIGIN) + if (encoding.length(encoder) > 0) { + sendFrame(frame.docId, FRAME_SYNC, encoding.toUint8Array(encoder)) + } + if (messageType === SYNC_STEP_2 && !entry.synced) { + entry.synced = true + entry.resolveSynced() + } + } + + function connect(): void { + if (destroyed) return + setStatus('connecting') + socket = createSocket() + socket.binaryType = 'arraybuffer' + + socket.onopen = () => { + attempts = 0 + setStatus('connected') + for (const [docId, entry] of bound) sendSyncStep1(docId, entry.doc) + // Re-announce local presence after a reconnect. + const localState = awareness.getLocalState() + if (localState !== null) { + sendFrame( + PRESENCE_DOC_ID, + FRAME_AWARENESS, + awarenessProtocol.encodeAwarenessUpdate(awareness, [awareness.clientID]), + ) + } + } + + socket.onmessage = (event) => { + const { data } = event + if (data instanceof ArrayBuffer) handleFrame(new Uint8Array(data)) + else if (data instanceof Uint8Array) handleFrame(data) + } + + // Errors always surface as a close — reconnect is scheduled there. + socket.onerror = () => {} + + socket.onclose = () => { + socket = null + if (destroyed) return + setStatus('offline') + const delay = + Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** attempts) + + Math.random() * 500 + attempts += 1 + reconnectTimer = setTimeout(connect, delay) + } + } + + function unbind(docId: string): void { + const entry = bound.get(docId) + if (!entry) return + entry.doc.off('update', entry.updateHandler) + entry.doc.destroy() + bound.delete(docId) + } + + function beforeUnload(): void { + awarenessProtocol.removeAwarenessStates(awareness, [awareness.clientID], 'unload') + } + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', beforeUnload) + } + + connect() + + return { + bind: (docId) => { + const existing = bound.get(docId) + if (existing) return existing + const entry = attachDoc(docId, new Y.Doc()) + bound.set(docId, entry) + sendSyncStep1(docId, entry.doc) + return entry + }, + unbind, + awareness, + status: () => status, + onStatus: (listener) => { + statusListeners.add(listener) + return () => statusListeners.delete(listener) + }, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + destroy: () => { + destroyed = true + clearTimeout(reconnectTimer) + if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnload) + awareness.off('update', awarenessUpdateHandler) + awarenessProtocol.removeAwarenessStates(awareness, [awareness.clientID], 'destroy') + awareness.destroy() + for (const docId of [...bound.keys()]) unbind(docId) + socket?.close() + socket = null + }, + } +} From 6bc62472340dc13c7fd3e9781a65717a66bb5c28 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 11:59:16 +0200 Subject: [PATCH 11/49] =?UTF-8?q?feat(editor):=20CRDT=20write=20path=20rep?= =?UTF-8?q?laces=20save=20pipeline=20=E2=80=94=20per-doc=20undo,=20live=20?= =?UTF-8?q?persistence=20via=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store rewiring (Tasks 9-12 of the collab plan): - collabBinding.ts glues the editor store to the Y docs: local mutation patches translate into Y ops (LOCAL_ORIGIN), remote/undo changes project back; per-doc Y.UndoManager with coalesceKey-compatible capture breaks; multi-doc undo groups (one Cmd+Z reverts convert-to-component & friends across page + site + component docs); detached-mode alignment guard rebuilds docs when tests hand-assemble the store via setState. - runHistoricMutation drops patch-pair history + dirty tracking; undo/redo delegate to the binding; lifecycle actions reset the doc world. - usePersistence slims to HTTP-load-for-first-paint + lazy provider connect + status mapping; autosave, Cmd+S, beforeunload flush, dirty snapshots, baseSeq bookkeeping and the save queue are gone (the relay persists). - Deleted: saveTrackingSlice, dirtyTracking, conflictActions, SaveConflictBanner, remoteSnapshot, editorSaveRef, editor.save command + keybinding, EDITOR_SAVE_REQUEST_EVENT, cms loadSiteShell/loadSiteRow and the ?id= single-row filter. - Publish flushes the collab relay server-side (flushCollabDocs plumb) so the baked snapshot includes edits inside the debounce window. - applyChildrenDiff falls back to a wholesale rewrite when the Y array drifted from the pre-state instead of throwing mid-transaction. Co-Authored-By: Claude Fable 5 --- server/handlers/cms/components.ts | 2 +- server/handlers/cms/layouts.ts | 2 +- server/handlers/cms/pages.ts | 2 +- server/handlers/cms/publish.ts | 4 + server/handlers/cms/shared.ts | 16 +- server/index.ts | 1 + server/router.ts | 7 + .../deleteNodesDepthOrder.test.ts | 4 +- .../editor-store/dirtyTracking.test.ts | 476 --------------- .../historyCoalescingFold.test.ts | 188 ------ .../editor-store/inlineEditSlice.test.ts | 45 +- .../editor-store/layoutsSlice.test.ts | 4 +- .../mutateAllPagesAndSite.test.ts | 11 +- .../editor-store/nodeInlineStyles.test.ts | 27 +- .../editor-store/saveConflicts.test.ts | 224 ------- .../editor-store/siteRuntimeSlice.test.ts | 9 +- .../editor-store/styleRuleSlice.test.ts | 11 +- src/__tests__/editor-store/undo-redo.test.ts | 75 ++- .../visualComponentsMutationContract.test.ts | 25 +- src/__tests__/persistence/cmsAdapter.test.ts | 12 - .../persistence/savePersistenceQueue.test.tsx | 257 -------- src/__tests__/templates/templateModel.test.ts | 4 - src/__tests__/toolbar/toolbar.test.ts | 28 +- .../AdminCanvasLayout/AdminCanvasLayout.tsx | 44 +- .../SiteImport/shared/importPlanning.ts | 10 +- src/admin/pages/site/SitePage.tsx | 13 +- .../pages/site/dialogs/LayoutNameDialog.tsx | 5 - src/admin/pages/site/hooks/editorSaveRef.ts | 26 - src/admin/pages/site/hooks/remoteSnapshot.ts | 55 -- src/admin/pages/site/hooks/usePersistence.ts | 492 ++++----------- .../site/store/slices/inlineEditSlice.ts | 19 +- .../site/store/slices/saveTrackingSlice.ts | 226 ------- .../site/store/slices/site/collabBinding.ts | 578 ++++++++++++++++++ .../site/store/slices/site/conflictActions.ts | 285 --------- .../pages/site/store/slices/site/defaults.ts | 5 +- .../site/store/slices/site/dirtyTracking.ts | 193 ------ .../pages/site/store/slices/site/helpers.ts | 122 +--- .../store/slices/site/lifecycleActions.ts | 39 +- .../pages/site/store/slices/site/types.ts | 85 +-- .../site/store/slices/site/undoRedoActions.ts | 80 +-- .../pages/site/store/slices/sitePanelSlice.ts | 15 +- .../pages/site/store/slices/siteSlice.ts | 18 +- src/admin/pages/site/store/store.ts | 13 +- .../pages/site/toolbar/PublishButton.tsx | 73 +-- .../SaveConflictBanner.module.css | 64 -- .../SaveConflictBanner/SaveConflictBanner.tsx | 139 ----- src/admin/spotlight/commands/editor.ts | 22 - src/admin/spotlight/keybindings.ts | 7 - src/admin/state/adminEvents.ts | 20 - src/core/collab/applyPatches.ts | 29 +- src/core/collab/docSet.ts | 5 + src/core/page-tree/pageMutations.ts | 4 +- src/core/persistence/cms.ts | 41 +- src/core/utils/deepEqual.ts | 6 +- 54 files changed, 973 insertions(+), 3194 deletions(-) delete mode 100644 src/__tests__/editor-store/dirtyTracking.test.ts delete mode 100644 src/__tests__/editor-store/historyCoalescingFold.test.ts delete mode 100644 src/__tests__/editor-store/saveConflicts.test.ts delete mode 100644 src/__tests__/persistence/savePersistenceQueue.test.tsx delete mode 100644 src/admin/pages/site/hooks/editorSaveRef.ts delete mode 100644 src/admin/pages/site/hooks/remoteSnapshot.ts delete mode 100644 src/admin/pages/site/store/slices/saveTrackingSlice.ts create mode 100644 src/admin/pages/site/store/slices/site/collabBinding.ts delete mode 100644 src/admin/pages/site/store/slices/site/conflictActions.ts delete mode 100644 src/admin/pages/site/store/slices/site/dirtyTracking.ts delete mode 100644 src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css delete mode 100644 src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx diff --git a/server/handlers/cms/components.ts b/server/handlers/cms/components.ts index aaee0490c..7e48f7df7 100644 --- a/server/handlers/cms/components.ts +++ b/server/handlers/cms/components.ts @@ -31,5 +31,5 @@ export async function handleComponentsRoutes(req: Request, db: DbClient): Promis const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - return siteCollectionRowsResponse(db, url, 'components') + return siteCollectionRowsResponse(db, 'components') } diff --git a/server/handlers/cms/layouts.ts b/server/handlers/cms/layouts.ts index aba05e728..cb42d825e 100644 --- a/server/handlers/cms/layouts.ts +++ b/server/handlers/cms/layouts.ts @@ -31,5 +31,5 @@ export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise Promise } export function requestAuditContext(req: Request): { ipAddress: string | null; userAgent: string | null } { @@ -46,22 +48,12 @@ export function requestAuditContext(req: Request): { ipAddress: string | null; u /** * `{ rows }` response for one of the three site collection GETs - * (pages / components / layouts), honoring the optional `?id=` - * single-row filter — the conflict banner's "Load theirs" fetch. A - * soft-deleted or foreign-table id yields `{ rows: [] }`: absence is the - * "deleted remotely" signal, and the table check keeps the endpoint from - * leaking rows of other data tables. + * (pages / components / layouts). */ export async function siteCollectionRowsResponse( db: DbClient, - url: URL, tableId: 'pages' | 'components' | 'layouts', ): Promise { - const id = url.searchParams.get('id') - if (id !== null) { - const row = await getDataRow(db, id) - return jsonResponse({ rows: row && row.tableId === tableId ? [row] : [] }) - } return jsonResponse({ rows: await listDataRows(db, tableId) }) } diff --git a/server/index.ts b/server/index.ts index e8daf5dcd..fce424a2a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -113,6 +113,7 @@ const server = Bun.serve({ staticDir: config.staticDir, uploadsDir: config.uploadsDir, databaseUrl: config.databaseUrl, + flushCollabDocs: () => collabRelay.flushAll(), }) for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) diff --git a/server/router.ts b/server/router.ts index 8945ccec4..82a388ceb 100644 --- a/server/router.ts +++ b/server/router.ts @@ -28,6 +28,12 @@ interface ServerRuntime { db: DbClient staticDir?: string uploadsDir?: string + /** + * Flush the collab relay's debounced persists to the DB. Wired by the + * server boot; handlers that read-then-act on the stored draft (publish) + * call it so the snapshot includes edits still inside the debounce window. + */ + flushCollabDocs?: () => Promise /** * The raw `DATABASE_URL` the server booted with — forwarded down to * CMS handlers that need to resolve the on-disk SQLite file (e.g. the @@ -152,6 +158,7 @@ function tryServeCmsApi(req: Request, runtime: ServerRuntime, _url: URL, pathnam return handleCmsRequest(req, runtime.db, { uploadsDir: runtime.uploadsDir, databaseUrl: runtime.databaseUrl, + flushCollabDocs: runtime.flushCollabDocs, }) } diff --git a/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts b/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts index 3e925d352..9ec213c98 100644 --- a/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts +++ b/src/__tests__/editor-store/deleteNodesDepthOrder.test.ts @@ -74,7 +74,6 @@ describe('deleteNodes — batch ordering and routing', () => { const childB = useEditorStore.getState().insertNode('base.text', { text: 'b' }, container) const sibling = useEditorStore.getState().insertNode('base.text', { text: 's' }, rootId) const baseline = Object.keys(useEditorStore.getState().site!.pages[0].nodes).length - const depthBefore = useEditorStore.getState()._historyPast.length // Parent first — depth-DESC ordering must delete the leaves first so the // already-deleted descendants hit the "node not found" guard cleanly. @@ -86,9 +85,8 @@ describe('deleteNodes — batch ordering and routing', () => { expect(nodes[childB]).toBeUndefined() expect(nodes[sibling]).toBeUndefined() expect(Object.keys(nodes).length).toBe(baseline - 4) - expect(useEditorStore.getState()._historyPast.length).toBe(depthBefore + 1) - // One undo restores the whole batch. + // One undo restores the whole batch — the deletion was a single step. useEditorStore.getState().undo() const restored = useEditorStore.getState().site!.pages[0].nodes expect(Object.keys(restored).length).toBe(baseline) diff --git a/src/__tests__/editor-store/dirtyTracking.test.ts b/src/__tests__/editor-store/dirtyTracking.test.ts deleted file mode 100644 index b8f69e0fa..000000000 --- a/src/__tests__/editor-store/dirtyTracking.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * Patch-derived save-dirty tracking (slices/site/dirtyTracking.ts). - * - * Unit half: collectDirtyFromSitePatches resolves site-relative Mutative - * patch paths to page / Visual Component ids against the POST-mutation site, - * derives DELETED row ids from the pre/post membership diff (robust to every - * recipe style — splice, filter-reassign, wholesale replace), and escalates - * anything unattributable to `all` (over-marking is safe, under-marking - * loses edits). - * - * Store half: the real editor store wires the collector into - * runHistoricMutation, undo/redo, the lifecycle resets, and the - * `_dirtySave` snapshot/restore actions consumed by autosave — including - * the snapshot-time netting rule (a row that exists again is never shipped - * as deleted; a write-mark for a gone row is dropped). - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import type { Patches } from 'mutative' -import { useEditorStore } from '@site/store/store' -import { - collectDirtyFromSitePatches, - emptyDirtyMarks, - mergeDirtyMarks, - type DirtyMarks, -} from '@site/store/slices/site/dirtyTracking' -import { makeNode, makePage, makeSite, makeVC } from '../fixtures' - -// --------------------------------------------------------------------------- -// Unit: collectDirtyFromSitePatches -// --------------------------------------------------------------------------- - -function twoPageTwoVcSite() { - return makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - visualComponents: [ - makeVC({ id: 'vc-one', name: 'One' }), - makeVC({ id: 'vc-two', name: 'Two' }), - ], - }) -} - -describe('collectDirtyFromSitePatches', () => { - it('attributes a nested page edit to that page id', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['pages', 1, 'nodes', 'root', 'props', 'text'], value: 'x' }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-b']) - expect(marks.componentIds.size).toBe(0) - }) - - it('attributes a VC tree edit to that component id', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['visualComponents', 0, 'tree', 'nodes', 'vc-root', 'props', 'x'], value: 1 }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.componentIds]).toEqual(['vc-one']) - }) - - it('attributes a remove at exactly [pages, i] to the deleted page id (membership diff)', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: pre.visualComponents, - }) - const patches: Patches = [{ op: 'remove', path: ['pages', 1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.deletedPageIds]).toEqual(['page-b']) - }) - - it('attributes a remove at exactly [visualComponents, i] to the deleted component id', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: pre.pages, - visualComponents: [makeVC({ id: 'vc-one', name: 'One' })], - }) - const patches: Patches = [{ op: 'remove', path: ['visualComponents', 1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect(marks.componentIds.size).toBe(0) - expect([...marks.deletedComponentIds]).toEqual(['vc-two']) - }) - - it('escalates a wholesale [pages] replacement to all AND still attributes deletions', () => { - const pre = twoPageTwoVcSite() - const post = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: pre.visualComponents, - }) - const patches: Patches = [{ op: 'replace', path: ['pages'], value: post.pages }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(true) - expect([...marks.deletedPageIds]).toEqual(['page-b']) - }) - - it('marks nothing for [pages, length] bookkeeping when membership is unchanged', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [{ op: 'replace', path: ['pages', 'length'], value: 1 }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks).toEqual(emptyDirtyMarks()) - }) - - it('escalates an index that does not resolve in the post-state to all', () => { - const site = twoPageTwoVcSite() // 2 pages — index 5 resolves to nothing - const patches: Patches = [{ op: 'replace', path: ['pages', 5, 'title'], value: 'ghost' }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(true) - }) - - it('escalates a non-numeric, non-length second segment to all', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [{ op: 'replace', path: ['pages', 'not-an-index'], value: 1 }] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(true) - }) - - it('marks the SHELL (no row marks) for shell-field paths — feeds the live-sync merge rule', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['styleRules', 'x'], value: {} }, - { op: 'replace', path: ['name'], value: 'Renamed' }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks).toEqual({ ...emptyDirtyMarks(), shell: true }) - }) - - it('does NOT mark the shell for updatedAt — bumped by every mutation, bookkeeping not content', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [{ op: 'replace', path: ['updatedAt'], value: 123 }] - expect(collectDirtyFromSitePatches(patches, site, site)).toEqual(emptyDirtyMarks()) - }) - - it('attributes an element add at [pages, i] to the added page', () => { - const post = twoPageTwoVcSite() - const pre = makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index', title: 'Home' })], - visualComponents: post.visualComponents, - }) - const patches: Patches = [{ op: 'add', path: ['pages', 1], value: post.pages[1] }] - const marks = collectDirtyFromSitePatches(patches, pre, post) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-b']) - expect(marks.deletedPageIds.size).toBe(0) - }) - - it('accumulates marks across a mixed patch set', () => { - const site = twoPageTwoVcSite() - const patches: Patches = [ - { op: 'replace', path: ['pages', 0, 'title'], value: 'New Home' }, - { op: 'replace', path: ['visualComponents', 1, 'name'], value: 'Two v2' }, - { op: 'replace', path: ['styleRules', 'r1'], value: {} }, - ] - const marks = collectDirtyFromSitePatches(patches, site, site) - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect([...marks.componentIds]).toEqual(['vc-two']) - }) -}) - -describe('mergeDirtyMarks', () => { - it('unions write and deleted ids and propagates the all flag', () => { - const target: DirtyMarks = { - ...emptyDirtyMarks(), - pageIds: new Set(['page-a']), - componentIds: new Set(['vc-one']), - deletedPageIds: new Set(['page-gone']), - } - mergeDirtyMarks(target, { - ...emptyDirtyMarks(), - pageIds: new Set(['page-b']), - deletedLayoutIds: new Set(['layout-gone']), - }) - expect(target.all).toBe(false) - expect([...target.pageIds].sort()).toEqual(['page-a', 'page-b']) - expect([...target.componentIds]).toEqual(['vc-one']) - expect([...target.deletedPageIds]).toEqual(['page-gone']) - expect([...target.deletedLayoutIds]).toEqual(['layout-gone']) - - mergeDirtyMarks(target, { ...emptyDirtyMarks(), all: true }) - expect(target.all).toBe(true) - // Existing ids survive an all-merge — `all` is a flag, not a reset. - expect([...target.pageIds].sort()).toEqual(['page-a', 'page-b']) - }) - - it('never clears all once set, even when incoming is partial', () => { - const target: DirtyMarks = { ...emptyDirtyMarks(), all: true } - mergeDirtyMarks(target, { ...emptyDirtyMarks(), pageIds: new Set(['page-a']) }) - expect(target.all).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// Store integration: the real editor store -// --------------------------------------------------------------------------- - -function freshStore() { - useEditorStore.setState({ - site: null, - activePageId: null, - activeDocument: null, - selectedNodeId: null, - selectedNodeIds: [], - hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - _dirtySave: emptyDirtyMarks(), - } as Parameters[0]) -} - -function dirty(): DirtyMarks { - return useEditorStore.getState()._dirtySave -} - -function loadTwoPageSite() { - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - visualComponents: [makeVC({ id: 'vc-card', name: 'Card' })], - }), - ) -} - -describe('editor store dirty-save tracking', () => { - beforeEach(freshStore) - - it('loadSite starts with empty marks', () => { - loadTwoPageSite() - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('updateNodeProps on the active page marks exactly that page', () => { - loadTwoPageSite() - // loadSite activates the home page (slug `index`) — page-a. - expect(useEditorStore.getState().activePageId).toBe('page-a') - - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect(marks.componentIds.size).toBe(0) - }) - - it('editing a VC tree in VC canvas mode marks exactly that component', () => { - loadTwoPageSite() - useEditorStore.getState().setActiveDocument({ kind: 'visualComponent', vcId: 'vc-card' }) - - useEditorStore.getState().updateNodeProps('vc-root', { padding: '8px' }) - - const marks = dirty() - expect(marks.all).toBe(false) - expect(marks.pageIds.size).toBe(0) - expect([...marks.componentIds]).toEqual(['vc-card']) - }) - - it('componentizing a page node marks both the edited page and the new component', () => { - const sourceNode = makeNode({ id: 'source-text', moduleId: 'base.text' }) - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ - id: 'page-a', - slug: 'index', - title: 'Home', - nodes: { - root: makeNode({ id: 'root', moduleId: 'base.body', children: ['source-text'] }), - 'source-text': sourceNode, - }, - }), - ], - visualComponents: [], - }), - ) - - useEditorStore.getState().convertNodeToComponent('source-text', 'Saved Card') - - const state = useEditorStore.getState() - const createdComponent = state.site!.visualComponents.find((vc) => vc.name === 'Saved Card') - expect(createdComponent).toBeDefined() - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual(['page-a']) - expect([...marks.componentIds]).toEqual([createdComponent!.id]) - }) - - it('takeDirtySaveSnapshot returns the accumulated marks AND resets the accumulator', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - - expect([...snapshot.pageIds]).toEqual(['page-a']) - expect(snapshot.all).toBe(false) - expect(dirty()).toEqual(emptyDirtyMarks()) - - // The snapshot is an independent copy — mutating it cannot poison the store. - snapshot.pageIds.add('page-injected') - expect(dirty().pageIds.size).toBe(0) - }) - - it('undo after a snapshot re-marks the restored page', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - useEditorStore.getState().takeDirtySaveSnapshot() // simulate a successful save - expect(dirty()).toEqual(emptyDirtyMarks()) - - useEditorStore.getState().undo() - - // The undone page differs from what storage now holds — it must re-save. - expect([...dirty().pageIds]).toEqual(['page-a']) - expect(dirty().all).toBe(false) - }) - - it('redo after a snapshot re-marks the replayed page', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - useEditorStore.getState().undo() - useEditorStore.getState().takeDirtySaveSnapshot() - - useEditorStore.getState().redo() - - expect([...dirty().pageIds]).toEqual(['page-a']) - }) - - it('restoreDirtySaveSnapshot merges a failed save back into fresh marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - - // While the (failed) save was in flight, the user edited page-b. - useEditorStore.setState({ activePageId: 'page-b' }) - useEditorStore.getState().updateNodeProps('root', { text: 'other page' }) - expect([...dirty().pageIds]).toEqual(['page-b']) - - useEditorStore.getState().restoreDirtySaveSnapshot(snapshot) - - expect([...dirty().pageIds].sort()).toEqual(['page-a', 'page-b']) - expect(dirty().all).toBe(false) - }) - - it('markAllDirtyForSave sets the conservative full-save flag', () => { - loadTwoPageSite() - useEditorStore.getState().markAllDirtyForSave() - expect(dirty().all).toBe(true) - }) - - it('loadSite resets accumulated marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - expect(dirty().pageIds.size).toBe(1) - - loadTwoPageSite() - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('createSite resets marks to all=true — a brand-new site needs a full first save', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - useEditorStore.getState().createSite('Fresh Site') - - const marks = dirty() - expect(marks.all).toBe(true) - expect(marks.pageIds.size).toBe(0) - expect(marks.componentIds.size).toBe(0) - }) - - it('clearSite resets marks to empty', () => { - loadTwoPageSite() - useEditorStore.getState().updateNodeProps('root', { text: 'hello' }) - - useEditorStore.getState().clearSite() - - expect(dirty()).toEqual(emptyDirtyMarks()) - }) - - it('addPage marks the created page (and only it)', () => { - loadTwoPageSite() - const newPage = useEditorStore.getState().addPage('Pricing', 'pricing') - - const marks = dirty() - expect(marks.all).toBe(false) - expect([...marks.pageIds]).toEqual([newPage.id]) - expect(marks.componentIds.size).toBe(0) - }) - - it('deletePage records the deleted id — never as a changed page, never as all', () => { - loadTwoPageSite() - // Delete the LAST page so no surviving element is displaced to a new index. - useEditorStore.getState().deletePage('page-b') - - const state = useEditorStore.getState() - expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) - // The deleted page must NOT appear as a changed page — its removal ships - // as an explicit deleted-id. - expect(dirty().pageIds.has('page-b')).toBe(false) - expect([...dirty().deletedPageIds]).toEqual(['page-b']) - expect(dirty().all).toBe(false) - }) - - it('a VC delete records the deleted component id without escalating to all', () => { - useEditorStore.getState().loadSite( - makeSite({ - pages: [makePage({ id: 'page-a', slug: 'index' })], - visualComponents: [ - makeVC({ id: 'vc-one', name: 'One' }), - makeVC({ id: 'vc-two', name: 'Two' }), - ], - }), - ) - - // Delete the LAST component — no displaced survivors. - useEditorStore.getState().deleteVisualComponent('vc-two') - - const state = useEditorStore.getState() - expect(state.site!.visualComponents.map((vc) => vc.id)).toEqual(['vc-one']) - expect(dirty().all).toBe(false) - expect(dirty().componentIds.has('vc-two')).toBe(false) - expect([...dirty().deletedComponentIds]).toEqual(['vc-two']) - }) - - it('snapshot netting: delete + undo within one save window ships a write, not a delete', () => { - loadTwoPageSite() - useEditorStore.getState().deletePage('page-b') - expect([...dirty().deletedPageIds]).toEqual(['page-b']) - - useEditorStore.getState().undo() // page-b is back in the store - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - // The row exists again — it must NOT ship as deleted… - expect(snapshot.deletedPageIds.size).toBe(0) - // …and the restored content ships as a plain write. - expect(snapshot.pageIds.has('page-b')).toBe(true) - }) - - it('snapshot netting: a write-mark for a row deleted afterwards is dropped', () => { - loadTwoPageSite() - // Edit page-b, THEN delete it — shipping it as changed would resurrect it. - useEditorStore.setState({ activePageId: 'page-b' }) - useEditorStore.getState().updateNodeProps('root', { text: 'edited' }) - expect(dirty().pageIds.has('page-b')).toBe(true) - - useEditorStore.getState().deletePage('page-b') - - const snapshot = useEditorStore.getState().takeDirtySaveSnapshot() - expect(snapshot.pageIds.has('page-b')).toBe(false) - expect([...snapshot.deletedPageIds]).toEqual(['page-b']) - }) - - it('shell-only mutations (site rename) accumulate the shell mark and NO row marks', () => { - loadTwoPageSite() - useEditorStore.getState().updateSiteName('Renamed Site') - - expect(useEditorStore.getState().site!.name).toBe('Renamed Site') - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) - expect(dirty()).toEqual({ ...emptyDirtyMarks(), shell: true }) - }) -}) diff --git a/src/__tests__/editor-store/historyCoalescingFold.test.ts b/src/__tests__/editor-store/historyCoalescingFold.test.ts deleted file mode 100644 index 135fa03d7..000000000 --- a/src/__tests__/editor-store/historyCoalescingFold.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * History coalescing fold — per-path patch dedup inside a typing burst. - * - * `commitHistory` folds consecutive same-`coalesceKey` entries into the top - * history entry. The fold must keep AT MOST one inverse and one forward patch - * per touched path (oldest inverse wins, newest forward value wins) instead of - * accumulating 2K patch pairs over a K-keystroke burst — while keeping - * undo/redo results bit-identical: - * - * 1. Burst on an existing prop: undo → original value, redo → final value. - * 2. Burst that CREATES a prop (add-op first keystroke): undo → prop absent, - * redo (from the prop-absent state) → final value. - * 3. Burst touching two paths (the prop + the `site.updatedAt` stamp that - * `runHistoricMutation` appends): both paths revert and replay correctly. - * 4. Size bound: a 100-keystroke burst leaves at most one patch per touched - * path per direction in the top entry. - * 5. Pin the Mutative `apply()` behavior the fold relies on: 'add' and - * 'replace' are interchangeable for plain-object keys. - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import { apply } from 'mutative' -import type { Patches } from 'mutative' -import { useEditorStore } from '@site/store/store' -import '@modules/base/index' - -function freshStore(): void { - useEditorStore.setState({ - site: null, - activePageId: null, - activeDocument: null, - selectedNodeId: null, - selectedNodeIds: [], - hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - } as Parameters[0]) -} - -beforeEach(freshStore) - -/** Create a site with one text node and return its id. */ -function setupTextNode(initialProps: Record = { text: '' }): string { - const site = useEditorStore.getState().createSite('Fold Test') - const rootId = site.pages[0].rootNodeId - return useEditorStore.getState().insertNode('base.text', initialProps, rootId) -} - -function pathKey(path: Patches[number]['path']): string { - return JSON.stringify(path) -} - -describe('history coalescing — fold correctness', () => { - it('burst on an existing prop: undo → original value, redo → final value', () => { - const nodeId = setupTextNode({ text: 'original' }) - - for (const text of ['a', 'ab', 'abc', 'abcd']) { - useEditorStore.getState().updateNodeProps(nodeId, { text }) - } - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('abcd') - - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('original') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('abcd') - }) - - it('burst that CREATES a prop: undo → prop absent, redo → final value', () => { - const nodeId = setupTextNode({ text: 'hello' }) - - // `title` does not exist on the node yet — the first keystroke's forward - // patch is 'add'-shaped, and the folded forward patch must stay applicable - // from the post-undo state where the prop is absent. - for (const title of ['t', 'ti', 'tit', 'title']) { - useEditorStore.getState().updateNodeProps(nodeId, { title }) - } - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.title).toBe('title') - - useEditorStore.getState().undo() - const propsAfterUndo = useEditorStore.getState().site!.pages[0].nodes[nodeId].props - expect('title' in propsAfterUndo).toBe(false) - expect(propsAfterUndo.text).toBe('hello') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.title).toBe('title') - }) - - it("the folded forward patch for a burst-created prop keeps the oldest 'add' op", () => { - const nodeId = setupTextNode({ text: 'hello' }) - for (const title of ['t', 'ti', 'tit']) { - useEditorStore.getState().updateNodeProps(nodeId, { title }) - } - - const top = useEditorStore.getState()._historyPast.at(-1)! - const titlePatch = top.forward.find((p) => Array.isArray(p.path) && p.path.at(-1) === 'title') - expect(titlePatch).toBeDefined() - expect(titlePatch!.op).toBe('add') - expect(titlePatch!.value).toBe('tit') - }) - - it('burst touching two paths: prop AND site.updatedAt both revert and replay', () => { - const nodeId = setupTextNode({ text: 'start' }) - - // Force a sentinel timestamp so the pre-burst value is distinguishable - // from anything Date.now() can produce during the burst. - const site = useEditorStore.getState().site! - useEditorStore.setState({ site: { ...site, updatedAt: 1000 } }) - - for (const text of ['x', 'xy', 'xyz']) { - useEditorStore.getState().updateNodeProps(nodeId, { text }) - } - const updatedAtAfterBurst = useEditorStore.getState().site!.updatedAt - expect(updatedAtAfterBurst).not.toBe(1000) - - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.updatedAt).toBe(1000) - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('start') - - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.updatedAt).toBe(updatedAtAfterBurst) - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('xyz') - }) - - it('undo + redo round-trips a burst bit-identically', () => { - const nodeId = setupTextNode({ text: 'seed' }) - for (let i = 1; i <= 10; i++) { - useEditorStore.getState().updateNodeProps(nodeId, { text: 'seed'.repeat(i) }) - } - - const afterBurst = JSON.stringify(useEditorStore.getState().site) - useEditorStore.getState().undo() - useEditorStore.getState().redo() - expect(JSON.stringify(useEditorStore.getState().site)).toBe(afterBurst) - }) -}) - -describe('history coalescing — fold size bound', () => { - it('a 100-keystroke burst keeps at most one patch per path per direction', () => { - const nodeId = setupTextNode({ text: '' }) - const depthBefore = useEditorStore.getState()._historyPast.length - - for (let i = 1; i <= 100; i++) { - useEditorStore.getState().updateNodeProps(nodeId, { text: 'x'.repeat(i) }) - } - - const past = useEditorStore.getState()._historyPast - // Still exactly one coalesced entry for the whole burst. - expect(past.length).toBe(depthBefore + 1) - - const top = past.at(-1)! - for (const direction of [top.inverse, top.forward]) { - const paths = direction.map((p) => pathKey(p.path)) - // No duplicate paths — the burst touched 2 paths (the prop + updatedAt), - // so each direction holds at most 2 patches, not ~200. - expect(new Set(paths).size).toBe(paths.length) - expect(direction.length).toBeLessThanOrEqual(2) - } - - // The fold must not have broken undo/redo. - useEditorStore.getState().undo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') - useEditorStore.getState().redo() - expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('x'.repeat(100)) - }) -}) - -describe('mutative apply() op semantics the fold relies on', () => { - it("treats 'add' and 'replace' identically for plain-object keys", () => { - // Verified against mutative@1.3.0 src/apply.ts: for plain objects both ops - // run `base[key] = value`. (They differ ONLY for array indices, where - // 'add' splices — coalescing recipes never patch array tails.) This pin - // fails if a mutative upgrade changes that contract. - const base = { props: { existing: 1 } as Record } - const viaAdd = apply(base, [{ op: 'add', path: ['props', 'k'], value: 'v' }] as Patches) - const viaReplace = apply(base, [{ op: 'replace', path: ['props', 'k'], value: 'v' }] as Patches) - expect(viaAdd).toEqual(viaReplace) - - // 'remove' deletes the key, and deleting an absent key is a no-op — the - // fold may therefore collapse any op sequence involving 'remove' to the - // newest patch wholesale. - const removed = apply(base, [{ op: 'remove', path: ['props', 'absent'] }] as Patches) - expect(removed).toEqual(base) - }) -}) diff --git a/src/__tests__/editor-store/inlineEditSlice.test.ts b/src/__tests__/editor-store/inlineEditSlice.test.ts index 02517eadb..3b5de3109 100644 --- a/src/__tests__/editor-store/inlineEditSlice.test.ts +++ b/src/__tests__/editor-store/inlineEditSlice.test.ts @@ -30,20 +30,15 @@ function nodeText(nodeId: string): unknown { } beforeEach(() => { + // clearSite also resets the collab binding's docs + undo history. + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, activeDocument: null, activeInlineEdit: null, - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - canUndo: false, - canRedo: false, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) }) @@ -120,17 +115,17 @@ describe('startInlineEdit', () => { }) describe('applyInlineEditValue — live commit, one undo entry per burst', () => { - it('commits every keystroke live and coalesces the burst into ONE history entry', () => { + it('commits every keystroke live and coalesces the burst into ONE undo step', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().applyInlineEditValue('HelloW') useEditorStore.getState().applyInlineEditValue('HelloWo') useEditorStore.getState().applyInlineEditValue('HelloWorld') - const state = useEditorStore.getState() expect(nodeText(nodeId)).toBe('HelloWorld') - expect(state._historyPast.length).toBe(entriesBefore + 1) - expect(state.activeInlineEdit?.committed).toBe(true) + expect(useEditorStore.getState().activeInlineEdit?.committed).toBe(true) + // ONE undo reverts the whole burst — not one keystroke. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBe('Hello') }) it('a single undo() reverts the whole burst to the initial value', () => { @@ -145,21 +140,21 @@ describe('applyInlineEditValue — live commit, one undo entry per burst', () => it('does not flip committed when the applied value equals the stored value', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().applyInlineEditValue('Hello') const state = useEditorStore.getState() expect(state.activeInlineEdit?.committed).toBe(false) - expect(state._historyPast.length).toBe(entriesBefore) + // No history entry was pushed: undo reverts the node INSERT, not a + // (nonexistent) equal-value edit. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) it('isolates the session burst from a prior Properties-panel burst on the same prop', () => { const { nodeId } = setupSiteWithTextNode() // Simulate panel typing: same coalesce key the inline session will use. useEditorStore.getState().updateNodeProps(nodeId, { text: 'PanelTyped' }) - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('PanelTypedX') - expect(useEditorStore.getState()._historyPast.length).toBe(entriesBefore + 1) // Escape reverts ONLY the inline burst, not the panel typing. useEditorStore.getState().cancelInlineEdit() expect(nodeText(nodeId)).toBe('PanelTyped') @@ -177,13 +172,14 @@ describe('endInlineEdit', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('HelloA') - const entriesAfterBurst = useEditorStore.getState()._historyPast.length useEditorStore.getState().endInlineEdit() expect(useEditorStore.getState().activeInlineEdit).toBeNull() expect(nodeText(nodeId)).toBe('HelloA') - // A later edit of the SAME prop starts a fresh undo entry. + // A later edit of the SAME prop starts a fresh undo entry: the first + // undo reverts only it, back to the session's committed value. useEditorStore.getState().updateNodeProps(nodeId, { text: 'HelloB' }) - expect(useEditorStore.getState()._historyPast.length).toBe(entriesAfterBurst + 1) + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBe('HelloA') }) }) @@ -192,23 +188,26 @@ describe('cancelInlineEdit', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') useEditorStore.getState().applyInlineEditValue('Mangled') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().cancelInlineEdit() const state = useEditorStore.getState() expect(state.activeInlineEdit).toBeNull() expect(nodeText(nodeId)).toBe('Hello') - expect(state._historyPast.length).toBe(entriesBefore - 1) + // The cancel consumed the burst's undo step: the next undo reverts the + // node insert itself. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) it('does NOT undo for an uncommitted session', () => { const { nodeId } = setupSiteWithTextNode() useEditorStore.getState().startInlineEdit(nodeId, 'bp') - const entriesBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().cancelInlineEdit() const state = useEditorStore.getState() expect(state.activeInlineEdit).toBeNull() - expect(state._historyPast.length).toBe(entriesBefore) expect(nodeText(nodeId)).toBe('Hello') + // History untouched: the next undo reverts the node insert. + useEditorStore.getState().undo() + expect(nodeText(nodeId)).toBeUndefined() }) }) diff --git a/src/__tests__/editor-store/layoutsSlice.test.ts b/src/__tests__/editor-store/layoutsSlice.test.ts index ea9478827..1c135c416 100644 --- a/src/__tests__/editor-store/layoutsSlice.test.ts +++ b/src/__tests__/editor-store/layoutsSlice.test.ts @@ -3,7 +3,7 @@ * * Covers: * 1. saveNodeAsLayout: captures the subtree + referenced classes onto - * site.layouts (and marks the layout dirty for save). + * site.layouts. * 2. Guards: page root refused; duplicate names throw SavedLayoutNameError. * 3. insertLayout: exact structure restoration with fresh node ids * (paste semantics — props, classIds, child order survive). @@ -66,8 +66,6 @@ describe('layoutsSlice.saveNodeAsLayout', () => { expect(layout.nodes[textId].props.text).toBe('Saved copy') expect(layout.classes[classId]?.name).toBe('layout-style') - // Dirty tracking: the new layout ships on the next incremental save. - expect([...useEditorStore.getState()._dirtySave.layoutIds]).toContain(layoutId!) }) it('refuses to capture the page root', () => { diff --git a/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts b/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts index ae1a47e46..18f0647a8 100644 --- a/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts +++ b/src/__tests__/editor-store/mutateAllPagesAndSite.test.ts @@ -199,8 +199,6 @@ describe('mutateAllPagesAndSite — atomicity', () => { return true }) - const historyBefore = useEditorStore.getState()._historyPast.length - // Now run the four-helper recipe. useEditorStore.getState().mutateAllPagesAndSite((_site, helpers) => { helpers.addPage({ title: 'Added', slug: 'added', nodeFragment: makeFragment() }) @@ -210,8 +208,13 @@ describe('mutateAllPagesAndSite — atomicity', () => { return true }) - const historyAfter = useEditorStore.getState()._historyPast.length - expect(historyAfter - historyBefore).toBe(1) + // Exactly ONE history snapshot: a single undo reverts all four helpers. + useEditorStore.getState().undo() + const site = useEditorStore.getState().site! + expect(site.pages.some((p) => p.slug === 'added')).toBe(false) + expect(Object.values(site.styleRules).some((r) => r.name === 'new-rule')).toBe(false) + expect(site.pages.find((p) => p.id === existingPageId)?.title).toBe('Old') + expect(site.styleRules[existingRuleId]?.styles.color).toBeUndefined() }) it('undo after the four-helper recipe reverts ALL four mutations in one press', () => { diff --git a/src/__tests__/editor-store/nodeInlineStyles.test.ts b/src/__tests__/editor-store/nodeInlineStyles.test.ts index 24d7a6423..f97ea7d37 100644 --- a/src/__tests__/editor-store/nodeInlineStyles.test.ts +++ b/src/__tests__/editor-store/nodeInlineStyles.test.ts @@ -9,8 +9,8 @@ import { useEditorStore } from '@site/store/store' import '@modules/base/index' function freshStore() { + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, selectedNodeId: null, selectedNodeIds: [], @@ -20,11 +20,6 @@ function freshStore() { activeClassId: null, previewClassAssignment: null, propertiesPanel: { collapsed: false, x: 0, y: 0, width: 280 }, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, } as Parameters[0]) } @@ -72,25 +67,28 @@ describe('setNodeInlineStyles', () => { it('clearNodeInlineStyles removes the whole inlineStyles field in one step', () => { const id = setup() useEditorStore.getState().setNodeInlineStyles(id, { color: 'red', display: 'flex' }) - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().clearNodeInlineStyles(id) expect(nodeInline(id)).toBeUndefined() - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore + 1) + // ONE undo restores the full style bag — the clear was a single step. + useEditorStore.getState().undo() + expect(nodeInline(id)).toEqual({ color: 'red', display: 'flex' }) }) it('clearNodeInlineStyles is a no-op (no history) when there are no inline styles', () => { const id = setup() - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().clearNodeInlineStyles(id) - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore) + // No entry was pushed: the next undo reverts the node INSERT itself. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[id]).toBeUndefined() }) it('a no-op patch (removing an absent key) records no change', () => { const id = setup() - const historyBefore = useEditorStore.getState()._historyPast.length useEditorStore.getState().removeNodeInlineStyleProperty(id, 'color') - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore) + // No entry was pushed: the next undo reverts the node INSERT itself. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[id]).toBeUndefined() }) it('setInlineStyleEditing(true) clears the active class (mutually exclusive)', () => { @@ -129,7 +127,6 @@ describe('setNodeInlineStyles', () => { const id = setup() // Simulate "display: flex" then setting a flex sub-property. useEditorStore.getState().setNodeInlineStyles(id, { display: 'flex', alignItems: 'center' }) - const historyBefore = useEditorStore.getState()._historyPast.length // Clearing display must prune the now-orphaned flex property too — one step. useEditorStore.getState().setNodeInlineStyles(id, { @@ -140,6 +137,8 @@ describe('setNodeInlineStyles', () => { }) expect(nodeInline(id)).toBeUndefined() - expect(useEditorStore.getState()._historyPast.length).toBe(historyBefore + 1) + // ONE undo restores both pruned properties — the clear was a single step. + useEditorStore.getState().undo() + expect(nodeInline(id)).toEqual({ display: 'flex', alignItems: 'center' }) }) }) diff --git a/src/__tests__/editor-store/saveConflicts.test.ts b/src/__tests__/editor-store/saveConflicts.test.ts deleted file mode 100644 index 2bf859e40..000000000 --- a/src/__tests__/editor-store/saveConflicts.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Save-conflict resolution + base-seq bookkeeping (multi-admin level A). - * - * Covers the store half of the conflict protocol: - * - seedBaseSeqs / commitSavedBaseSeqs — the conflict-detection bases every - * incremental save ships, seeded at load and bumped on success (deleted - * rows KEEP their entries so a resurrect-by-undo carries the right base), - * - resolveSaveConflictKeepMine — bumps the target's base seq so the next - * save overwrites as a stated decision; dirty marks stay untouched, - * - applyRemoteSnapshot — swaps the remote version in (or removes a - * remotely-deleted target), clears the target's dirty marks, syncs the - * base seq, clears the undo history, and — for Visual Components — - * propagates to consumer pages with REAL dirty marks (slot re-sync / - * ref-cascade removal). - */ -import { describe, it, expect, beforeEach } from 'bun:test' -import { useEditorStore } from '@site/store/store' -import { emptyDirtyMarks } from '@site/store/slices/site/dirtyTracking' -import { makeNode, makePage, makeSite, makeVC } from '../fixtures' - -function loadFixtureSite(overrides: Parameters[0] = {}) { - useEditorStore.getState().loadSite(makeSite(overrides)) -} - -beforeEach(() => { - useEditorStore.getState().clearSite() -}) - -// --------------------------------------------------------------------------- -// Base-seq bookkeeping -// --------------------------------------------------------------------------- - -describe('base-seq bookkeeping', () => { - it('seedBaseSeqs replaces the maps wholesale', () => { - loadFixtureSite() - useEditorStore.getState().seedBaseSeqs({ 'page-1': 4, 'vc-1': 5 }, 6) - expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-1': 4, 'vc-1': 5 }) - expect(useEditorStore.getState().shellBaseSeq).toBe(6) - - useEditorStore.getState().seedBaseSeqs({ 'page-2': 9 }, 10) - expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-2': 9 }) - expect(useEditorStore.getState().shellBaseSeq).toBe(10) - }) - - it('commitSavedBaseSeqs (incremental) bumps exactly the shipped ids — deleted rows keep entries', () => { - loadFixtureSite({ pages: [makePage({ id: 'page-a' }), makePage({ id: 'page-b', slug: 'b' })] }) - useEditorStore.getState().seedBaseSeqs({ 'page-a': 1, 'page-b': 1, 'vc-gone': 1 }, 1) - - const dirty = { - ...emptyDirtyMarks(), - pageIds: new Set(['page-a']), - deletedComponentIds: new Set(['vc-gone']), - } - useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, dirty, 7) - - const { baseSeqs, shellBaseSeq } = useEditorStore.getState() - expect(baseSeqs['page-a']).toBe(7) - // Deleted rows keep a base at the delete-save's seq: an undo that - // resurrects the row must not read as a blind overwrite. - expect(baseSeqs['vc-gone']).toBe(7) - // Unshipped rows keep their old base. - expect(baseSeqs['page-b']).toBe(1) - expect(shellBaseSeq).toBe(7) - }) - - it('commitSavedBaseSeqs (replace) rebuilds the map from the shipped site', () => { - loadFixtureSite({ pages: [makePage({ id: 'page-a' })] }) - useEditorStore.getState().seedBaseSeqs({ 'stale-row': 3 }, 3) - - useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, undefined, 9) - - expect(useEditorStore.getState().baseSeqs).toEqual({ 'page-a': 9 }) - expect(useEditorStore.getState().shellBaseSeq).toBe(9) - }) - - it('a successful save clears pending conflicts', () => { - loadFixtureSite() - useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-1', seq: 5 }]) - useEditorStore.getState().commitSavedBaseSeqs(useEditorStore.getState().site!, undefined, 9) - expect(useEditorStore.getState().saveConflicts).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// Keep mine -// --------------------------------------------------------------------------- - -describe('resolveSaveConflictKeepMine', () => { - it('bumps the row base to the remote seq and drops the conflict; dirty marks survive', () => { - loadFixtureSite() - useEditorStore.getState().seedBaseSeqs({ 'page-1': 2 }, 2) - useEditorStore.setState((s) => { - s._dirtySave.pageIds.add('page-1') - }) - useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-1', seq: 8 }]) - - useEditorStore.getState().resolveSaveConflictKeepMine({ table: 'pages', rowId: 'page-1', seq: 8 }) - - const state = useEditorStore.getState() - expect(state.saveConflicts).toEqual([]) - expect(state.baseSeqs['page-1']).toBe(8) - expect(state._dirtySave.pageIds.has('page-1')).toBe(true) - }) - - it('bumps the shell base for a site conflict', () => { - loadFixtureSite() - useEditorStore.getState().seedBaseSeqs({}, 2) - useEditorStore.getState().setSaveConflicts([{ table: 'site', rowId: 'default', seq: 11 }]) - - useEditorStore.getState().resolveSaveConflictKeepMine({ table: 'site', rowId: 'default', seq: 11 }) - - expect(useEditorStore.getState().shellBaseSeq).toBe(11) - expect(useEditorStore.getState().saveConflicts).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// Load theirs -// --------------------------------------------------------------------------- - -describe('applyRemoteSnapshot', () => { - it('swaps a remote page in, clears its dirty marks, syncs the base seq, clears history', () => { - loadFixtureSite({ pages: [makePage({ id: 'page-a', title: 'Mine' })] }) - useEditorStore.getState().seedBaseSeqs({ 'page-a': 1 }, 1) - // Local unsaved edit — real history + dirty marks. - useEditorStore.getState().renamePage('page-a', 'Mine (edited)') - expect(useEditorStore.getState().canUndo).toBe(true) - expect(useEditorStore.getState()._dirtySave.pageIds.has('page-a')).toBe(true) - useEditorStore.getState().setSaveConflicts([{ table: 'pages', rowId: 'page-a', seq: 6 }]) - - useEditorStore.getState().applyRemoteSnapshot({ - table: 'pages', - rowId: 'page-a', - row: makePage({ id: 'page-a', title: 'Theirs' }), - seq: 6, - }) - - const state = useEditorStore.getState() - expect(state.site!.pages.find((p) => p.id === 'page-a')!.title).toBe('Theirs') - expect(state._dirtySave.pageIds.has('page-a')).toBe(false) - expect(state.baseSeqs['page-a']).toBe(6) - expect(state.saveConflicts).toEqual([]) - // Site-relative history patches are undefined across a swapped tree. - expect(state.canUndo).toBe(false) - expect(state._historyPast).toEqual([]) - }) - - it('removes a remotely-deleted page and moves the active page off it', () => { - loadFixtureSite({ - pages: [ - makePage({ id: 'page-home', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'b', title: 'B' }), - ], - }) - useEditorStore.getState().seedBaseSeqs({ 'page-home': 1, 'page-b': 1 }, 1) - useEditorStore.getState().setActivePage('page-b') - - useEditorStore.getState().applyRemoteSnapshot({ - table: 'pages', - rowId: 'page-b', - row: null, - seq: 6, - }) - - const state = useEditorStore.getState() - expect(state.site!.pages.map((p) => p.id)).toEqual(['page-home']) - expect(state.activePageId).toBe('page-home') - expect(state.baseSeqs['page-b']).toBeUndefined() - }) - - it('swaps remote shell fields in place, leaving the row collections untouched', () => { - loadFixtureSite({ name: 'Mine', pages: [makePage({ id: 'page-a' })] }) - useEditorStore.getState().seedBaseSeqs({ 'page-a': 1 }, 1) - const remoteShell = (() => { - const { pages: _p, visualComponents: _v, layouts: _l, ...shell } = makeSite({ name: 'Theirs' }) - return shell - })() - - useEditorStore.getState().applyRemoteSnapshot({ - table: 'site', - shell: remoteShell, - seq: 12, - }) - - const state = useEditorStore.getState() - expect(state.site!.name).toBe('Theirs') - expect(state.site!.pages.map((p) => p.id)).toEqual(['page-a']) - expect(state.shellBaseSeq).toBe(12) - }) - - it('a remotely-deleted VC cascades ref removal into consumer pages WITH dirty marks', () => { - const vc = makeVC({ id: 'vc-1', name: 'Card' }) - const page = makePage({ - id: 'page-a', - nodes: { - root: makeNode({ id: 'root', moduleId: 'base.body', children: ['ref-1'] }), - 'ref-1': makeNode({ - id: 'ref-1', - moduleId: 'base.visual-component-ref', - props: { componentId: 'vc-1' }, - }), - }, - }) - loadFixtureSite({ pages: [page], visualComponents: [vc] }) - useEditorStore.getState().seedBaseSeqs({ 'page-a': 1, 'vc-1': 1 }, 1) - useEditorStore.getState().setSaveConflicts([{ table: 'components', rowId: 'vc-1', seq: 4 }]) - - useEditorStore.getState().applyRemoteSnapshot({ - table: 'components', - rowId: 'vc-1', - row: null, - seq: 4, - }) - - const state = useEditorStore.getState() - expect(state.site!.visualComponents).toEqual([]) - // The consumer page lost its ref node… - expect(state.site!.pages[0].nodes['ref-1']).toBeUndefined() - // …and now DIFFERS from storage, so it must ship with the next save. - expect(state._dirtySave.pageIds.has('page-a')).toBe(true) - expect(state.baseSeqs['vc-1']).toBeUndefined() - expect(state.saveConflicts).toEqual([]) - }) -}) diff --git a/src/__tests__/editor-store/siteRuntimeSlice.test.ts b/src/__tests__/editor-store/siteRuntimeSlice.test.ts index 09f428acf..cc8bd882c 100644 --- a/src/__tests__/editor-store/siteRuntimeSlice.test.ts +++ b/src/__tests__/editor-store/siteRuntimeSlice.test.ts @@ -13,7 +13,6 @@ function resetStore() { selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) } @@ -51,7 +50,7 @@ describe('site runtime store actions', () => { expect(useEditorStore.getState().site?.runtime).toEqual(useEditorStore.getState().siteRuntime) }) - it('patches script runtime settings, marks the project dirty, and participates in undo', () => { + it('patches script runtime settings and participates in undo', () => { const store = useEditorStore.getState() store.createSite('Runtime Site') const fileId = useEditorStore.getState().createFile('src/scripts/confetti.ts', 'script') @@ -60,7 +59,6 @@ describe('site runtime store actions', () => { _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, }) useEditorStore.getState().patchScriptRuntimeConfig(fileId, { @@ -70,7 +68,6 @@ describe('site runtime store actions', () => { }) const afterPatch = useEditorStore.getState() - expect(afterPatch.hasUnsavedChanges).toBe(true) expect(afterPatch.canUndo).toBe(true) expect(afterPatch.siteRuntime.scripts[fileId]).toEqual({ ...DEFAULT_SCRIPT_RUNTIME_CONFIG, @@ -107,19 +104,17 @@ describe('site runtime store actions', () => { expect(useEditorStore.getState().site?.runtime?.scripts[fileId]).toBeUndefined() }) - it('marks dependency manifest edits dirty and makes them undoable', () => { + it('makes dependency manifest edits undoable', () => { useEditorStore.getState().createSite('Runtime Site') useEditorStore.setState({ _historyPast: [], _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, }) useEditorStore.getState().setDependency('canvas-confetti', '^1.9.3') - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canUndo).toBe(true) expect(useEditorStore.getState().packageJson.dependencies['canvas-confetti']).toBe('^1.9.3') expect(useEditorStore.getState().site?.packageJson?.dependencies['canvas-confetti']).toBe('^1.9.3') diff --git a/src/__tests__/editor-store/styleRuleSlice.test.ts b/src/__tests__/editor-store/styleRuleSlice.test.ts index c18d877f6..f39a56acc 100644 --- a/src/__tests__/editor-store/styleRuleSlice.test.ts +++ b/src/__tests__/editor-store/styleRuleSlice.test.ts @@ -157,17 +157,20 @@ describe('styleRuleSlice.clearClassStyleProperties', () => { getStore().updateClassStyles(cls.id, { display: 'flex', alignItems: 'center', color: 'red' }) getStore().setClassContextStyles(cls.id, 'mobile', { gap: '8px' }) - const historyBefore = historyLength() getStore().clearClassStyleProperties(cls.id, ['display', 'alignItems', 'gap']) - const rule = useEditorStore.getState().site!.styleRules[cls.id] + let rule = useEditorStore.getState().site!.styleRules[cls.id] // Pruned everywhere; the unrelated `color` survives. expect('display' in rule.styles).toBe(false) expect('alignItems' in rule.styles).toBe(false) expect(rule.styles.color).toBe('red') expect('gap' in (rule.contextStyles.mobile ?? {})).toBe(false) - // Single undo step. - expect(historyLength()).toBe(historyBefore + 1) + // Single undo step: ONE undo restores base + context properties together. + getStore().undo() + rule = useEditorStore.getState().site!.styleRules[cls.id] + expect(rule.styles.display).toBe('flex') + expect(rule.styles.alignItems).toBe('center') + expect(rule.contextStyles.mobile?.gap).toBe('8px') }) it('is a no-op (no history) when none of the properties are set', () => { diff --git a/src/__tests__/editor-store/undo-redo.test.ts b/src/__tests__/editor-store/undo-redo.test.ts index 36a3b387c..0edd698d1 100644 --- a/src/__tests__/editor-store/undo-redo.test.ts +++ b/src/__tests__/editor-store/undo-redo.test.ts @@ -1,9 +1,11 @@ /** - * Undo/Redo store tests — verifies J4 requirements: - * - undo/redo operates only on site state - * - canUndo / canRedo flags stay accurate - * - history is capped at MAX_HISTORY (50) - * - undo then modify creates a new branch (future is cleared) + * Undo/Redo store tests. + * + * History lives in per-doc Y.UndoManagers inside the collab binding + * (slices/site/collabBinding.ts) — these tests pin down the OBSERVABLE + * contract through the store: undo/redo operates only on site state, + * canUndo/canRedo stay accurate, coalescing folds a typing burst into one + * step, and undo-then-modify clears the redo branch. */ import { describe, it, expect, beforeEach } from 'bun:test' import { useEditorStore } from '@site/store/store' @@ -14,17 +16,13 @@ function getStore() { } beforeEach(() => { - // Reset store to a clean slate before each test + // Reset store to a clean slate before each test. clearSite also resets the + // collab binding's docs + undo managers. + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - hasUnsavedChanges: false, }) }) @@ -148,7 +146,6 @@ describe('Undo / Redo — basic lifecycle', () => { useEditorStore.getState().insertNode('base.text', {}, rootId) expect(useEditorStore.getState().canRedo).toBe(false) - expect(useEditorStore.getState()._historyFuture).toHaveLength(0) }) it('multiple mutations are each individually undoable', () => { @@ -193,8 +190,6 @@ describe('Undo / Redo — basic lifecycle', () => { useEditorStore.getState().createSite('New SiteDocument') expect(useEditorStore.getState().canUndo).toBe(false) expect(useEditorStore.getState().canRedo).toBe(false) - expect(useEditorStore.getState()._historyPast).toHaveLength(0) - expect(useEditorStore.getState()._historyFuture).toHaveLength(0) }) it('canvas/UI state (zoom, panX) is not affected by undo', () => { @@ -223,31 +218,39 @@ describe('Undo / Redo — input coalescing', () => { it('coalesces consecutive same-prop edits into one undo entry', () => { const { nodeId } = setupTextNode() - const depthAfterInsert = useEditorStore.getState()._historyPast.length // Simulate per-keystroke typing on a single prop. for (const text of ['H', 'He', 'Hel', 'Hell', 'Hello']) { useEditorStore.getState().updateNodeProps(nodeId, { text }) } - - // The whole typing burst added exactly ONE history entry, not five. - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterInsert + 1) expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('Hello') - // A single undo reverts the entire burst back to the pre-typing value. + // A single undo reverts the entire burst back to the pre-typing value — + // NOT one keystroke — and the node insert stays applied. useEditorStore.getState().undo() expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId]).toBeDefined() }) it('does not coalesce edits to different props', () => { const { nodeId } = setupTextNode() - const depthAfterInsert = useEditorStore.getState()._historyPast.length + // Capture the module-default tag value BEFORE the edit — different test + // files may register base.text with different default props. + const tagBefore = useEditorStore.getState().site!.pages[0].nodes[nodeId].props.tag useEditorStore.getState().updateNodeProps(nodeId, { text: 'hi' }) useEditorStore.getState().updateNodeProps(nodeId, { tag: 'h1' }) - // Different prop keys → two distinct undo entries. - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterInsert + 2) + // Different prop keys → two distinct undo entries: the first undo + // reverts ONLY the tag edit, the text edit survives it. + useEditorStore.getState().undo() + let node = useEditorStore.getState().site!.pages[0].nodes[nodeId] + expect(node.props.tag).toBe(tagBefore) + expect(node.props.text).toBe('hi') + + useEditorStore.getState().undo() + node = useEditorStore.getState().site!.pages[0].nodes[nodeId] + expect(node.props.text).toBe('') }) it('breaks the burst after undo so the next edit is a fresh entry', () => { @@ -256,11 +259,16 @@ describe('Undo / Redo — input coalescing', () => { useEditorStore.getState().updateNodeProps(nodeId, { text: 'a' }) useEditorStore.getState().updateNodeProps(nodeId, { text: 'ab' }) useEditorStore.getState().undo() // back to '' - const depthAfterUndo = useEditorStore.getState()._historyPast.length + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') - // Typing again must NOT fold into the undone burst. + // Typing again must NOT fold into the undone burst: it forms a fresh + // entry whose undo returns to '' (the post-undo value), and the node + // insert stays applied. useEditorStore.getState().updateNodeProps(nodeId, { text: 'x' }) - expect(useEditorStore.getState()._historyPast.length).toBe(depthAfterUndo + 1) + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('x') + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('') + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId]).toBeDefined() }) it('a non-coalescing mutation ends the burst', () => { @@ -271,10 +279,12 @@ describe('Undo / Redo — input coalescing', () => { useEditorStore.getState().updateNodeProps(nodeId, { text: 'a' }) // Structural mutation in between resets the coalescing key. useEditorStore.getState().insertNode('base.text', { text: '' }, rootId) - const depth = useEditorStore.getState()._historyPast.length - useEditorStore.getState().updateNodeProps(nodeId, { text: 'ab' }) - expect(useEditorStore.getState()._historyPast.length).toBe(depth + 1) + + // 'ab' formed its OWN entry (no folding across the structural break): + // the first undo reverts only it, back to 'a' — not to ''. + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes[nodeId].props.text).toBe('a') }) it('redo replays a coalesced burst back to its final value', () => { @@ -323,10 +333,11 @@ describe('Undo / Redo — patch correctness', () => { const b = useEditorStore.getState().insertNode('base.container', {}, rootId) useEditorStore.getState().moveNode(a, b, 0) - const afterMove = JSON.stringify(useEditorStore.getState().site!.pages[0].nodes) + const afterMove = structuredClone(useEditorStore.getState().site!.pages[0].nodes) useEditorStore.getState().undo() useEditorStore.getState().redo() - const afterRoundTrip = JSON.stringify(useEditorStore.getState().site!.pages[0].nodes) - expect(afterRoundTrip).toBe(afterMove) + // Deep equality, not string equality — the projection rebuilds node + // objects from the Y maps, so key ORDER may differ; content must not. + expect(useEditorStore.getState().site!.pages[0].nodes).toEqual(afterMove) }) }) diff --git a/src/__tests__/editor-store/visualComponentsMutationContract.test.ts b/src/__tests__/editor-store/visualComponentsMutationContract.test.ts index 15603a116..f32924d7f 100644 --- a/src/__tests__/editor-store/visualComponentsMutationContract.test.ts +++ b/src/__tests__/editor-store/visualComponentsMutationContract.test.ts @@ -3,27 +3,23 @@ * * Visual Components live inside the assembled SiteDocument, so every action * that mutates them must use the same document mutation contract as page/tree - * actions: snapshot undo history, mark the document dirty, and allow undo to - * restore the previous SiteDocument. + * actions: capture an undo step (via the collab binding's per-doc + * Y.UndoManagers) and allow undo to restore the previous SiteDocument. */ import { beforeEach, describe, expect, it } from 'bun:test' import { useEditorStore } from '@site/store/store' +import { collabClearHistory } from '@site/store/slices/site/collabBinding' import { makeNode, makePage, makeSite, makeVC, makeVCNode } from '../fixtures' function freshStore() { + useEditorStore.getState().clearSite() useEditorStore.setState({ - site: null, activePageId: null, activeDocument: null, selectedNodeId: null, selectedNodeIds: [], hoveredNodeId: null, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - hasUnsavedChanges: false, - } as Parameters[0]) + }) } function loadSiteWithCardVc() { @@ -45,18 +41,15 @@ function loadSiteWithCardVc() { } function expectMutationContract(action: () => void, assertChanged: () => void): void { - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) expect(useEditorStore.getState().canUndo).toBe(false) action() assertChanged() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canUndo).toBe(true) useEditorStore.getState().undo() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) expect(useEditorStore.getState().canRedo).toBe(true) } @@ -145,13 +138,7 @@ describe('Visual Component actions use the SiteDocument mutation contract', () = expect(useEditorStore.getState().site!.visualComponents[0].tree.nodes['vc-root'].propBindings).toBeUndefined() useEditorStore.getState().setNodePropBinding('vc-root', 'text', 'param-title') - useEditorStore.setState({ - hasUnsavedChanges: false, - _historyPast: [], - _historyFuture: [], - canUndo: false, - canRedo: false, - } as Parameters[0]) + collabClearHistory() expectMutationContract( () => { diff --git a/src/__tests__/persistence/cmsAdapter.test.ts b/src/__tests__/persistence/cmsAdapter.test.ts index 0fdefe378..a5450e452 100644 --- a/src/__tests__/persistence/cmsAdapter.test.ts +++ b/src/__tests__/persistence/cmsAdapter.test.ts @@ -383,16 +383,4 @@ describe('CmsAdapter conflict protocol', () => { expect(loaded?.shellSeq).toBe(3) expect(loaded?.rowSeqs).toEqual({ page_home: 2 }) }) - - it('loadSiteRow fetches the single-row filter and returns null for a deleted row', async () => { - const requested: string[] = [] - const adapter = new CmsAdapter(async (input) => { - requested.push(String(input)) - return new Response(JSON.stringify({ rows: [] }), { status: 200 }) - }) - - const row = await adapter.loadSiteRow('pages', 'page-gone') - expect(row).toBeNull() - expect(requested[0]).toBe('/admin/api/cms/pages?id=page-gone') - }) }) diff --git a/src/__tests__/persistence/savePersistenceQueue.test.tsx b/src/__tests__/persistence/savePersistenceQueue.test.tsx deleted file mode 100644 index 5e5e43750..000000000 --- a/src/__tests__/persistence/savePersistenceQueue.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/** - * usePersistence single-flight save queue. - * - * Every save trigger (autosave, Cmd+S, save-request events, the MCP bridge, - * unmount flush) funnels through `saveCurrentSite`, which allows at most ONE - * save on the wire and ONE queued follow-up: - * - * - N triggers during an in-flight save coalesce into a single follow-up - * that reads the LATEST store state when it runs, - * - the follow-up is skipped when the in-flight save already shipped - * everything (no unsaved changes remain), - * - a FAILED in-flight save does not cancel the queued retry — its dirty - * marks were restored, so the retry ships them again. - * - * Two saves can therefore never interleave on the wire — the failure mode - * the retired four-request protocol had. - */ -import { afterEach, describe, expect, it } from 'bun:test' -import React, { useEffect } from 'react' -import { cleanup, render, waitFor } from '@testing-library/react' -import { usePersistence } from '@site/hooks/usePersistence' -import type { IPersistenceAdapter, SaveSiteOptions } from '@core/persistence/types' -import { SaveConflictError } from '@core/persistence/saveConflict' -import type { SiteDocument } from '@core/page-tree' -import { useEditorStore } from '@site/store/store' -import { emptyDirtyMarks } from '@site/store/slices/site/dirtyTracking' -import { makePage, makeSite } from '../fixtures' - -interface Deferred { - promise: Promise - resolve: () => void - reject: (err: unknown) => void -} - -function deferred(): Deferred { - let resolve!: () => void - let reject!: (err: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -interface RecordedSave { - dirty: SaveSiteOptions['dirty'] - gate: Deferred -} - -/** Adapter whose saveSite parks on a caller-controlled gate per invocation. */ -function makeGatedAdapter(): { adapter: IPersistenceAdapter; saves: RecordedSave[] } { - const saves: RecordedSave[] = [] - const adapter: IPersistenceAdapter = { - loadSite: async () => undefined, - saveSite: (_site: SiteDocument, opts: SaveSiteOptions = {}) => { - const gate = deferred() - saves.push({ dirty: opts.dirty, gate }) - // Each released save reports a strictly-increasing seq, like the server. - return gate.promise.then(() => ({ seq: saves.length })) - }, - } - return { adapter, saves } -} - -/** Mounts usePersistence and hands the save callback out to the test. */ -function HookHost({ - adapter, - onSave, -}: { - adapter: IPersistenceAdapter - onSave: (save: () => Promise) => void -}) { - const { saveSite } = usePersistence('default', adapter, { enabled: true }) - useEffect(() => { - onSave(saveSite) - }, [onSave, saveSite]) - return null -} - -function seedStore(): void { - useEditorStore.setState({ - _historyPast: [], - _historyFuture: [], - _historyCoalesceKey: null, - hasUnsavedChanges: false, - _dirtySave: emptyDirtyMarks(), - } as Parameters[0]) - useEditorStore.getState().loadSite( - makeSite({ - pages: [ - makePage({ id: 'page-a', slug: 'index', title: 'Home' }), - makePage({ id: 'page-b', slug: 'about', title: 'About' }), - ], - }), - ) -} - -async function mountHook(adapter: IPersistenceAdapter): Promise<() => Promise> { - let save: (() => Promise) | null = null - render( { save = s }} />) - await waitFor(() => expect(save).not.toBeNull()) - return save! -} - -function editPage(pageId: string): void { - const store = useEditorStore.getState() - useEditorStore.setState({ activePageId: pageId }) - store.updateNodeProps('root', { text: `edit-${pageId}-${Math.random()}` }) -} - -afterEach(cleanup) - -describe('usePersistence single-flight save queue', () => { - it('coalesces N mid-flight triggers into ONE follow-up that ships the latest state', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - expect([...saves[0].dirty!.pageIds]).toEqual(['page-a']) - - // Three triggers while save #1 is on the wire — plus a NEW edit. - editPage('page-b') - const q1 = save() - const q2 = save() - const q3 = save() - // All coalesce into one queued promise; nothing new on the wire yet. - expect(saves).toHaveLength(1) - - saves[0].gate.resolve() - await first - - await waitFor(() => expect(saves).toHaveLength(2)) - // The follow-up shipped the LATEST marks (page-b's edit). - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - - saves[1].gate.resolve() - await Promise.all([q1, q2, q3]) - // No third save — the queue drained. - expect(saves).toHaveLength(2) - }) - - it('skips the queued follow-up when the in-flight save already shipped everything', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - - // Trigger spam with NO new edits while save #1 is in flight. - const q = save() - saves[0].gate.resolve() - await first - await q - - // hasUnsavedChanges went false when save #1 landed — the follow-up is - // pointless and must not fire. - expect(saves).toHaveLength(1) - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('a failed in-flight save does not cancel the queued retry, and the retry re-ships the restored marks', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - - editPage('page-b') - const q = save() - - saves[0].gate.reject(new Error('network down')) - await expect(first).rejects.toThrow('network down') - - // The retry fires and carries BOTH page-a (restored from the failed - // snapshot) and page-b (the new edit). - await waitFor(() => expect(saves).toHaveLength(2)) - expect(saves[1].dirty!.pageIds.has('page-a')).toBe(true) - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - - saves[1].gate.resolve() - await q - expect(useEditorStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('a new trigger after the queue drained starts a fresh save', async () => { - seedStore() - const { adapter, saves } = makeGatedAdapter() - const save = await mountHook(adapter) - - editPage('page-a') - const first = save() - await waitFor(() => expect(saves).toHaveLength(1)) - saves[0].gate.resolve() - await first - - editPage('page-b') - const second = save() - await waitFor(() => expect(saves).toHaveLength(2)) - expect(saves[1].dirty!.pageIds.has('page-b')).toBe(true) - saves[1].gate.resolve() - await second - }) -}) - -// --------------------------------------------------------------------------- -// Conflict flow — a 409'd save surfaces the resolution UI, loses nothing -// --------------------------------------------------------------------------- - -describe('usePersistence conflict flow', () => { - it('a SaveConflictError sets store.saveConflicts, restores the dirty marks, and bumps nothing', async () => { - seedStore() - useEditorStore.getState().seedBaseSeqs({ 'page-a': 3 }, 3) - const conflicts = [{ table: 'pages' as const, rowId: 'page-a', seq: 7 }] - const adapter: IPersistenceAdapter = { - loadSite: async () => undefined, - saveSite: async () => { - throw new SaveConflictError(conflicts) - }, - } - const save = await mountHook(adapter) - - editPage('page-a') - await expect(save()).rejects.toBeInstanceOf(SaveConflictError) - - const state = useEditorStore.getState() - expect(state.saveConflicts).toEqual(conflicts) - // The failed snapshot merged back — nothing lost, next save re-ships it. - expect(state._dirtySave.pageIds.has('page-a')).toBe(true) - // Base seqs untouched by the failure — only a resolution changes them. - expect(state.baseSeqs['page-a']).toBe(3) - }) - - it('a successful save commits the shipped bases to the response seq and clears conflicts', async () => { - seedStore() - useEditorStore.getState().seedBaseSeqs({ 'page-a': 3, 'page-b': 3 }, 3) - const adapter: IPersistenceAdapter = { - loadSite: async () => undefined, - saveSite: async () => ({ seq: 15 }), - } - const save = await mountHook(adapter) - - editPage('page-a') - await save() - - const state = useEditorStore.getState() - expect(state.baseSeqs['page-a']).toBe(15) - expect(state.baseSeqs['page-b']).toBe(3) - expect(state.shellBaseSeq).toBe(15) - }) -}) diff --git a/src/__tests__/templates/templateModel.test.ts b/src/__tests__/templates/templateModel.test.ts index 743f9a967..eb60f0cf2 100644 --- a/src/__tests__/templates/templateModel.test.ts +++ b/src/__tests__/templates/templateModel.test.ts @@ -15,7 +15,6 @@ function resetStore() { _historyFuture: [], canUndo: false, canRedo: false, - hasUnsavedChanges: false, } as Parameters[0]) } @@ -60,14 +59,12 @@ describe('dynamic template model', () => { site, activePageId: page.id, activeDocument: { kind: 'page', pageId: page.id }, - hasUnsavedChanges: false, }) useEditorStore.getState().convertTemplateToPage(page.id) const nextPage = useEditorStore.getState().site?.pages[0] expect(nextPage?.template).toBeUndefined() expect(nextPage?.nodes[page.rootNodeId].dynamicBindings).toBeUndefined() - expect(useEditorStore.getState().hasUnsavedChanges).toBe(true) }) it('sets and removes a node dynamic binding without changing the static prop fallback', () => { @@ -80,7 +77,6 @@ describe('dynamic template model', () => { site, activePageId: page.id, activeDocument: { kind: 'page', pageId: page.id }, - hasUnsavedChanges: false, }) useEditorStore.getState().setNodeDynamicBinding(root.id, 'text', { source: 'currentEntry', diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts index d8716c623..e800dcfa6 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -347,19 +347,17 @@ describe('PublishButton — publish state machine', () => { expect(src).toContain('primaryTestId="toolbar-publish-btn"') }) - it('saves the current draft before calling the CMS publish endpoint', () => { + it('relies on the server-side relay flush instead of a client save before publish', () => { const { readFileSync } = require('fs') const src = readFileSync( new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), 'utf-8', ) - // The publish call is wrapped in `runStepUp(() => publishCmsDraft())` - // so the StepUpProvider can intercept `step_up_required` and re-auth. - // We assert that save runs before that wrapped call lands. - const savePosition = src.indexOf('await onSave?.()') - const publishPosition = src.indexOf('publishCmsDraft()') - expect(savePosition).toBeGreaterThan(-1) - expect(publishPosition).toBeGreaterThan(savePosition) + // Live co-editing streams every edit to the server as it happens; the + // publish ENDPOINT flushes the relay's debounced persist. The button must + // not carry a client-side save path anymore. + expect(src).toContain('publishCmsDraft()') + expect(src).not.toContain('onSave') }) it('loads persisted publish status when the toolbar mounts', () => { @@ -372,13 +370,16 @@ describe('PublishButton — publish state machine', () => { expect(src).toContain('draftMatchesPublished') }) - it('returns from Published to Publish when the draft has unsaved changes', () => { + it('returns from Published to Publish when the draft moves on', () => { const { readFileSync } = require('fs') const src = readFileSync( new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), 'utf-8', ) - expect(src).toContain('hasUnsavedChanges') + // Every store mutation (local or a remote peer's) produces a new `site` + // reference; the button compares against the reference captured at + // publish time and drops back to idle when they diverge. + expect(src).toContain('publishedSiteRef') expect(src).toContain("setState('idle')") }) @@ -570,13 +571,14 @@ describe('Toolbar — structural requirements', () => { 'utf-8', ) expect(src).toContain('PublishActionGroup') - expect(src).toContain('Draft saved') - expect(src).toContain('Unsaved draft') + expect(src).toContain('Draft synced') + expect(src).toContain('Offline — reconnecting') expect(src).toContain("state === 'published' ? 'Published'") expect(src).toContain('state === \'published\' ? CheckIcon') expect(src).toContain("statusLabel={state === 'published' ? null : status.label}") expect(src).toContain('publishDisabled={disabled || state === \'published\'}') - expect(src).toContain('Save draft') + // Saving is continuous (live co-editing) — no manual save action remains. + expect(src).not.toContain('Save draft') expect(src).toContain('Preview page') // "Open live page" used to live in this menu — it's now a dedicated // icon button (OpenLivePageButton) next to the avatar in the Toolbar diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 3944900f0..9b16c0d93 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -30,8 +30,8 @@ * - Site explorer panel — site concepts: pages, components, styles, scripts * - CodeEditorPanel (Task #432) — center-stage, code editing * - * J12: usePersistence handles CMS draft load on mount, preference-gated - * 30s auto-save, toolbar Save, and Cmd+S immediate save. + * usePersistence handles the CMS draft load on mount and connects the + * live-collab provider — edits stream continuously; there is no manual save. * * Agent Panel: Phase D AI assistant — self-contained floating panel (Guideline #410). * Authenticates via ambient Claude Code credentials through the local Bun server. @@ -93,15 +93,6 @@ const SettingsModal = lazy(() => import('@admin/modals/Settings/SettingsModal').then((m) => ({ default: m.SettingsModal })), ) -// Save-conflict resolution banner — mounts only while a 409'd save has -// unresolved conflicts (multi-admin conflict safety, level A), so its chunk -// stays out of the route shell on first paint. -const SaveConflictBanner = lazy(() => - import('@admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner').then((m) => ({ - default: m.SaveConflictBanner, - })), -) - // Editor-only toolbar surface: preview iframe. It self-gates on store state, // but we ALSO conditionally render it at the call site (below) so its chunk // isn't fetched on first paint — the preview overlay drags in the entire @@ -126,7 +117,6 @@ export function AdminCanvasLayout() { const faviconUrl = useEditorStore((s) => s.site?.settings.faviconUrl ?? null) // Editor-only toolbar surface — gate its lazy chunk on store state. const previewOpen = useEditorStore((s) => s.previewOpen) - const hasSaveConflicts = useEditorStore((s) => s.saveConflicts.length > 0) // Settings modal mount gate. adminUi is the canonical source — the // editor's `settingsSlice.openSettings` mirrors into it, and the admin // shell reads from it too. @@ -169,11 +159,9 @@ export function AdminCanvasLayout() { canEditContent: canEditContentFlag, canEditStyle: canEditStyleFlag, } - // J12 — wire persistence: load, auto-save, toolbar Save, Cmd+S. - const persistence = usePersistence('default', cmsAdapter, { - markNewSiteUnsaved: true, - enabled: true, - }) + // Boot the document lifecycle: HTTP load for first paint, then the collab + // provider — every edit streams live and the server relay persists. + const persistence = usePersistence('default', cmsAdapter, { enabled: true }) // Keep the open page in lockstep with the URL: consume `?page=` on // load, and mirror the active page's slug back into the address bar so it's // directly linkable. @@ -203,10 +191,6 @@ export function AdminCanvasLayout() { : null const loadEditorBody = usePostPaintEditorBodyGate() - async function saveBeforeWorkspaceNavigation(): Promise { - if (!useEditorStore.getState().hasUnsavedChanges) return - await persistence.saveSite() - } return ( @@ -227,11 +211,7 @@ export function AdminCanvasLayout() { faviconUrl={faviconUrl} section="site" adminNavigationSlot={( - + )} overlay={previewOpen && ( @@ -241,21 +221,11 @@ export function AdminCanvasLayout() { rightSlot={( <> - + )} /> - {hasSaveConflicts && ( - - - - )} - {loadEditorBody ? ( const loaded = await cmsAdapter.loadSite('default') if (loaded) { useEditorStore.getState().loadSite(loaded.site) - useEditorStore.getState().seedBaseSeqs(loaded.rowSeqs, loaded.shellSeq) return loaded.site } @@ -188,10 +187,9 @@ export async function ensureCurrentSiteForStaticImport(): Promise export async function saveImportedDraftSite(): Promise { const site = useEditorStore.getState().site if (!site) throw new Error('Import completed, but no draft site is loaded.') - // Replace-mode full save (no dirty hints) — an import deliberately - // replaces whatever is stored, so there is no conflict check to feed. - const { seq } = await cmsAdapter.saveSite(site) - useEditorStore.getState().commitSavedBaseSeqs(site, undefined, seq) - useEditorStore.getState().setHasUnsavedChanges(false) + // Replace-mode full save — an import deliberately replaces whatever is + // stored. This is an out-of-relay write, so the collab relay resets the + // affected docs and every connected editor rebinds to the imported state. + await cmsAdapter.saveSite(site) requestCmsSiteReload() } diff --git a/src/admin/pages/site/SitePage.tsx b/src/admin/pages/site/SitePage.tsx index 3d8855d65..07569939f 100644 --- a/src/admin/pages/site/SitePage.tsx +++ b/src/admin/pages/site/SitePage.tsx @@ -4,11 +4,6 @@ import { consumePendingAction } from '@admin/spotlight/pendingAction' import { useEditorStore } from '@site/store/store' import { useMcpWorkspaceBridge } from '@admin/ai/useMcpWorkspaceBridge' import { executeAgentTool } from './agent' -import { flushEditorSave } from './hooks/editorSaveRef' - -async function flushPendingSiteDraft(): Promise { - if (useEditorStore.getState().hasUnsavedChanges) await flushEditorSave() -} /** * SitePage — visual editor route. @@ -18,8 +13,12 @@ async function flushPendingSiteDraft(): Promise { * lazy-loaded one level down by AdminCanvasLayout after the shell has painted. */ export function SitePage() { - // Relay MCP browser-tool calls to this open editor while it's mounted. - useMcpWorkspaceBridge('site', executeAgentTool, flushPendingSiteDraft) + // Relay MCP browser-tool calls to this open editor while it's mounted. No + // post-tool persistence step: store mutations stream to the relay through the + // collab socket the moment they land, and every headless MCP read flushes the + // relay server-side first (see server/ai/mcp/server.ts), so a follow-up read + // always observes the edit. + useMcpWorkspaceBridge('site', executeAgentTool) // Consume cross-workspace pending actions queued by the spotlight. Each // action waits for the editor store to hydrate (site !== null) — we diff --git a/src/admin/pages/site/dialogs/LayoutNameDialog.tsx b/src/admin/pages/site/dialogs/LayoutNameDialog.tsx index cee24a9f8..66576f9da 100644 --- a/src/admin/pages/site/dialogs/LayoutNameDialog.tsx +++ b/src/admin/pages/site/dialogs/LayoutNameDialog.tsx @@ -18,7 +18,6 @@ import { Dialog } from '@ui/components/Dialog' import { Input } from '@ui/components/Input' import { pushToast } from '@ui/components/Toast' import { getErrorMessage } from '@core/utils/errorMessage' -import { requestEditorSave } from '@admin/state/adminEvents' import { useEditorStore } from '@site/store/store' import { SavedLayoutNameError } from '@site/store/slices/layoutsSlice' import type { LayoutNameDialogRequest } from '@site/store/slices/uiSlice' @@ -62,10 +61,6 @@ function LayoutNameDialogBody({ setError('This element can no longer be saved as a layout.') return } - // "Save as layout" reads as a deliberate save — persist immediately - // rather than relying on the autosave debounce, which is dropped if the - // user leaves the editor (e.g. to the Data view) before it fires. - requestEditorSave() pushToast({ kind: 'success', title: `Saved layout "${name.trim()}"`, diff --git a/src/admin/pages/site/hooks/editorSaveRef.ts b/src/admin/pages/site/hooks/editorSaveRef.ts deleted file mode 100644 index 9d6ea926a..000000000 --- a/src/admin/pages/site/hooks/editorSaveRef.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Editor save bridge — lets the MCP editor-bridge listener flush a pending - * draft save synchronously after a write tool runs. - * - * The built-in agent panel never needs this: its server-side read tools read - * the browser-posted snapshot, so they always see live state. MCP's headless - * read tools (`read_styles`, content reads) hit the DB, so a browser write that - * only landed in the editor store would be invisible until the 30 s autosave - * fires. `usePersistence` registers its save callback here; the Site instance - * of `useMcpWorkspaceBridge` calls `flushEditorSave()` right after a mutating - * tool so a follow-up headless read sees the change. - */ -let saveFn: (() => Promise) | null = null - -/** Registered by `usePersistence`. Returns an unregister cleanup. */ -export function registerEditorSave(fn: () => Promise): () => void { - saveFn = fn - return () => { - if (saveFn === fn) saveFn = null - } -} - -/** Flush a pending draft save. No-op when the editor isn't mounted. */ -export async function flushEditorSave(): Promise { - if (saveFn) await saveFn() -} diff --git a/src/admin/pages/site/hooks/remoteSnapshot.ts b/src/admin/pages/site/hooks/remoteSnapshot.ts deleted file mode 100644 index 72af396b1..000000000 --- a/src/admin/pages/site/hooks/remoteSnapshot.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * fetchRemoteSnapshot — pull the CURRENT server state of one sync target and - * convert it to the domain shape `applyRemoteSnapshot` consumes. - * - * Shared by the conflict banner's "Load theirs" and the live-sync socket's - * clean-target pull. Row targets ride the collection GETs' single-row `?id=` - * filter; absence (`row: null`) IS the "deleted remotely" signal. The shell - * rides `GET /site`, which carries its sync seq. - * - * The returned seq prefers the FETCHED row's seq over the triggering - * event/conflict seq — the row may have moved further since. - */ -import { cmsAdapter } from '@core/persistence/cms' -import { pageFromRow } from '@core/data/pageFromRow' -import { visualComponentFromRow } from '@core/data/componentFromRow' -import { savedLayoutFromRow } from '@core/data/layoutFromRow' -import type { RemoteSnapshot } from '@site/store/slices/site/types' - -export interface RemoteSnapshotTarget { - table: 'site' | 'pages' | 'components' | 'layouts' - rowId: string - /** The seq that triggered the fetch — fallback when the target is deleted. */ - seq: number -} - -/** Row snapshot — the variants of {@link RemoteSnapshot} that carry a `row`. */ -export type RemoteRowSnapshot = Exclude - -// Overloads: a row-table target provably yields a row snapshot, so callers -// that never fetch the shell (the socket's row handler) get the narrow type. -export async function fetchRemoteSnapshot( - target: RemoteSnapshotTarget & { table: RemoteRowSnapshot['table'] }, -): Promise -export async function fetchRemoteSnapshot(target: RemoteSnapshotTarget): Promise -export async function fetchRemoteSnapshot(target: RemoteSnapshotTarget): Promise { - if (target.table === 'site') { - const remote = await cmsAdapter.loadSiteShell() - if (!remote) throw new Error('The site shell no longer exists on the server.') - return { table: 'site', shell: remote.shell, seq: remote.seq } - } - const row = await cmsAdapter.loadSiteRow(target.table, target.rowId) - const seq = row?.seq ?? target.seq - if (target.table === 'pages') { - return { table: 'pages', rowId: target.rowId, row: row ? pageFromRow(row) : null, seq } - } - if (target.table === 'components') { - return { - table: 'components', - rowId: target.rowId, - row: row ? visualComponentFromRow(row) : null, - seq, - } - } - return { table: 'layouts', rowId: target.rowId, row: row ? savedLayoutFromRow(row) : null, seq } -} diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index bb327c4f1..c88d6b07c 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -1,74 +1,50 @@ /** - * usePersistence — React hook that wires the Zustand store to the CMS persistence adapter. + * usePersistence — React hook that boots the editor's document lifecycle. * * Responsibilities: - * 1. LOAD on mount — loads the single CMS draft site document; falls back to - * creating a fresh blank draft when the CMS has no draft yet. - * 2. AUTO-SAVE — when enabled in preferences, debounced 30 s after the - * `hasUnsavedChanges` flag transitions to true. Timer is properly reset on - * each new change so that rapid edits collapse into a single save. - * 3. MANUAL SAVE — returned as a stable callback for toolbar Save and used - * by Cmd+S / Ctrl+S. Resets the unsaved-changes flag. + * 1. LOAD on mount — fetch the CMS draft site over HTTP and hydrate the + * store (fast first paint), falling back to bootstrapping a fresh blank + * draft for new installs. + * 2. CONNECT — open the collab provider (one WebSocket, every doc + * multiplexed) and hand it to the store's collab binding. From that + * moment every local mutation streams to the relay (which persists + * continuously) and every remote peer's edit projects into the store. + * There is no client-side save pipeline anymore — no autosave timer, no + * Cmd+S, no dirty tracking, no beforeunload flush. + * 3. STATUS — map load state + provider connection state onto the toolbar + * status chip (`loading` / `synced` / `connecting` / `offline` / `error`). * * Constraint #230: raw adapter data is validated via `validateSite` before * being passed to `store.loadSite()`. * - * Mount it once at the top of EditorLayout and pass the returned save callback - * to toolbar chrome that needs an explicit Save action. - * - * Guideline #239 / selector-stability note: - * All store reads inside effects use `useEditorStore.getState()` (point-in-time - * snapshots) rather than `useEditorStore(selector)` React hooks. This avoids - * subscribing EditorLayout to store changes from within this hook, which would - * cause spurious re-renders. - * - * The auto-save subscription uses a primitive boolean selector - * `(s) => s.hasUnsavedChanges` so that `Object.is` comparisons work correctly - * and the listener fires ONLY when the flag actually changes — not on every - * single store update. Using an inline object selector like - * `(s) => ({ site: s.site, dirty: s.hasUnsavedChanges })` would create - * a brand-new object on every evaluation, causing the listener to fire on - * every store mutation and leaking unbounded setTimeout instances. + * Guideline #239 / selector-stability note: store reads inside effects use + * `useEditorStore.getState()` (point-in-time snapshots) rather than + * `useEditorStore(selector)` hooks, so this hook never subscribes its host + * component to store changes. */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useEditorStore } from '@site/store/store' import type { SiteDocument } from '@core/page-tree' import type { IPersistenceAdapter } from '@core/persistence/types' import { cmsAdapter } from '@core/persistence/cms' -import { SaveConflictError } from '@core/persistence/saveConflict' import { SiteValidationError } from '@core/persistence/validate' +import { readEditorSelectPreference } from '@site/preferences/editorPreferences' +import type { CollabProvider } from '@site/collab/collabProvider' import { - readAutoSaveDelayMs, - readAutoSavePreference, - readEditorSelectPreference, - subscribeToEditorPrefsChanged, -} from '@site/preferences/editorPreferences' -import { getKeybindingForCommand } from '@admin/spotlight/keybindings' -import { registerEditorSave } from './editorSaveRef' - -/** - * Re-exported for back-compat. The canonical declaration lives in - * `@admin/state/adminEvents` so plugin code (which just dispatches the - * event after a pack install) can import the constant without dragging - * this whole hook — and its editor-store dependency — into the - * non-editor admin bundle. - */ - + connectCollabProvider, + disconnectCollabProvider, +} from '@site/store/slices/site/collabBinding' import { - CMS_SITE_RELOAD_EVENT, - EDITOR_SAVE_REQUEST_EVENT, consumePendingCmsSiteReload, hasPendingCmsSiteReload, } from '@admin/state/adminEvents' export interface PersistenceSaveStatus { - state: 'loading' | 'saved' | 'unsaved' | 'saving' | 'error' + state: 'loading' | 'synced' | 'connecting' | 'offline' | 'error' message?: string - lastSavedAt?: number } interface PersistenceController { - saveSite: () => Promise saveStatus: PersistenceSaveStatus } @@ -112,132 +88,29 @@ function applyDefaultBreakpointPreference( export function usePersistence( requestedSiteId = 'default', adapter: IPersistenceAdapter = cmsAdapter, - options: { markNewSiteUnsaved?: boolean; enabled?: boolean } = {}, + options: { enabled?: boolean } = {}, ): PersistenceController { - const markNewSiteUnsaved = options.markNewSiteUnsaved ?? false const enabled = options.enabled ?? true - const [saveStatus, setSaveStatus] = useState( - enabled ? { state: 'loading' } : { state: 'saved' }, + const [loadState, setLoadState] = useState<{ phase: 'loading' | 'ready' | 'error'; message?: string }>( + enabled ? { phase: 'loading' } : { phase: 'ready' }, ) - /** Whether the initial load has completed — prevents auto-save before load */ - const loadedRef = useRef(false) - /** Stable reference to the adapter so it doesn't trigger re-renders */ + const [collabState, setCollabState] = useState<'connecting' | 'connected' | 'offline'>('connecting') + /** Stable reference to the adapter so a per-render instance can't re-trigger the load effect. */ const adapterRef = useRef(adapter) useEffect(() => { adapterRef.current = adapter }, [adapter]) - /** The save currently on the wire, if any. */ - const inFlightSaveRef = useRef | null>(null) - /** The single queued follow-up save every mid-flight trigger coalesces into. */ - const queuedSaveRef = useRef | null>(null) - - // Exception #1: referenced in the useCallback dep array of saveCurrentSite, - // so exhaustive-deps requires a stable identity here. - const runSave = useCallback(async () => { - const { - site, - setHasUnsavedChanges, - takeDirtySaveSnapshot, - restoreDirtySaveSnapshot, - baseSeqs, - shellBaseSeq, - } = useEditorStore.getState() - if (!site) return - - setSaveStatus({ state: 'saving', message: 'Saving draft' }) - // Snapshot-and-reset the dirty marks BEFORE the await: edits landing while - // the save is in flight accumulate fresh marks for the NEXT save, and a - // failed save merges this snapshot back so nothing is lost. - const dirty = takeDirtySaveSnapshot() - try { - const { seq } = await adapterRef.current.saveSite(site, { dirty, baseSeqs, shellBaseSeq }) - // Storage now holds this save — bump the conflict-detection bases of - // everything it shipped to the save's seq. - useEditorStore.getState().commitSavedBaseSeqs(site, dirty, seq) - // Clear the unsaved flag ONLY when no mutation landed while the save - // was on the wire — every store mutation produces a new `site` - // reference, so reference equality is the exact signal. Without this - // guard a mid-flight edit would lose its "unsaved" status (and the - // queued follow-up save would be skipped, dropping the edit until the - // next mutation re-set the flag). - if (useEditorStore.getState().site === site) setHasUnsavedChanges(false) - setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) - } catch (err) { - restoreDirtySaveSnapshot(dirty) - if (err instanceof SaveConflictError) { - // Another admin stored newer versions of rows this save shipped. - // Surface the resolution UI (conflict banner) instead of a plain - // error; the restored dirty marks keep the local edits safe while - // the user decides per row. - useEditorStore.getState().setSaveConflicts(err.conflicts) - } - setSaveStatus({ state: 'error', message: errorMessage(err, 'Save failed') }) - throw err - } - }, []) - - /** - * SINGLE-FLIGHT save queue: at most one save on the wire and at most one - * queued follow-up. Every trigger (autosave, Cmd+S, save-request events, - * the MCP bridge, unmount flush) funnels through here, so two saves can - * never interleave — the failure mode the four-request protocol used to - * have. A queued save reads the LATEST store state when it runs, so N - * triggers during one in-flight save collapse into a single follow-up; it - * is skipped entirely when the in-flight save already shipped everything - * (no unsaved changes remain). - * - * Exception #1: referenced in useEffect dep arrays below, so - * exhaustive-deps requires a stable identity here. - */ - const saveCurrentSite = useCallback((): Promise => { - const start = (): Promise => { - const run = runSave().finally(() => { - if (inFlightSaveRef.current === run) inFlightSaveRef.current = null - }) - inFlightSaveRef.current = run - return run - } - - const inFlight = inFlightSaveRef.current - if (!inFlight) return start() - - queuedSaveRef.current ??= inFlight - // A failed in-flight save must not cancel the queued retry — its dirty - // marks were restored, so the follow-up ships them again. - .catch(() => {}) - .then(() => { - queuedSaveRef.current = null - // The in-flight save may have already shipped everything (trigger - // spam without new edits) — skip the pointless follow-up. - if (!useEditorStore.getState().hasUnsavedChanges) return - return start() - }) - return queuedSaveRef.current - }, [runSave]) - - // Expose the save to the MCP editor-bridge so a write tool relayed from an - // external agent can flush to the DB before a follow-up headless read. - useEffect(() => registerEditorSave(saveCurrentSite), [saveCurrentSite]) - - // ─── 1. Load site document on mount ──────────────────────────────────────── + // ─── 1. Load site document + connect the collab provider ────────────────── useEffect(() => { - if (!enabled) { - loadedRef.current = true - return - } + if (!enabled) return undefined let cancelled = false + let connected = false - async function load() { - // Read actions point-in-time — no React subscription needed - const { - site: existingSite, - hasUnsavedChanges, - loadSite, - createSite, - setHasUnsavedChanges, - } = useEditorStore.getState() + async function load(): Promise { + // Read actions point-in-time — no React subscription needed. + const { site: existingSite, loadSite, createSite } = useEditorStore.getState() const pendingCmsSiteReload = hasPendingCmsSiteReload() const shouldReloadExistingSite = existingSite @@ -245,247 +118,108 @@ export function usePersistence( : false if (existingSite && !shouldReloadExistingSite) { - loadedRef.current = true - setSaveStatus( - hasUnsavedChanges - ? { state: 'unsaved', message: 'Unsaved changes' } - : { state: 'saved', lastSavedAt: Date.now() }, - ) + // In-memory document from an earlier editor mount. The provider + // connect below re-syncs every doc against the server, so any drift + // (writes from other admins / plugins while we were away) projects in. + setLoadState({ phase: 'ready' }) return } const idToTry = requestedSiteId || 'default' - if (idToTry) { - try { - // The adapter validates internally (validateSite + validatePages). - // Constraint #230 is satisfied at the adapter boundary. - const result = await adapterRef.current.loadSite(idToTry) - if (result && !cancelled) { - if (pendingCmsSiteReload) consumePendingCmsSiteReload() - loadSite(result.site) - // Seed the conflict-detection bases from the same read that - // produced the document — every future save compares against these. - useEditorStore.getState().seedBaseSeqs(result.rowSeqs, result.shellSeq) - applyDefaultBreakpointPreference(result.site.breakpoints) - loadedRef.current = true - setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) - return - } - } catch (err) { - if (err instanceof SiteValidationError) { - console.warn('[persistence] Corrupt CMS site data:', err.message) - } else { - console.warn('[persistence] Failed to load CMS site:', err) - } - if (!cancelled) { - setSaveStatus({ state: 'error', message: errorMessage(err, 'Failed to load CMS site') }) - } - return - } - } - - if (cancelled) return - if (pendingCmsSiteReload) consumePendingCmsSiteReload() - - if (existingSite) { - loadedRef.current = true - setSaveStatus( - hasUnsavedChanges - ? { state: 'unsaved', message: 'Unsaved changes' } - : { state: 'saved', lastSavedAt: Date.now() }, - ) - return - } - - // Bootstrap a fresh draft once for new installs that have an admin/site row - // but no instatic document yet. - if (!cancelled) { - const created = createSite('My Site') - applyDefaultBreakpointPreference(created.breakpoints) - loadedRef.current = true - try { - // Replace-mode full save (no dirty hints): the site doesn't exist - // in storage yet. - const { seq } = await adapterRef.current.saveSite(created) - // Storage now matches the store — drop the createSite all-dirty - // mark and seed the conflict-detection bases at the save's seq. - useEditorStore.getState().takeDirtySaveSnapshot() - useEditorStore.getState().commitSavedBaseSeqs(created, undefined, seq) - setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) - } catch (err) { - setHasUnsavedChanges(true) - setSaveStatus({ - state: markNewSiteUnsaved ? 'unsaved' : 'error', - message: errorMessage(err, 'Draft not saved yet'), - }) - } - } - } - - load() - return () => { cancelled = true } - }, [enabled, markNewSiteUnsaved, requestedSiteId]) - - // External "site changed at the server" hook. Non-editor workspaces call - // `requestCmsSiteReload()`, which retains the reload if this hook is not - // mounted yet and dispatches `CMS_SITE_RELOAD_EVENT` for live editor mounts. - useEffect(() => { - if (!enabled) return undefined - - async function reload() { - const idToTry = requestedSiteId || 'default' - const pendingCmsSiteReload = hasPendingCmsSiteReload() try { - // Adapter validates internally (Constraint #230). + // The adapter validates internally (validateSite + validatePages) — + // Constraint #230 is satisfied at the adapter boundary. const result = await adapterRef.current.loadSite(idToTry) - if (!result) { + if (cancelled) return + if (result) { if (pendingCmsSiteReload) consumePendingCmsSiteReload() + loadSite(result.site) + applyDefaultBreakpointPreference(result.site.breakpoints) + setLoadState({ phase: 'ready' }) return } - const { loadSite, setHasUnsavedChanges, seedBaseSeqs } = useEditorStore.getState() - loadSite(result.site) - seedBaseSeqs(result.rowSeqs, result.shellSeq) - applyDefaultBreakpointPreference(result.site.breakpoints) - // The site doc on disk is now authoritative; clear the unsaved flag so - // the auto-save loop doesn't immediately overwrite it back. - setHasUnsavedChanges(false) - if (pendingCmsSiteReload) consumePendingCmsSiteReload() - setSaveStatus({ state: 'saved', lastSavedAt: Date.now() }) } catch (err) { - console.error('[persistence] Reload after pack install failed:', err) + if (err instanceof SiteValidationError) { + console.warn('[persistence] Corrupt CMS site data:', err.message) + } else { + console.warn('[persistence] Failed to load CMS site:', err) + } + if (!cancelled) { + setLoadState({ phase: 'error', message: errorMessage(err, 'Failed to load CMS site') }) + } + return } - } - - function handleReload() { - void reload() - } - - window.addEventListener(CMS_SITE_RELOAD_EVENT, handleReload) - return () => window.removeEventListener(CMS_SITE_RELOAD_EVENT, handleReload) - }, [enabled, requestedSiteId]) - - // ─── 2. Auto-save (debounced) ────────────────────────────────────────────── - useEffect(() => { - if (!enabled) return undefined - - // Primitive boolean selector — Object.is works correctly, listener fires - // ONLY when hasUnsavedChanges actually changes (false→true or true→false). - // This avoids creating a new object on every selector evaluation (which - // would cause the listener to run on every store mutation — timer leak). - let timer: ReturnType | undefined - - function scheduleAutoSave() { - clearTimeout(timer) - if (!loadedRef.current) return - if (!useEditorStore.getState().hasUnsavedChanges) return - // Pending conflicts need a per-row decision — an autosave retry would - // just 409 again. The conflict banner resumes saving on resolution. - if (useEditorStore.getState().saveConflicts.length > 0) return - if (!readAutoSavePreference()) return - // Read the delay each time auto-save is scheduled — toggling the - // preference re-fires `subscribeToEditorPrefsChanged` which calls back - // into this scheduler, so the next scheduled tick uses the fresh value. - timer = setTimeout(() => { - void saveCurrentSite().catch((err) => { - console.error('[persistence] Auto-save failed:', err) - }) - }, readAutoSaveDelayMs()) - } + if (cancelled) return + if (pendingCmsSiteReload) consumePendingCmsSiteReload() - const unsub = useEditorStore.subscribe( - (s) => s.hasUnsavedChanges, - (dirty) => { - if (!dirty) { - clearTimeout(timer) - setSaveStatus((status) => - status.state === 'saving' ? status : { state: 'saved', lastSavedAt: status.lastSavedAt } - ) - return + // Bootstrap a fresh draft once for new installs that have an admin/site + // row but no instatic document yet. The one HTTP save creates the + // storage rows; the provider connect below then binds the server-seeded + // docs for them. + const created = createSite('My Site') + applyDefaultBreakpointPreference(created.breakpoints) + try { + await adapterRef.current.saveSite(created) + if (!cancelled) setLoadState({ phase: 'ready' }) + } catch (err) { + if (!cancelled) { + setLoadState({ phase: 'error', message: errorMessage(err, 'Draft not saved yet') }) } - setSaveStatus({ state: 'unsaved', message: 'Unsaved changes' }) - scheduleAutoSave() - }, - ) - const prefsUnsub = subscribeToEditorPrefsChanged(scheduleAutoSave) - - // beforeunload flush — tab close / hard reload. Fire-and-forget: the - // handler cannot await async work, so this bypasses the save queue and - // ships the current netted marks WITHOUT resetting them (a failed flush - // is retried by the next save). One request now, so either the whole - // save lands or none of it does — no partial-prefix commits at unload. - function flushBeforeUnload() { - const { site, hasUnsavedChanges, peekDirtySaveSnapshot, baseSeqs, shellBaseSeq } = - useEditorStore.getState() - if (!site || !loadedRef.current || !hasUnsavedChanges) return - clearTimeout(timer) - void adapterRef.current - .saveSite(site, { dirty: peekDirtySaveSnapshot(), baseSeqs, shellBaseSeq }) - .catch((err) => { - console.error('[persistence] flush save failed:', err) - }) - } - - window.addEventListener('beforeunload', flushBeforeUnload) - - return () => { - unsub() - prefsUnsub() - clearTimeout(timer) - window.removeEventListener('beforeunload', flushBeforeUnload) - // Unmount cleanup — in-app SPA navigation AWAY from the editor (e.g. to - // the Data view), which does NOT fire `beforeunload`. Unlike unload, - // the promise survives unmount, so this routes through the save queue - // and can never interleave with an in-flight autosave. - const { site, hasUnsavedChanges } = useEditorStore.getState() - if (site && loadedRef.current && hasUnsavedChanges) { - void saveCurrentSite().catch((err) => { - console.error('[persistence] flush save failed:', err) - }) } } - }, [enabled, saveCurrentSite]) - // ─── Immediate-save requests ─────────────────────────────────────────────── - // Deliberate, discrete save actions (e.g. "Save as layout") dispatch - // EDITOR_SAVE_REQUEST_EVENT to commit right away instead of waiting for the - // autosave debounce. Runs the same pipeline as Cmd+S (snapshot dirty marks, - // reset the unsaved flag), independent of the autosave preference. - useEffect(() => { - if (!enabled) return undefined + let provider: CollabProvider | null = null + let offStatus: (() => void) | null = null - function handleSaveRequest() { - void saveCurrentSite().catch((err) => { - console.error('[persistence] Save request failed:', err) + async function boot(): Promise { + // Fetch the provider module (yjs transport) in parallel with the HTTP + // load — a dynamic import keeps it out of the route-shell chunk. + const providerModule = import('@site/collab/collabProvider') + await load() + if (cancelled) return + const { createCollabProvider } = await providerModule + if (cancelled) return + provider = createCollabProvider() + offStatus = provider.onStatus((status) => { + setCollabState(status === 'connected' ? 'connected' : status) }) + setCollabState(provider.status() === 'connected' ? 'connected' : provider.status()) + // Connect AFTER the HTTP load hydrated the store: connectCollabProvider + // rebinds every doc for the loaded site through the provider + // (server-seeded), and the HTTP-loaded projection keeps the canvas + // painted while the initial sync streams in. + connected = true + connectCollabProvider(provider) } + void boot() - window.addEventListener(EDITOR_SAVE_REQUEST_EVENT, handleSaveRequest) - return () => window.removeEventListener(EDITOR_SAVE_REQUEST_EVENT, handleSaveRequest) - }, [enabled, saveCurrentSite]) - - // ─── 3. Cmd/Ctrl+S — immediate save ─────────────────────────────────────── - // Match predicate comes from the keybindings registry — single source of truth. - useEffect(() => { - if (!enabled) return undefined - - const kbSave = getKeybindingForCommand('editor.save') - - async function handleKeyDown(e: KeyboardEvent) { - if (!kbSave?.match(e)) return - e.preventDefault() - - try { - await saveCurrentSite() - } catch (err) { - console.error('[persistence] Manual save failed:', err) + return () => { + cancelled = true + offStatus?.() + if (connected) { + // Tears the provider down AND resets the binding to detached docs + // seeded from whatever site the store still holds. + disconnectCollabProvider() + } else { + // Unmounted before the connect happened — the binding never adopted + // this provider, so destroy it directly. + provider?.destroy() } } + }, [enabled, requestedSiteId]) - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [enabled, saveCurrentSite]) - - return { saveSite: saveCurrentSite, saveStatus } + const saveStatus: PersistenceSaveStatus = + loadState.phase === 'loading' + ? { state: 'loading' } + : loadState.phase === 'error' + ? { state: 'error', message: loadState.message } + : collabState === 'connected' + ? { state: 'synced' } + : collabState === 'connecting' + ? { state: 'connecting' } + : { state: 'offline', message: 'Reconnecting' } + + return { saveStatus } } diff --git a/src/admin/pages/site/store/slices/inlineEditSlice.ts b/src/admin/pages/site/store/slices/inlineEditSlice.ts index 4ca815489..831f3f083 100644 --- a/src/admin/pages/site/store/slices/inlineEditSlice.ts +++ b/src/admin/pages/site/store/slices/inlineEditSlice.ts @@ -9,13 +9,14 @@ * in slices/site/nodeActions.ts) — the whole typing burst is ONE undo entry, * which is what lets `cancelInlineEdit` revert with a single `undo()`. * - * Burst isolation: `startInlineEdit` and `endInlineEdit` both reset - * `_historyCoalesceKey`, so the inline burst can never fold into a + * Burst isolation: `startInlineEdit` and `endInlineEdit` both call + * `collabBreakCoalescing`, so the inline burst can never fold into a * Properties-panel typing burst for the same prop (or vice versa) — Escape * must revert exactly the inline session, nothing more. */ import { registry } from '@core/module-engine' import type { EditorStoreSliceCreator } from '@site/store/types' +import { collabBreakCoalescing } from './site/collabBinding' import { getActiveTree } from './selectionSlice' interface ActiveInlineEdit { @@ -90,10 +91,10 @@ export const createInlineEditSlice: EditorStoreSliceCreator = ( initialValue: value, committed: false, } - // Isolate the session's burst from any in-flight coalescing burst for - // the same key (e.g. Properties-panel typing on the same prop). - s._historyCoalesceKey = null }) + // Isolate the session's burst from any in-flight coalescing burst for + // the same key (e.g. Properties-panel typing on the same prop). + collabBreakCoalescing() }, applyInlineEditValue: (value) => { @@ -118,20 +119,20 @@ export const createInlineEditSlice: EditorStoreSliceCreator = ( if (!get().activeInlineEdit) return set((s) => { s.activeInlineEdit = null - // End the burst: later edits of the same prop get a fresh undo entry. - s._historyCoalesceKey = null }) + // End the burst: later edits of the same prop get a fresh undo entry. + collabBreakCoalescing() }, cancelInlineEdit: () => { const session = get().activeInlineEdit if (!session) return // The whole session is one coalesced entry — a single undo() restores - // the pre-session value. undo() also resets _historyCoalesceKey. + // the pre-session value. undo() also breaks coalescing. if (session.committed) get().undo() set((s) => { s.activeInlineEdit = null - s._historyCoalesceKey = null }) + collabBreakCoalescing() }, }) diff --git a/src/admin/pages/site/store/slices/saveTrackingSlice.ts b/src/admin/pages/site/store/slices/saveTrackingSlice.ts deleted file mode 100644 index 041f1d4da..000000000 --- a/src/admin/pages/site/store/slices/saveTrackingSlice.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Save-tracking slice — the unsaved-changes flag, the patch-derived - * save-dirty accumulator (see slices/site/dirtyTracking.ts), and the - * multi-admin sync bookkeeping: per-row + shell base seqs (the - * conflict-detection bases every incremental save ships) and the - * pending-conflicts list behind the SaveConflictBanner. Conflict RESOLUTION - * actions live in slices/site/conflictActions.ts — they mutate the document. - * - * Autosave takes a snapshot (which resets the accumulator), ships only the - * named page/component/layout writes plus explicit deleted-row ids, and - * merges the snapshot back on save failure so nothing is lost. - * `mutateSite`-family helpers feed the accumulator from each mutation's - * site-relative patches. - * - * NETTING happens at snapshot time, against the live site document: - * - a deleted-mark for a row that EXISTS in the store again (deleted then - * re-created / undone within one save window) is dropped — the row ships - * as a plain write, net effect preserved; - * - a write-mark for a row that NO LONGER exists is dropped — shipping it - * would resurrect a row the user deleted. - * The server's "id in both changed and deleted sets → 400" check backstops - * this rule. `peekDirtySaveSnapshot` applies the same netting WITHOUT - * resetting the accumulator — the beforeunload flush uses it because a - * fire-and-forget save must leave the marks in place for a retry. - */ - -import type { SiteDocument } from '@core/page-tree' -import type { SaveConflict } from '@core/persistence/saveConflict' -import type { EditorStoreSliceCreator } from '@site/store/types' -import { emptyDirtyMarks, mergeDirtyMarks, type DirtyMarks } from './site/dirtyTracking' - -interface SaveTrackingSlice { - // Unsaved changes guard - hasUnsavedChanges: boolean - setHasUnsavedChanges: (value: boolean) => void - - /** - * Patch-derived save-dirty accumulator — which pages/VCs/layouts changed - * (and which were deleted) since the last successful save. - */ - _dirtySave: DirtyMarks - /** Conservative full-save mark (imports, fresh sites) — ships as a replace-mode save. */ - markAllDirtyForSave: () => void - /** Return the accumulated marks (netted against the live site) and reset the accumulator. */ - takeDirtySaveSnapshot: () => DirtyMarks - /** Netted copy of the accumulated marks WITHOUT resetting — for fire-and-forget flushes. */ - peekDirtySaveSnapshot: () => DirtyMarks - /** Merge a failed save's snapshot back so the next save retries it. */ - restoreDirtySaveSnapshot: (marks: DirtyMarks) => void - - /** - * Conflict-detection bases: rowId → the sync seq this editor last - * synchronized with (seeded at load, bumped to the response seq on every - * successful save — for DELETED rows too, so an undo that resurrects a - * saved-deleted row carries the right base instead of reading as a blind - * overwrite). Every incremental save ships the subset covering its - * changed + deleted rows; the server 409s when storage is newer. - */ - baseSeqs: Record - /** The shell's counterpart to `baseSeqs` — one coarse seq for the whole shell. */ - shellBaseSeq: number - /** - * Conflicts reported by the last 409'd save, pending user resolution via - * the conflict banner (Keep mine / Load theirs — see site/conflictActions). - * Autosave is suppressed while non-empty; a successful save clears it. - */ - saveConflicts: SaveConflict[] - /** - * The highest site-global seq this editor has synchronized — the cursor - * the live-sync socket sends on (re)connect so the server can reply with - * exactly the delta it missed. Advanced by loads, save responses, and - * every processed sync event. - */ - syncCursor: number - /** Advance the sync cursor (monotonic — lower values are ignored). */ - advanceSyncCursor: (seq: number) => void - /** - * Merge incoming conflicts (from live-sync events) into the pending list — - * deduped by table+rowId, keeping the newest seq. `setSaveConflicts` - * REPLACES the list and stays the 409 handler's entry point. - */ - addSaveConflicts: (conflicts: readonly SaveConflict[]) => void - /** Replace the base-seq maps wholesale — the load/reload path. */ - seedBaseSeqs: (rowSeqs: Record, shellSeq: number) => void - /** - * After a successful save: bump the bases of everything the save shipped to - * the response seq. Incremental saves cover the dirty-snapshot ids + the - * shell; replace-mode saves rebuild the whole map from the shipped site. - */ - commitSavedBaseSeqs: (savedSite: SiteDocument, dirty: DirtyMarks | undefined, seq: number) => void -} - -declare module '@site/store/types' { - // Surface this slice's fields on the combined EditorStore type. - interface EditorStore extends SaveTrackingSlice {} -} - -/** Copy `ids`, keeping only those for which `keep` holds. */ -function filteredSet(ids: ReadonlySet, keep: (id: string) => boolean): Set { - const out = new Set() - for (const id of ids) if (keep(id)) out.add(id) - return out -} - -/** Independent, netted copy of the marks (see module doc for the netting rule). */ -function nettedDirtySnapshot(current: DirtyMarks, site: SiteDocument | null): DirtyMarks { - if (!site) { - // No document to net against — return the marks as accumulated. - return { - all: current.all, - shell: current.shell, - pageIds: new Set(current.pageIds), - componentIds: new Set(current.componentIds), - layoutIds: new Set(current.layoutIds), - deletedPageIds: new Set(current.deletedPageIds), - deletedComponentIds: new Set(current.deletedComponentIds), - deletedLayoutIds: new Set(current.deletedLayoutIds), - } - } - const pageIds = new Set(site.pages.map((p) => p.id)) - const componentIds = new Set(site.visualComponents.map((vc) => vc.id)) - const layoutIds = new Set(site.layouts.map((l) => l.id)) - return { - all: current.all, - shell: current.shell, - pageIds: filteredSet(current.pageIds, (id) => pageIds.has(id)), - componentIds: filteredSet(current.componentIds, (id) => componentIds.has(id)), - layoutIds: filteredSet(current.layoutIds, (id) => layoutIds.has(id)), - deletedPageIds: filteredSet(current.deletedPageIds, (id) => !pageIds.has(id)), - deletedComponentIds: filteredSet(current.deletedComponentIds, (id) => !componentIds.has(id)), - deletedLayoutIds: filteredSet(current.deletedLayoutIds, (id) => !layoutIds.has(id)), - } -} - -export const createSaveTrackingSlice: EditorStoreSliceCreator = ( - set, - get, -) => ({ - hasUnsavedChanges: false, - - setHasUnsavedChanges: (value) => set({ hasUnsavedChanges: value }), - - _dirtySave: emptyDirtyMarks(), - - markAllDirtyForSave: () => - set((state) => { - state._dirtySave.all = true - }), - - takeDirtySaveSnapshot: () => { - const { _dirtySave: current, site } = get() - const snapshot = nettedDirtySnapshot(current, site) - set((state) => { - state._dirtySave = emptyDirtyMarks() - }) - return snapshot - }, - - peekDirtySaveSnapshot: () => { - const { _dirtySave: current, site } = get() - return nettedDirtySnapshot(current, site) - }, - - restoreDirtySaveSnapshot: (marks) => - set((state) => { - mergeDirtyMarks(state._dirtySave, marks) - }), - - baseSeqs: {}, - shellBaseSeq: 0, - saveConflicts: [], - syncCursor: 0, - - advanceSyncCursor: (seq) => - set((state) => { - if (seq > state.syncCursor) state.syncCursor = seq - }), - - addSaveConflicts: (conflicts) => - set((state) => { - for (const incoming of conflicts) { - const existing = state.saveConflicts.find( - (c) => c.table === incoming.table && c.rowId === incoming.rowId, - ) - if (existing) { - existing.seq = Math.max(existing.seq, incoming.seq) - } else { - state.saveConflicts.push({ ...incoming }) - } - } - }), - - seedBaseSeqs: (rowSeqs, shellSeq) => - set((state) => { - state.baseSeqs = { ...rowSeqs } - state.shellBaseSeq = shellSeq - state.syncCursor = Math.max(shellSeq, ...Object.values(rowSeqs), 0) - }), - - commitSavedBaseSeqs: (savedSite, dirty, seq) => - set((state) => { - if (!dirty || dirty.all) { - // Replace-mode save — storage now holds exactly the shipped site. - const next: Record = {} - for (const page of savedSite.pages) next[page.id] = seq - for (const vc of savedSite.visualComponents) next[vc.id] = seq - for (const layout of savedSite.layouts) next[layout.id] = seq - state.baseSeqs = next - } else { - const shippedIds = [ - ...dirty.pageIds, ...dirty.deletedPageIds, - ...dirty.componentIds, ...dirty.deletedComponentIds, - ...dirty.layoutIds, ...dirty.deletedLayoutIds, - ] - for (const id of shippedIds) state.baseSeqs[id] = seq - } - // Correct even when the server skipped the shell write (content - // unchanged): the stored shell seq then stays BELOW this save's seq, - // and the conflict check only fires on stored > base. - state.shellBaseSeq = seq - if (seq > state.syncCursor) state.syncCursor = seq - // A successful save proves no shipped row conflicted — stale banner - // entries (all conflicts come from shipped rows) are moot. - state.saveConflicts = [] - }), -}) diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts new file mode 100644 index 000000000..ac73f6f9f --- /dev/null +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -0,0 +1,578 @@ +/** + * Collab binding — glues the editor store to the CRDT documents. + * + * Write path (hybrid, chosen for zero disturbance of the hot path): + * - LOCAL mutations keep applying to the Zustand store directly (exactly + * the pre-collab behavior — structural sharing, O(change)), and their + * Mutative patches are translated into Y operations + * (`applySitePatchesToDocs`, LOCAL_ORIGIN). + * - NON-local doc changes (remote peers, undo/redo, reconcile) flow the + * other way: a per-doc projection replaces the affected row/shell in the + * store. Round-trip identity between the two paths is what the + * @core/collab test suite pins down. + * + * Undo — per-doc Y.UndoManager tracking LOCAL_ORIGIN only (remote edits are + * never undoable by this user): + * - coalescing reproduces the old `coalesceKey` semantics: consecutive + * same-key mutations merge into one undo step (captureTimeout ∞ + + * explicit `stopCapturing` whenever the key breaks), + * - a global routing stack maps Cmd+Z to the doc that captured the most + * recent local step (multi-doc mutations route to their PRIMARY doc — + * the site doc for roster/shell ops, the row doc otherwise). + * + * Modes: + * - DETACHED (default; tests, and the window before the socket connects): + * docs live locally, seeded from the loaded site. Everything works + * single-user with no transport. + * - CONNECTED (editor runtime): `connectCollabProvider` rebinds every doc + * through the CollabProvider — empty docs that the SERVER seeds (the + * single-seeder rule). Edits gate on per-doc sync (sub-second); the + * HTTP-loaded projection keeps the canvas painted meanwhile. + */ +import * as Y from 'yjs' +import type { Patches } from 'mutative' +import type { StoreApi } from 'zustand' +import type { Page, PageNode, SiteDocument } from '@core/page-tree' +import type { VisualComponent } from '@core/visualComponents' +import type { SavedLayout } from '@core/layouts' +import { + applySitePatchesToDocs, + createCollabDocSet, + dataMap, + encodeCollabDocId, + LOCAL_ORIGIN, + metaMap, + parseCollabDocId, + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, + rostersMap, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, + SEED_ORIGIN, + shellMap, + SITE_DOC_ID, + treeMap, + type CollabDocSet, +} from '@core/collab' +import { clonePackageJson } from '@core/site-dependencies/manifest' +import { cloneSiteRuntimeConfig } from '@core/site-runtime' +import type { EditorStore } from '@site/store/types' +import type { CollabProvider } from '@site/collab/collabProvider' + +interface ManagedDoc { + doc: Y.Doc + manager: Y.UndoManager + detach: () => void +} + +let storeApi: StoreApi | null = null +let docs: CollabDocSet = createCollabDocSet() +let managed = new Map() +/** + * Undo routing — one entry per undoable STEP, holding every docId whose + * UndoManager captured a new stack item during that step. Single-doc + * mutations push `[docId]`; multi-doc mutations (convert-to-component, + * roster ops, Super Import) push the whole group so one Cmd+Z reverts the + * entire mutation across all its docs. + */ +let undoRoute: string[][] = [] +let redoRoute: string[][] = [] +const lastCoalesce = new Map() +let provider: CollabProvider | null = null +let detachProviderReset: (() => void) | null = null +const pendingProjections = new Set() +let projectionFlushScheduled = false +/** + * The exact store `site` object the doc world currently mirrors. Every path + * that changes both sides together records the new reference (local + * mutations, loads/resets, projections). When a mutation's PRE-site is a + * different object, the store was replaced out-of-band (tests hand-assemble + * via setState) — the detached doc world is stale wholesale and rebuilds + * from the pre-mutation site before translating. + */ +let alignedSiteRef: SiteDocument | null = null + +/** Called once by store creation — the binding's only handle into Zustand. */ +export function initCollabBinding(api: StoreApi): void { + storeApi = api +} + +// --------------------------------------------------------------------------- +// Doc infrastructure +// --------------------------------------------------------------------------- + +function undoScopesFor(docId: string, doc: Y.Doc): Y.Map[] { + return docId === SITE_DOC_ID + ? [shellMap(doc), rostersMap(doc)] + : [treeMap(doc), metaMap(doc), dataMap(doc)] +} + +function ensureManaged(docId: string, doc: Y.Doc): ManagedDoc { + const existing = managed.get(docId) + if (existing && existing.doc === doc) return existing + existing?.detach() + + const manager = new Y.UndoManager(undoScopesFor(docId, doc), { + trackedOrigins: new Set([LOCAL_ORIGIN]), + // Coalescing is controlled explicitly via stopCapturing (coalesceKey + // semantics) — an infinite window means WE decide the undo-step breaks. + captureTimeout: Number.MAX_SAFE_INTEGER, + }) + + const updateHandler = (_update: Uint8Array, origin: unknown) => { + // Local mutations already applied to the store directly; seeds mirror + // what the store just loaded. Everything else projects back. + if (origin === LOCAL_ORIGIN || origin === SEED_ORIGIN) return + scheduleProjection(docId) + } + doc.on('update', updateHandler) + + const entry: ManagedDoc = { + doc, + manager, + detach: () => { + doc.off('update', updateHandler) + manager.destroy() + }, + } + managed.set(docId, entry) + return entry +} + +/** DocSet facade the patch translator uses — attaches undo/observer infra on demand. */ +const managedDocSet: CollabDocSet = { + get: (docId) => docs.get(docId), + ensure: (docId) => { + if (provider) { + const binding = provider.bind(docId) + if (docs.get(docId) !== binding.doc) adoptProviderDoc(docId, binding.doc) + return binding.doc + } + const doc = docs.ensure(docId) + ensureManaged(docId, doc) + return doc + }, + set: (docId, doc) => { + docs.set(docId, doc) + ensureManaged(docId, doc) + }, + delete: (docId) => { + managed.get(docId)?.detach() + managed.delete(docId) + docs.delete(docId) + }, + entries: () => docs.entries(), +} + +// --------------------------------------------------------------------------- +// Local write path +// --------------------------------------------------------------------------- + +/** + * Translate one local mutation's site patches into the docs. Returns false + * when the write is currently gated (connected but the target docs have not + * finished their first sync) — the caller then rejects the mutation. + */ +export function applyLocalSitePatches( + patches: Patches, + preSite: SiteDocument, + nextSite: SiteDocument, + coalesceKey: string | null, +): boolean { + if (provider) { + // 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 false + } + } + + if (!provider && preSite !== alignedSiteRef) { + // Out-of-band site replacement (setState without loadSite). Rebuild the + // detached doc world from the pre-mutation site so the translation — + // and its undo — covers exactly this mutation. History from before the + // replacement is meaningless against the new document and drops. + // Connected mode never rebuilds from the store: synced docs are the + // source of truth and the ref updates below as projections land. + resetCollabDocsFromSite(preSite) + } + + const stopped = new Set() + const decideCoalescing = (docId: string): void => { + if (stopped.has(docId)) return + stopped.add(docId) + const last = lastCoalesce.get(docId) ?? null + const entry = managed.get(docId) + if (entry && (coalesceKey === null || coalesceKey !== last)) { + entry.manager.stopCapturing() + } + lastCoalesce.set(docId, coalesceKey) + } + + const decidingDocSet: CollabDocSet = { + ...managedDocSet, + ensure: (docId) => { + const doc = managedDocSet.ensure(docId) + decideCoalescing(docId) + return doc + }, + } + + const before = new Map() + for (const [docId, entry] of managed) before.set(docId, entry.manager.undoStack.length) + + const touched = applySitePatchesToDocs(patches, preSite, nextSite, decidingDocSet, LOCAL_ORIGIN) + alignedSiteRef = nextSite + if (touched.length === 0) return true + + // Docs whose undo stack GREW form this step's routing group. Docs that + // only folded into their existing top item (coalescing) push nothing — + // the burst's original group already covers them. + const grew = touched.filter( + (docId) => (managed.get(docId)?.manager.undoStack.length ?? 0) > (before.get(docId) ?? 0), + ) + if (grew.length > 0) { + undoRoute.push(grew) + redoRoute = [] + syncUndoFlags() + } + return true +} + +/** End any in-progress coalescing burst (inline-edit session boundaries). */ +export function collabBreakCoalescing(): void { + lastCoalesce.clear() +} + +// --------------------------------------------------------------------------- +// Undo / redo +// --------------------------------------------------------------------------- + +function syncUndoFlags(): void { + storeApi?.setState({ + canUndo: undoRoute.length > 0, + canRedo: redoRoute.length > 0, + }) +} + +export function collabUndo(): boolean { + while (undoRoute.length > 0) { + const group = undoRoute.pop()! + let undid = false + lastCoalesce.clear() + // Reverse touch order: the site doc is always first in a group, so + // row-level reverts land before the roster/shell revert projects. + for (const docId of [...group].reverse()) { + const entry = managed.get(docId) + if (entry && entry.manager.undoStack.length > 0) { + entry.manager.undo() + undid = true + } + } + if (undid) { + // Undo repaints synchronously — the managers fired the doc updates + // inside .undo(), so the projections are already pending. + flushProjections() + redoRoute.push(group) + syncUndoFlags() + return true + } + } + syncUndoFlags() + return false +} + +export function collabRedo(): boolean { + while (redoRoute.length > 0) { + const group = redoRoute.pop()! + let redid = false + lastCoalesce.clear() + for (const docId of group) { + const entry = managed.get(docId) + if (entry && entry.manager.redoStack.length > 0) { + entry.manager.redo() + redid = true + } + } + if (redid) { + flushProjections() + undoRoute.push(group) + syncUndoFlags() + return true + } + } + syncUndoFlags() + return false +} + +export function collabClearHistory(): void { + for (const [, entry] of managed) entry.manager.clear() + undoRoute = [] + redoRoute = [] + lastCoalesce.clear() + syncUndoFlags() +} + +// --------------------------------------------------------------------------- +// Projection (remote / undo / reconcile → store) +// --------------------------------------------------------------------------- + +function flushProjections(): void { + const batch = [...pendingProjections] + pendingProjections.clear() + // Site doc last — it assembles rows the row projections just refreshed. + batch.sort((a, b) => (a === SITE_DOC_ID ? 1 : 0) - (b === SITE_DOC_ID ? 1 : 0)) + for (const id of batch) projectDocIntoStore(id) +} + +function scheduleProjection(docId: string): void { + pendingProjections.add(docId) + if (projectionFlushScheduled) return + projectionFlushScheduled = true + queueMicrotask(() => { + projectionFlushScheduled = false + flushProjections() + }) +} + +function rowFromDoc(docId: string): Page | VisualComponent | SavedLayout | null { + const parsed = parseCollabDocId(docId) + if (!parsed || parsed.kind === 'site') return null + const doc = docs.get(docId) + if (!doc) return null + if (parsed.kind === 'page') { + const page = projectPageDoc(doc, parsed.rowId) + return page.rootNodeId ? page : null + } + if (parsed.kind === 'component') { + const vc = projectComponentDoc(doc, parsed.rowId) + return vc.tree.rootNodeId ? vc : null + } + const layout = projectLayoutDoc(doc, parsed.rowId) + return layout.rootNodeId ? layout : null +} + +function projectDocIntoStore(docId: string): void { + const api = storeApi + if (!api) return + const state = api.getState() + const site = state.site + if (!site) return + const parsed = parseCollabDocId(docId) + if (!parsed) return + + if (parsed.kind === 'site') { + const doc = docs.get(docId) + if (!doc) return + const projected = projectSiteDoc(doc) + if (Object.keys(projected.shell).length === 0) return + const byId = { + pages: new Map(site.pages.map((p) => [p.id, p])), + components: new Map(site.visualComponents.map((vc) => [vc.id, vc])), + layouts: new Map(site.layouts.map((l) => [l.id, l])), + } + const assemble = ( + ids: readonly string[], + existing: Map, + kind: 'page' | 'component' | 'layout', + ): T[] => { + const rows: T[] = [] + for (const id of ids) { + const known = existing.get(id) + if (known) { + rows.push(known) + continue + } + const fresh = rowFromDoc(encodeCollabDocId({ kind, rowId: id })) as T | null + if (fresh) rows.push(fresh) + } + return rows + } + const nextSite: SiteDocument = { + ...site, + ...(projected.shell as Partial), + pages: assemble(projected.rosters.pages, byId.pages, 'page'), + visualComponents: assemble(projected.rosters.components, byId.components, 'component'), + layouts: assemble(projected.rosters.layouts, byId.layouts, 'layout'), + } + if (projected.shell.conditions === undefined) delete nextSite.conditions + const packageJson = clonePackageJson(nextSite.packageJson) + const siteRuntime = cloneSiteRuntimeConfig(nextSite.runtime) + const alignedSite = { ...nextSite, packageJson, runtime: siteRuntime } + alignedSiteRef = alignedSite + api.setState({ + site: alignedSite, + packageJson, + siteRuntime, + ...(nextSite.pages.some((p) => p.id === state.activePageId) + ? {} + : { activePageId: nextSite.pages[0]?.id ?? null }), + }) + return + } + + const row = rowFromDoc(docId) + const collection = + parsed.kind === 'page' ? 'pages' : parsed.kind === 'component' ? 'visualComponents' : 'layouts' + const rows = site[collection] as Array<{ id: string }> + const index = rows.findIndex((r) => r.id === parsed.rowId) + if (!row) { + if (index === -1) return + const nextRows = rows.filter((r) => r.id !== parsed.rowId) + const nextSite = { ...site, [collection]: nextRows } as SiteDocument + alignedSiteRef = nextSite + api.setState({ site: nextSite }) + return + } + const nextRows = index === -1 ? [...rows, row] : rows.map((r, i) => (i === index ? row : r)) + const nextSite = { ...site, [collection]: nextRows } as SiteDocument + alignedSiteRef = nextSite + const patch: Partial = { + site: nextSite, + } + // Selection can only reference the ACTIVE tree — clear it when its node + // vanished from the freshly projected row. + if (state.selectedNodeId) { + const activeTree: Record | undefined = + state.activeDocument?.kind === 'visualComponent' + ? state.activeDocument.vcId === parsed.rowId + ? ((row as VisualComponent).tree.nodes as Record) + : undefined + : state.activePageId === parsed.rowId + ? ((row as Page).nodes as Record) + : undefined + if (activeTree && !activeTree[state.selectedNodeId]) { + patch.selectedNodeId = null + patch.selectedNodeIds = [] + patch.hoveredNodeId = null + } + } + api.setState(patch) +} + +// --------------------------------------------------------------------------- +// Lifecycle + provider connection +// --------------------------------------------------------------------------- + +const providerBindings = new Map() + +function allDocIdsForSite(site: SiteDocument): string[] { + return [ + SITE_DOC_ID, + ...site.pages.map((p) => `page:${p.id}`), + ...site.visualComponents.map((vc) => `component:${vc.id}`), + ...site.layouts.map((l) => `layout:${l.id}`), + ] +} + +/** + * Reset the doc world to mirror a freshly loaded site (or nothing). In + * detached mode the docs are seeded locally; in connected mode every doc + * rebinds through the provider (server-seeded). + */ +export function resetCollabDocsFromSite(site: SiteDocument | null): void { + alignedSiteRef = site + for (const [, entry] of managed) entry.detach() + managed = new Map() + providerBindings.clear() + const previous = docs + docs = createCollabDocSet() + for (const [docId] of previous.entries()) { + if (provider) provider.unbind(docId) + else previous.delete(docId) + } + undoRoute = [] + redoRoute = [] + lastCoalesce.clear() + // Drop any projection still queued for the OLD docs — flushing it against + // the fresh doc set would project empty rows into the just-loaded site. + pendingProjections.clear() + syncUndoFlags() + + if (!site) return + if (provider) { + bindThroughProvider(allDocIdsForSite(site)) + return + } + seedDetachedDocs(site) +} + +function seedDetachedDocs(site: SiteDocument): void { + const siteDoc = docs.ensure(SITE_DOC_ID) + seedSiteDoc(siteDoc, site) + ensureManaged(SITE_DOC_ID, siteDoc) + for (const page of site.pages) { + const doc = docs.ensure(`page:${page.id}`) + seedPageDoc(doc, page) + ensureManaged(`page:${page.id}`, doc) + } + for (const vc of site.visualComponents) { + const doc = docs.ensure(`component:${vc.id}`) + seedComponentDoc(doc, vc) + ensureManaged(`component:${vc.id}`, doc) + } + for (const layout of site.layouts) { + const doc = docs.ensure(`layout:${layout.id}`) + seedLayoutDoc(doc, layout) + ensureManaged(`layout:${layout.id}`, doc) + } +} + +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 bindThroughProvider(docIds: readonly string[]): void { + if (!provider) return + for (const docId of docIds) { + const binding = provider.bind(docId) + const gate = adoptProviderDoc(docId, binding.doc) + gate.synced = binding.synced + void binding.whenSynced.then(() => { + gate.synced = true + scheduleProjection(docId) + }) + } +} + +/** + * Switch to CONNECTED mode: all current and future docs bind through the + * provider (server-seeded). Called by the editor runtime once the socket + * exists; never called in unit tests (detached mode). + */ +export function connectCollabProvider(next: CollabProvider): void { + provider = next + detachProviderReset?.() + detachProviderReset = next.onReset((docId) => { + // The server dropped this doc (out-of-relay JSON rewrite): rebind and + // let the fresh server seed re-project into the store. + const rebound = next.bind(docId) + const gate = adoptProviderDoc(docId, rebound.doc) + gate.synced = rebound.synced + void rebound.whenSynced.then(() => { + gate.synced = true + scheduleProjection(docId) + }) + }) + const site = storeApi?.getState().site ?? null + resetCollabDocsFromSite(site) +} + +export function disconnectCollabProvider(): void { + detachProviderReset?.() + detachProviderReset = null + provider?.destroy() + provider = null + providerBindings.clear() + const site = storeApi?.getState().site ?? null + resetCollabDocsFromSite(site) +} diff --git a/src/admin/pages/site/store/slices/site/conflictActions.ts b/src/admin/pages/site/store/slices/site/conflictActions.ts deleted file mode 100644 index 081085639..000000000 --- a/src/admin/pages/site/store/slices/site/conflictActions.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Remote-apply + save-conflict resolution actions (multi-admin levels A+B). - * - * `applyRemoteSnapshot` is the ONE way remote state enters the live editor - * document. Two callers share it: - * - the conflict banner's **Load theirs** (level A) — the user explicitly - * discards their local edits to one conflicted target; - * - the live-sync socket's **clean-target pull** (level B) — a sibling - * admin saved a row this editor holds clean, so it merges in place. - * - * Apply semantics: - * - the target is swapped in (or removed, when deleted remotely) WITHOUT - * pushing undo history; its dirty marks, base seq, and any pending - * conflict entry are synchronized; - * - the undo history is cleared — history entries are site-relative - * Mutative patches with array indices, and replaying them across a - * remotely swapped tree is undefined behavior; - * - EXCEPT when the fetched remote content deep-equals the local copy. - * That is exactly what the echo of this editor's own save looks like - * (the socket event can arrive before the save response), so the apply - * collapses to bookkeeping — and the user's undo history survives. - * - * VC consumer propagation: adopting a remote Visual Component re-syncs the - * slot instances of every ref to it, and adopting a remote VC DELETION - * cascades ref removal — both through `mutateSite`, so the affected consumer - * pages earn REAL dirty marks (they now differ from storage and must ship - * with the next save). The history entries those mutations push are cleared - * along with everything else. - * - * `resolveSaveConflictKeepMine` (level A) is the other resolution: bump the - * target's base seq so the NEXT save overwrites the remote version — a - * stated decision instead of a silent one. Local state stays untouched, so - * its history survives. - */ - -import type { BaseNode } from '@core/page-tree' -import { findHomePage, reconcileSiteExplorerInPlace, reindexNodeParents } from '@core/page-tree' -import { shellsEqual } from '@core/persistence/shellsEqual' -import { clonePackageJson } from '@core/site-dependencies/manifest' -import { cloneSiteRuntimeConfig } from '@core/site-runtime' -import { deepEqual } from '@core/utils/deepEqual' -import type { EditorStore } from '@site/store/types' -import type { Draft } from 'mutative' -import { renderCache } from '@site/canvas/renderCache' -import { clearCanvasSelectionDraft } from '../selectionSlice' -import { allTreeNodeMaps, syncAllVCRefSlotInstances } from '../vcSlotReconcile' -import { cascadeRemoveVCRefs } from '../vcTreeOps' -import { reconcileFrameworkClasses } from './framework/reconcile' -import type { RemoteSnapshot, SiteSlice, SiteSliceHelpers } from './types' - -type ConflictActions = Pick< - SiteSlice, - 'setSaveConflicts' | 'resolveSaveConflictKeepMine' | 'applyRemoteSnapshot' -> - -function dropConflict(state: Draft, table: string, rowId: string): void { - state.saveConflicts = state.saveConflicts.filter( - (c) => !(c.table === table && c.rowId === rowId), - ) -} - -function clearUndoHistory(state: Draft): void { - state._historyPast = [] - state._historyFuture = [] - state._historyCoalesceKey = null - state.canUndo = false - state.canRedo = false -} - -/** The snapshot's target rowId — the shell lives on the fixed 'default' row. */ -function snapshotRowId(snapshot: RemoteSnapshot): string { - return snapshot.table === 'site' ? 'default' : snapshot.rowId -} - -/** - * True when the remote snapshot is content-identical to the local document — - * the apply can collapse to bookkeeping (see module doc). For a remote - * deletion, "identical" means the target is already absent locally. - */ -function snapshotMatchesLocal( - site: EditorStore['site'], - snapshot: RemoteSnapshot, -): boolean { - if (!site) return false - if (snapshot.table === 'site') { - const { pages: _p, visualComponents: _v, layouts: _l, ...localShell } = site - // shellsEqual ignores `updatedAt` — bumped by every local mutation, so a - // plain deep-equal would misread every own-save echo as a remote change. - return shellsEqual(localShell, snapshot.shell) - } - const local = - snapshot.table === 'pages' - ? site.pages.find((p) => p.id === snapshot.rowId) - : snapshot.table === 'components' - ? site.visualComponents.find((vc) => vc.id === snapshot.rowId) - : site.layouts.find((layout) => layout.id === snapshot.rowId) - if (snapshot.row === null) return local === undefined - return local !== undefined && deepEqual(local, snapshot.row) -} - -export function createConflictActions({ set, get, mutateSite }: SiteSliceHelpers): ConflictActions { - return { - setSaveConflicts: (conflicts) => - set((state) => { - state.saveConflicts = [...conflicts] - }), - - resolveSaveConflictKeepMine: (conflict) => - set((state) => { - dropConflict(state, conflict.table, conflict.rowId) - if (conflict.table === 'site') { - state.shellBaseSeq = Math.max(state.shellBaseSeq, conflict.seq) - } else { - state.baseSeqs[conflict.rowId] = Math.max( - state.baseSeqs[conflict.rowId] ?? 0, - conflict.seq, - ) - } - // The failed save restored its dirty marks — nothing else to do; the - // next save ships the same rows and now passes the seq check. - }), - - applyRemoteSnapshot: (snapshot) => { - // Echo / no-op detection BEFORE any mutation: identical content means - // this editor is already synchronized (typically its own save echoing - // back through the socket) — sync the bookkeeping, keep the history. - if (snapshotMatchesLocal(get().site, snapshot)) { - set((state) => { - dropConflict(state, snapshot.table, snapshotRowId(snapshot)) - if (snapshot.table === 'site') { - state.shellBaseSeq = Math.max(state.shellBaseSeq, snapshot.seq) - } else if (snapshot.row === null) { - delete state.baseSeqs[snapshot.rowId] - // Aligned deletion (both sides deleted) — nothing left to ship. - const deletedMarks = - snapshot.table === 'pages' - ? state._dirtySave.deletedPageIds - : snapshot.table === 'components' - ? state._dirtySave.deletedComponentIds - : state._dirtySave.deletedLayoutIds - deletedMarks.delete(snapshot.rowId) - } else { - state.baseSeqs[snapshot.rowId] = Math.max( - state.baseSeqs[snapshot.rowId] ?? 0, - snapshot.seq, - ) - } - }) - return { applied: false, clearedHistory: false } - } - - // Read BEFORE the VC propagation below — `clearedHistory` reports - // whether the USER's undo entries were discarded, not the propagation's - // own transient entry. - const hadHistory = - get()._historyPast.length > 0 || get()._historyFuture.length > 0 - - // Consumer propagation FIRST (see module doc): mutateSite gives the - // affected pages real patch-derived dirty marks. Neither helper needs - // the VC swapped in yet — the sync takes the remote VC as an argument, - // and the cascade only reads ref nodes. - if (snapshot.table === 'components') { - if (snapshot.row) { - const vc = snapshot.row - mutateSite((site) => { - syncAllVCRefSlotInstances(allTreeNodeMaps(site), vc.id, vc) - }) - } else { - mutateSite((site) => { - for (const page of site.pages) { - cascadeRemoveVCRefs(page.nodes as Record, snapshot.rowId) - } - for (const vc of site.visualComponents) { - if (vc.id !== snapshot.rowId) { - cascadeRemoveVCRefs(vc.tree.nodes as Record, snapshot.rowId) - } - } - }) - } - } - - // Remote HTML swaps under the canvas — drop cached renders wholesale. - renderCache.clear() - - set((state) => { - const site = state.site - if (!site) return - dropConflict(state, snapshot.table, snapshotRowId(snapshot)) - - switch (snapshot.table) { - case 'site': { - // Overwrite every shell field in place; the row-backed - // collections stay untouched. `conditions` is the one optional - // shell key — drop it explicitly when the remote shell lacks it. - Object.assign(site, snapshot.shell) - if (snapshot.shell.conditions === undefined) delete site.conditions - reconcileFrameworkClasses(site) - reconcileSiteExplorerInPlace(site) - state.packageJson = clonePackageJson(snapshot.shell.packageJson) - state.siteRuntime = cloneSiteRuntimeConfig(snapshot.shell.runtime) - state.shellBaseSeq = snapshot.seq - break - } - - case 'pages': { - state._dirtySave.pageIds.delete(snapshot.rowId) - state._dirtySave.deletedPageIds.delete(snapshot.rowId) - const idx = site.pages.findIndex((p) => p.id === snapshot.rowId) - const wasActive = - state.activePageId === snapshot.rowId && - (state.activeDocument === null || state.activeDocument.kind === 'page') - if (snapshot.row) { - reindexNodeParents(snapshot.row.nodes) - if (idx >= 0) site.pages[idx] = snapshot.row - else site.pages.push(snapshot.row) - state.baseSeqs[snapshot.rowId] = snapshot.seq - } else { - if (idx >= 0) site.pages.splice(idx, 1) - delete state.baseSeqs[snapshot.rowId] - if (state.activePageId === snapshot.rowId) { - state.activePageId = (findHomePage(site.pages) ?? site.pages[0])?.id ?? null - } - // A page-kind activeDocument pointing at the removed page would - // make mutateActiveTree silently no-op — fall back to page mode. - if ( - state.activeDocument?.kind === 'page' && - state.activeDocument.pageId === snapshot.rowId - ) { - state.activeDocument = null - } - } - reconcileSiteExplorerInPlace(site) - // The canvas may hold selection/hover state into the replaced - // (or removed) tree — clear it when that document was active. - if (wasActive) clearCanvasSelectionDraft(state) - break - } - - case 'components': { - state._dirtySave.componentIds.delete(snapshot.rowId) - state._dirtySave.deletedComponentIds.delete(snapshot.rowId) - const idx = site.visualComponents.findIndex((vc) => vc.id === snapshot.rowId) - const wasActive = - state.activeDocument?.kind === 'visualComponent' && - state.activeDocument.vcId === snapshot.rowId - if (snapshot.row) { - reindexNodeParents(snapshot.row.tree.nodes) - if (idx >= 0) site.visualComponents[idx] = snapshot.row - else site.visualComponents.push(snapshot.row) - state.baseSeqs[snapshot.rowId] = snapshot.seq - } else { - if (idx >= 0) site.visualComponents.splice(idx, 1) - delete state.baseSeqs[snapshot.rowId] - // The open document was deleted remotely — fall back to page mode. - if (wasActive) state.activeDocument = null - } - reconcileSiteExplorerInPlace(site) - if (wasActive) clearCanvasSelectionDraft(state) - break - } - - case 'layouts': { - state._dirtySave.layoutIds.delete(snapshot.rowId) - state._dirtySave.deletedLayoutIds.delete(snapshot.rowId) - const idx = site.layouts.findIndex((layout) => layout.id === snapshot.rowId) - if (snapshot.row) { - reindexNodeParents(snapshot.row.nodes) - if (idx >= 0) site.layouts[idx] = snapshot.row - else site.layouts.push(snapshot.row) - state.baseSeqs[snapshot.rowId] = snapshot.seq - } else { - if (idx >= 0) site.layouts.splice(idx, 1) - delete state.baseSeqs[snapshot.rowId] - } - break - } - } - - clearUndoHistory(state) - }) - - return { applied: true, clearedHistory: hadHistory } - }, - } -} diff --git a/src/admin/pages/site/store/slices/site/defaults.ts b/src/admin/pages/site/store/slices/site/defaults.ts index 04a49187f..879cd8706 100644 --- a/src/admin/pages/site/store/slices/site/defaults.ts +++ b/src/admin/pages/site/store/slices/site/defaults.ts @@ -1,5 +1,5 @@ /** - * Default SiteDocument constructor + history sizing constants for the site slice. + * Default SiteDocument constructor for the site slice. */ import { nanoid } from 'nanoid' @@ -20,9 +20,6 @@ import { DEFAULT_SITE_RUNTIME, } from '@core/site-runtime' -/** Maximum undo history depth — prevents unbounded memory growth. */ -export const MAX_HISTORY = 50 - export function createDefaultSiteDocument(name: string): SiteDocument { const rootNode = createNode('base.body') const homePage: Page = { diff --git a/src/admin/pages/site/store/slices/site/dirtyTracking.ts b/src/admin/pages/site/store/slices/site/dirtyTracking.ts deleted file mode 100644 index c7db34ad5..000000000 --- a/src/admin/pages/site/store/slices/site/dirtyTracking.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Patch-derived save-dirty tracking. - * - * Every undoable mutation already produces site-relative Mutative patches - * (for undo history); this module reuses them to answer "which pages, - * Visual Components, and saved layouts changed — and which were DELETED — - * since the last successful save", so autosave can ship - * `{ changedPages, deletedPageIds }` instead of the whole roster. - * - * Write attribution (paths are relative to the SiteDocument): - * - `['pages', i, …]` / `['visualComponents', i, …]` / `['layouts', i, …]` - * → the POST-mutation element at index `i` is dirty. Post-state indexing - * is correct for every op that leaves an element at that index (add, - * replace, nested edits); reorders mark each displaced index — - * over-marking, which is safe. - * - `['pages']` wholesale replacement, an index that doesn't resolve in the - * post-state, or any unrecognised shape → `all` (conservative full save, - * shipped as a replace-mode save). - * - Any other top-level path is a shell field; the shell is always saved, - * so it needs no marks. - * - * Deletion attribution deliberately does NOT read patch shapes. Array - * removal produces different patch sets depending on how the recipe mutated - * (splice → shifted-index replaces + length op; filter-reassign → wholesale - * replace), so per-patch attribution of "which row disappeared" is fragile. - * Instead, when the patch set contains any op that can change a collection's - * membership (an op at collection depth ≤ 2, a `length` bookkeeping op, or - * any `remove`), the collector diffs the PRE vs POST id sets of that - * collection — O(collection) only on membership-shaped mutations, never on - * plain prop edits, and robust to every recipe style. - * - * The invariant: OVER-marking costs a few redundant page writes; - * UNDER-marking loses edits. Anything ambiguous must resolve to `all`. - * Deleted-vs-written netting for one row (delete then re-create in the same - * save window) happens at snapshot time in the save-tracking slice — a row - * that exists in the store at snapshot time is never shipped as deleted, and - * a write-mark for a row that no longer exists is dropped. - */ - -import type { Patches } from 'mutative' -import type { SiteDocument } from '@core/page-tree' - -export interface DirtyMarks { - all: boolean - /** - * A shell field (settings, style rules, files, breakpoints, explorer, …) - * changed since the last successful save. The shell still SHIPS with every - * save regardless (the server skips writing an unchanged shell) — this - * mark exists for the live-sync merge rule: a remote `shell-changed` event - * applies silently when the local shell is untouched, and surfaces as a - * conflict when it isn't. - */ - shell: boolean - pageIds: Set - componentIds: Set - layoutIds: Set - deletedPageIds: Set - deletedComponentIds: Set - deletedLayoutIds: Set -} - -export function emptyDirtyMarks(): DirtyMarks { - return { - all: false, - shell: false, - pageIds: new Set(), - componentIds: new Set(), - layoutIds: new Set(), - deletedPageIds: new Set(), - deletedComponentIds: new Set(), - deletedLayoutIds: new Set(), - } -} - -const TRACKED_COLLECTIONS = ['pages', 'visualComponents', 'layouts'] as const -type TrackedCollection = (typeof TRACKED_COLLECTIONS)[number] - -function writeSetFor(marks: DirtyMarks, head: TrackedCollection): Set { - if (head === 'pages') return marks.pageIds - if (head === 'visualComponents') return marks.componentIds - return marks.layoutIds -} - -function deleteSetFor(marks: DirtyMarks, head: TrackedCollection): Set { - if (head === 'pages') return marks.deletedPageIds - if (head === 'visualComponents') return marks.deletedComponentIds - return marks.deletedLayoutIds -} - -/** - * Diff a collection's pre/post membership: ids present before and gone after - * are deletions; ids present after but not before are creations (marked as - * writes — the post-index attribution usually catches them too; the set - * union is idempotent). - */ -function diffCollectionMembership( - marks: DirtyMarks, - collection: TrackedCollection, - preSite: SiteDocument, - postSite: SiteDocument, -): void { - const preIds = new Set(preSite[collection].map((item) => item.id)) - const postIds = new Set(postSite[collection].map((item) => item.id)) - for (const id of preIds) { - if (!postIds.has(id)) deleteSetFor(marks, collection).add(id) - } - for (const id of postIds) { - if (!preIds.has(id)) writeSetFor(marks, collection).add(id) - } -} - -/** - * True when a patch on `collection` can change its membership (not just a - * nested prop). Array membership only changes through ops on the array - * itself — whole-array replace (depth 1), an index slot, or the `length` - * bookkeeping (depth 2). Deeper ops (e.g. removing a node key inside a - * page's node map) never change which rows exist, so they must not trigger - * the O(collection) id diff on the hot node-edit path. - */ -function isMembershipShapedOp(patch: Patches[number]): boolean { - return patch.path.length <= 2 -} - -/** Derive dirty marks from one mutation's site-relative patches. */ -export function collectDirtyFromSitePatches( - patches: Patches, - preSite: SiteDocument, - postSite: SiteDocument, -): DirtyMarks { - const marks = emptyDirtyMarks() - const membershipDiffed = new Set() - - for (const patch of patches) { - const head = patch.path[0] - if (!TRACKED_COLLECTIONS.includes(head as TrackedCollection)) { - // `updatedAt` is bumped by EVERY historic mutation (page edits - // included) — it is bookkeeping, not shell content, and must not make - // every edit look like a shell edit (the server's `shellsEqual` - // ignores it for the same reason). - if (head !== 'updatedAt') { - // Shell field — always shipped with the save; the mark feeds the - // live-sync merge rule (see the DirtyMarks doc). - marks.shell = true - } - continue - } - const collection = head as TrackedCollection - - // Membership-shaped ops → pre/post id-set diff, once per collection. - if (isMembershipShapedOp(patch) && !membershipDiffed.has(collection)) { - membershipDiffed.add(collection) - diffCollectionMembership(marks, collection, preSite, postSite) - } - - if (patch.path.length === 1) { - // Wholesale array replacement (e.g. an import recipe) — membership was - // diffed above, but the surviving rows' contents may ALSO have changed - // wholesale; attribute conservatively. - marks.all = true - continue - } - const index = patch.path[1] - if (index === 'length') continue // array bookkeeping; membership diff covers it - if (typeof index !== 'number') { - marks.all = true - continue - } - if (patch.op === 'remove' && patch.path.length === 2) { - continue // element removal; membership diff covers it - } - const element = postSite[collection][index] - if (!element) { - // Index doesn't resolve post-mutation (unexpected op ordering) — - // attribute conservatively. - marks.all = true - continue - } - writeSetFor(marks, collection).add(element.id) - } - return marks -} - -/** Merge `incoming` into a draft's accumulated dirty state, in place. */ -export function mergeDirtyMarks(target: DirtyMarks, incoming: DirtyMarks): void { - if (incoming.all) target.all = true - if (incoming.shell) target.shell = true - for (const id of incoming.pageIds) target.pageIds.add(id) - for (const id of incoming.componentIds) target.componentIds.add(id) - for (const id of incoming.layoutIds) target.layoutIds.add(id) - for (const id of incoming.deletedPageIds) target.deletedPageIds.add(id) - for (const id of incoming.deletedComponentIds) target.deletedComponentIds.add(id) - for (const id of incoming.deletedLayoutIds) target.deletedLayoutIds.add(id) -} diff --git a/src/admin/pages/site/store/slices/site/helpers.ts b/src/admin/pages/site/store/slices/site/helpers.ts index b1f8b9008..a3c3457ef 100644 --- a/src/admin/pages/site/store/slices/site/helpers.ts +++ b/src/admin/pages/site/store/slices/site/helpers.ts @@ -20,14 +20,13 @@ import type { Draft, Patches } from 'mutative' import type { ImportFragment } from '@core/htmlImport' import type { NewStyleRule } from '@core/siteImport' import { addImportedScriptDependencies, addImportedScripts, addImportedStylesheets } from './importedSiteFiles' -import { collectDirtyFromSitePatches, mergeDirtyMarks } from './dirtyTracking' +import { applyLocalSitePatches } from './collabBinding' import type { EditorStore } from '@site/store/types' -import { MAX_HISTORY } from './defaults' import { reconcileFrameworkClasses } from './framework/reconcile' import { indexStyleRulesByName, linkImportedClassNames } from './importLinking' import { addImportedColorTokens, overwriteImportedColorTokens } from './importedColorTokens' import { addImportedFonts, addImportedFontTokens, addInstalledFontEntries, overwriteImportedFontTokens } from './importedFonts' -import type { HistoryEntry, SiteMutationResult, SiteSliceHelpers, SiteSliceRecipe } from './types' +import type { SiteMutationResult, SiteSliceHelpers, SiteSliceRecipe } from './types' import type { SiteImportTransaction } from '@core/siteImport' /** @@ -100,63 +99,6 @@ function stringArraysEqual(a: string[], b: string[]): boolean { return true } -/** Serialize a patch path so fold dedup can key on it. */ -function patchPathKey(path: Patches[number]['path']): string { - return JSON.stringify(path) -} - -/** - * Fold one coalesced keystroke's patch pair into the in-progress burst entry, - * deduplicating by patch path: the entry holds AT MOST one inverse and one - * forward patch per touched path, instead of accumulating 2K pairs (each - * carrying the full prop value at that instant) over a K-keystroke burst. - * - * - inverse (undo): the OLDEST patch per path wins — it restores the - * pre-burst value. Patches for newly-touched paths are prepended, keeping - * the previous newest-first replay order for hierarchically-overlapping - * paths. - * - forward (redo): the NEWEST value per path wins, but the stored patch - * keeps the op of the OLDEST forward patch: if the burst CREATED the prop, - * the folded patch stays 'add'-shaped so redo replays from the post-undo - * state where the prop is absent. Mutative's `apply` treats 'add' and - * 'replace' identically for plain-object keys (verified against - * mutative@1.3.0 `src/apply.ts`, pinned by `historyCoalescingFold.test.ts`) - * — they differ only for array indices, which coalescing recipes never - * patch — so preserving the op is exactness, not necessity. When a - * 'remove' op is involved on either side, the incoming patch replaces the - * stored one wholesale: deleting an absent object key is a no-op, so the - * newest patch already describes the burst's net effect for that path. - * - * Undo/redo results are bit-identical to the previous concat behavior — - * replaying [newest…oldest] inverses leaves the oldest value per path, and - * replaying [oldest…newest] forwards leaves the newest. - */ -function foldIntoCoalescedEntry(top: Draft, entry: HistoryEntry): void { - const knownInverse = new Set() - for (const p of top.inverse) knownInverse.add(patchPathKey(p.path)) - const freshInverse = entry.inverse.filter((p) => !knownInverse.has(patchPathKey(p.path))) - if (freshInverse.length > 0) top.inverse = [...freshInverse, ...top.inverse] - - const forward = [...top.forward] - const forwardIndexByPath = new Map() - forward.forEach((p, i) => forwardIndexByPath.set(patchPathKey(p.path), i)) - for (const incoming of entry.forward) { - const key = patchPathKey(incoming.path) - const i = forwardIndexByPath.get(key) - if (i === undefined) { - forwardIndexByPath.set(key, forward.length) - forward.push(incoming) - continue - } - const oldest = forward[i]! - forward[i] = - oldest.op === 'remove' || incoming.op === 'remove' - ? incoming - : { op: oldest.op, path: incoming.path, value: incoming.value } - } - top.forward = forward -} - function applyImportedBodyAttributes( rootNode: PageNode, fragment: ImportFragment, @@ -203,37 +145,6 @@ export function buildSiteHelpers( return result !== false } - /** - * Commit one transaction's site-scoped patch pair to undo history. - * - * Coalescing: when the incoming key matches the in-progress burst, the entry - * folds into the existing top entry instead of pushing a new one — deduped - * by patch path via `foldIntoCoalescedEntry` (oldest inverse wins, newest - * forward value wins), so a whole typing burst is one undo step holding at - * most one patch pair per touched path. - */ - function commitHistory(state: Draft, entry: HistoryEntry): void { - const coalescing = - entry.coalesceKey !== null && - entry.coalesceKey === state._historyCoalesceKey && - state._historyPast.length > 0 - if (coalescing) { - foldIntoCoalescedEntry(state._historyPast[state._historyPast.length - 1]!, entry) - state._historyFuture = [] - state.canRedo = false - return - } - - state._historyPast.push(entry) - if (state._historyPast.length > MAX_HISTORY) { - state._historyPast.shift() // evict oldest - } - state._historyFuture = [] - // Open a new burst (coalesceKey set) or end any prior one (null). - state._historyCoalesceKey = entry.coalesceKey - state.canUndo = true - state.canRedo = false - } /** * Core of every undoable mutation. Runs `recipe` against a Mutative draft of @@ -258,7 +169,7 @@ export function buildSiteHelpers( if (!cur.site) return false let result: SiteMutationResult = false - const [next, patches, inverse] = create( + const [next, patches] = create( cur, (draft) => { result = recipe(draft as Draft) @@ -274,14 +185,22 @@ export function buildSiteHelpers( for (const p of patches) touched.add(String(p.path[0])) if (touched.size === 0) return true // non-false result but no actual change - // History stores patches relative to `site` (strip the leading `'site'` - // segment) so undo/redo can `apply(site, …)` directly. + // Site-relative patches feed the collab write path: they translate into + // Y operations (per-doc undo + wire sync). See collabBinding.ts. const siteForward: Patches = patches .filter((p) => p.path[0] === 'site') .map((p) => ({ ...p, path: p.path.slice(1) })) - const siteInverse: Patches = inverse - .filter((p) => p.path[0] === 'site') - .map((p) => ({ ...p, path: p.path.slice(1) })) + + if (siteForward.length > 0) { + const accepted = applyLocalSitePatches(siteForward, cur.site, next.site!, coalesceKey) + if (!accepted) { + // Connected but not yet synced — the mutation is rejected wholesale + // so an unseeded doc can never receive local ops (which would + // duplicate server content on merge). + console.warn('[collab] edit dropped — documents still syncing') + return false + } + } set((state) => { // Apply every changed top-level field (site + any editor fields) from the @@ -290,15 +209,6 @@ export function buildSiteHelpers( const live = state as unknown as Record const produced = next as unknown as Record for (const key of touched) live[key] = produced[key] - - if (siteForward.length > 0) { - commitHistory(state, { inverse: siteInverse, forward: siteForward, coalesceKey }) - // The same patches drive save-dirty attribution: autosave ships only - // the pages/VCs these paths name, plus explicit deleted-row ids - // derived from the pre/post membership diff (see dirtyTracking.ts). - mergeDirtyMarks(state._dirtySave, collectDirtyFromSitePatches(siteForward, cur.site!, next.site!)) - } - state.hasUnsavedChanges = true }) return true } diff --git a/src/admin/pages/site/store/slices/site/lifecycleActions.ts b/src/admin/pages/site/store/slices/site/lifecycleActions.ts index 59d65996b..5c11b2800 100644 --- a/src/admin/pages/site/store/slices/site/lifecycleActions.ts +++ b/src/admin/pages/site/store/slices/site/lifecycleActions.ts @@ -14,8 +14,8 @@ import { DEFAULT_SITE_RUNTIME, } from '@core/site-runtime' import { clearCanvasSelectionDraft } from '../selectionSlice' +import { resetCollabDocsFromSite } from './collabBinding' import { createDefaultSiteDocument } from './defaults' -import { emptyDirtyMarks } from './dirtyTracking' import { reconcileFrameworkClasses } from './framework/reconcile' import type { SiteSlice, SiteSliceHelpers } from './types' @@ -58,20 +58,12 @@ export function createLifecycleActions({ // the prior site and would cause `mutateActiveTree` to silently no-op // (early-return) when the VC id is not present in the new site. state.activeDocument = null - state._historyPast = [] - state._historyFuture = [] - state._historyCoalesceKey = null state.canUndo = false state.canRedo = false - state.hasUnsavedChanges = false - // A brand-new site has no stored rows at all — first save is full. - state._dirtySave = { ...emptyDirtyMarks(), all: true } - // No stored rows → no sync bases and nothing to conflict with. - state.baseSeqs = {} - state.shellBaseSeq = 0 - state.saveConflicts = [] - state.syncCursor = 0 }) + // Rebuild the doc world around the fresh site (detached: seed locally; + // connected: rebind through the provider). Also clears undo history. + resetCollabDocsFromSite(site) return site }, @@ -94,21 +86,11 @@ export function createLifecycleActions({ state.activePageId = (findHomePage(site.pages) ?? site.pages[0])?.id ?? null // Reset activeDocument — see createSite for rationale. state.activeDocument = null - state._historyPast = [] - state._historyFuture = [] - state._historyCoalesceKey = null state.canUndo = false state.canRedo = false - state.hasUnsavedChanges = false - state._dirtySave = emptyDirtyMarks() - // Sync bases for the fresh document are seeded by the load path - // (usePersistence → seedBaseSeqs) — reset here so a hand-assembled - // load never inherits a previous document's bases or conflicts. - state.baseSeqs = {} - state.shellBaseSeq = 0 - state.saveConflicts = [] - state.syncCursor = 0 }) + // See createSite — mirror the loaded site into the collab docs. + resetCollabDocsFromSite(site) }, clearSite: () => { @@ -120,17 +102,10 @@ export function createLifecycleActions({ // Reset activeDocument — without a site there can be no active doc. state.activeDocument = null clearCanvasSelectionDraft(state) - state._historyPast = [] - state._historyFuture = [] - state._historyCoalesceKey = null state.canUndo = false state.canRedo = false - state._dirtySave = emptyDirtyMarks() - state.baseSeqs = {} - state.shellBaseSeq = 0 - state.saveConflicts = [] - state.syncCursor = 0 }) + resetCollabDocsFromSite(null) }, updateSiteName: (name) => { diff --git a/src/admin/pages/site/store/slices/site/types.ts b/src/admin/pages/site/store/slices/site/types.ts index ea14e393e..8f9197f04 100644 --- a/src/admin/pages/site/store/slices/site/types.ts +++ b/src/admin/pages/site/store/slices/site/types.ts @@ -7,7 +7,7 @@ */ import type { StoreApi } from 'zustand' -import type { Draft, Patches } from 'mutative' +import type { Draft } from 'mutative' import type { FrameworkColorToken, FrameworkColorUtilityType, FrameworkPreferencesSettings, FrameworkScaleManualSize, FrameworkScaleMode, FrameworkSpacingClassGenerator, FrameworkSpacingGroup, FrameworkTypographyClassGenerator, FrameworkTypographyGroup } from '@core/framework-schema' import type { DecorativeSiteExplorerSectionId, @@ -20,42 +20,17 @@ import type { SiteDocument, SiteExplorerSectionId, SiteSettings, - SiteShell, PageTemplateConfig, ConditionDef, StructuralExplorerRowOrder, StructuralSiteExplorerSectionId, } from '@core/page-tree' -import type { SaveConflict } from '@core/persistence/saveConflict' -import type { VisualComponent } from '@core/visualComponents' -import type { SavedLayout } from '@core/layouts' import type { FontEntry, FontToken } from '@core/fonts' import type { ImportFragment } from '@core/htmlImport' import type { NewStyleRule, SiteImportTransaction } from '@core/siteImport' import type { FrameworkChangeImpact, FrameworkPreset } from '@core/framework' import type { EditorStore } from '@site/store/types' -/** - * The fetched remote state of one sync target — consumed by - * `applyRemoteSnapshot`, which serves both the conflict banner's - * "Load theirs" and the live-sync socket's clean-row pull. `row: null` - * means the target was deleted remotely — applying removes it locally. - */ -export type RemoteSnapshot = - | { table: 'site'; shell: SiteShell; seq: number } - | { table: 'pages'; rowId: string; row: Page | null; seq: number } - | { table: 'components'; rowId: string; row: VisualComponent | null; seq: number } - | { table: 'layouts'; rowId: string; row: SavedLayout | null; seq: number } - -/** What `applyRemoteSnapshot` actually did — the socket hook toasts on it. */ -export interface RemoteApplyResult { - /** False when the snapshot deep-equaled local state (echo) — bookkeeping only. */ - applied: boolean - /** True when undo entries existed and were discarded by the apply. */ - clearedHistory: boolean -} - - // --------------------------------------------------------------------------- // Public action surface — every method below appears as a top-level entry on // the EditorStore. @@ -128,21 +103,6 @@ type UpdateFontTokenPatch = Partial<{ order: number }> -/** - * One undoable transaction, stored as Mutative patch pairs scoped to the - * SiteDocument (paths are relative to `site`, e.g. `['pages', 0, 'nodes', …]`). - * - * - `inverse` reverts the transaction (applied on undo). - * - `forward` re-applies it (applied on redo). - * - `coalesceKey` carries the in-progress input-burst identity so consecutive - * per-keystroke edits fold into a single entry (see `commitHistory`). - */ -export interface HistoryEntry { - inverse: Patches - forward: Patches - coalesceKey: string | null -} - export interface SiteSlice { site: SiteDocument | null @@ -152,27 +112,6 @@ export interface SiteSlice { clearSite: () => void updateSiteName: (name: string) => void - // Save-conflict resolution (multi-admin conflict safety — level A) - /** Replace the pending-conflicts list (set from the save pipeline's 409 handler). */ - setSaveConflicts: (conflicts: readonly SaveConflict[]) => void - /** - * Keep the local version: bump the target's base seq to the remote seq so - * the next save passes the conflict check — the overwrite becomes a stated - * decision instead of a silent one. Local dirty marks stay untouched. - */ - resolveSaveConflictKeepMine: (conflict: SaveConflict) => void - /** - * Adopt a remote version: swap it into the document (or remove the target - * when deleted remotely) without pushing undo history, clear the target's - * dirty marks and any pending conflict for it, and sync the base seq. - * Clears the undo history — site-relative patches are undefined across a - * remotely swapped tree — EXCEPT when the remote content deep-equals the - * local copy (the echo of one's own save), which is bookkeeping-only. - * Serves the conflict banner's "Load theirs" AND the live-sync socket's - * clean-target pull. - */ - applyRemoteSnapshot: (snapshot: RemoteSnapshot) => RemoteApplyResult - // Page mutations addPage: (title: string, slug?: string) => Page deletePage: (pageId: string) => void @@ -399,30 +338,12 @@ export interface SiteSlice { mutateAllPagesAndSite(fn: (site: SiteDocument, helpers: SiteImportTransaction) => SiteMutationResult): boolean // ─── Undo / Redo ────────────────────────────────────────────────────────── - /** - * Per-transaction Mutative patch pairs — most recent last. Each entry stores - * `inverse` (applied on undo) + `forward` (applied on redo) patches scoped to - * the SiteDocument, so a step costs O(change) memory instead of a full-site - * clone. See `HistoryEntry`. - */ - _historyPast: HistoryEntry[] - /** Entries popped by undo, available for redo — most recent last */ - _historyFuture: HistoryEntry[] + // History lives in per-doc Y.UndoManagers inside the collab binding + // (collabBinding.ts) — the store only mirrors availability flags. /** True if there's at least one state to undo to */ canUndo: boolean /** True if there's at least one state to redo to */ canRedo: boolean - /** - * Identity key of the in-progress history-coalescing burst, or `null`. - * - * Continuous-input mutations (per-keystroke text/number edits) pass a stable - * key derived from their target (`props::`, etc.). While the - * incoming key matches this one, the mutation folds into the existing - * top-of-stack snapshot instead of cloning the whole site again — so typing a - * word is ONE undo step, not one per character. Any non-coalescing mutation, - * `undo`/`redo`, or a site (re)load resets it to `null`, ending the burst. - */ - _historyCoalesceKey: string | null undo: () => void redo: () => void } diff --git a/src/admin/pages/site/store/slices/site/undoRedoActions.ts b/src/admin/pages/site/store/slices/site/undoRedoActions.ts index 8910c9a2a..fffe2cdf0 100644 --- a/src/admin/pages/site/store/slices/site/undoRedoActions.ts +++ b/src/admin/pages/site/store/slices/site/undoRedoActions.ts @@ -1,84 +1,22 @@ /** - * Undo/redo actions for the site slice. - * - * History is stored as Mutative patch pairs (see `HistoryEntry`): each entry - * carries `inverse` patches (applied on undo) and `forward` patches (applied on - * redo), scoped to the SiteDocument. Undo/redo `apply()` the relevant patch set - * to the current `site` — O(change), no full-site clone — then move the entry - * between the past/future stacks and re-derive the `packageJson` / `siteRuntime` - * mirrors from the restored site. + * Undo/redo actions for the site slice — thin delegates into the collab + * binding's per-doc Y.UndoManager routing (see collabBinding.ts). The undone + * state flows back into the store through the binding's projection path, so + * these actions never touch `site` directly. */ -import { apply } from 'mutative' -import { clonePackageJson } from '@core/site-dependencies/manifest' -import { cloneSiteRuntimeConfig } from '@core/site-runtime' -import { pruneCanvasSelectionDraft } from '../selectionSlice' -import { collectDirtyFromSitePatches, mergeDirtyMarks } from './dirtyTracking' -import type { SiteSlice, SiteSliceHelpers } from './types' +import { collabRedo, collabUndo } from './collabBinding' +import type { SiteSlice } from './types' type UndoRedoActions = Pick -export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoActions { +export function createUndoRedoActions(): UndoRedoActions { return { undo: () => { - const { _historyPast, site } = get() - if (_historyPast.length === 0 || !site) return - const entry = _historyPast[_historyPast.length - 1]! - const restored = apply(site, entry.inverse) - const packageJson = clonePackageJson(restored.packageJson) - const siteRuntime = cloneSiteRuntimeConfig(restored.runtime) - // Undo changes the same paths the original mutation did — the restored - // pages/VCs must be re-saved (and rows the undo removes again become - // explicit deletions via the pre/post membership diff). - const dirty = collectDirtyFromSitePatches(entry.inverse, site, restored) - set((state) => { - state._historyPast.pop() - state._historyFuture.push(entry) - // End any in-progress input-coalescing burst so the next keystroke - // starts a fresh undo entry rather than folding into the undone one. - state._historyCoalesceKey = null - state.site = { ...restored, packageJson, runtime: siteRuntime } - state.packageJson = packageJson - state.siteRuntime = siteRuntime - state.canUndo = state._historyPast.length > 0 - state.canRedo = true - state.hasUnsavedChanges = true - mergeDirtyMarks(state._dirtySave, dirty) - // Keep activePageId valid - if (!state.site.pages.find((p) => p.id === state.activePageId)) { - state.activePageId = state.site.pages[0]?.id ?? null - } - pruneCanvasSelectionDraft(state) - }) + collabUndo() }, - redo: () => { - const { _historyFuture, site } = get() - if (_historyFuture.length === 0 || !site) return - const entry = _historyFuture[_historyFuture.length - 1]! - const restored = apply(site, entry.forward) - const packageJson = clonePackageJson(restored.packageJson) - const siteRuntime = cloneSiteRuntimeConfig(restored.runtime) - // Redo re-applies the mutation's paths — mark the replayed pages/VCs - // (and re-deleted rows, via the pre/post membership diff). - const dirty = collectDirtyFromSitePatches(entry.forward, site, restored) - set((state) => { - state._historyFuture.pop() - state._historyPast.push(entry) - state._historyCoalesceKey = null - state.site = { ...restored, packageJson, runtime: siteRuntime } - state.packageJson = packageJson - state.siteRuntime = siteRuntime - state.canUndo = true - state.canRedo = state._historyFuture.length > 0 - state.hasUnsavedChanges = true - mergeDirtyMarks(state._dirtySave, dirty) - // Keep activePageId valid - if (!state.site.pages.find((p) => p.id === state.activePageId)) { - state.activePageId = state.site.pages[0]?.id ?? null - } - pruneCanvasSelectionDraft(state) - }) + collabRedo() }, } } diff --git a/src/admin/pages/site/store/slices/sitePanelSlice.ts b/src/admin/pages/site/store/slices/sitePanelSlice.ts index 66f267fce..abce462fa 100644 --- a/src/admin/pages/site/store/slices/sitePanelSlice.ts +++ b/src/admin/pages/site/store/slices/sitePanelSlice.ts @@ -185,12 +185,9 @@ export const createSitePanelSlice: EditorStoreSliceCreator = (se }) } - function commitSiteRuntime(nextRuntime: SiteRuntimeConfig, markUnsavedWithoutSite = false): void { + function commitSiteRuntime(nextRuntime: SiteRuntimeConfig): void { if (!get().site) { - set({ - siteRuntime: nextRuntime, - ...(markUnsavedWithoutSite ? { hasUnsavedChanges: true } : {}), - }) + set({ siteRuntime: nextRuntime }) return } mutateSiteState((state, site) => { @@ -252,7 +249,7 @@ export const createSitePanelSlice: EditorStoreSliceCreator = (se [fileId]: nextConfig, }, } - commitSiteRuntime(nextRuntime, true) + commitSiteRuntime(nextRuntime) }, patchScriptRuntimeConfig: (fileId, patch) => { @@ -269,7 +266,7 @@ export const createSitePanelSlice: EditorStoreSliceCreator = (se const scripts = { ...currentRuntime.scripts } delete scripts[fileId] - commitSiteRuntime({ ...currentRuntime, scripts }, true) + commitSiteRuntime({ ...currentRuntime, scripts }) }, setStyleRuntimeConfig: (fileId, config) => { @@ -288,7 +285,7 @@ export const createSitePanelSlice: EditorStoreSliceCreator = (se [fileId]: nextConfig, }, } - commitSiteRuntime(nextRuntime, true) + commitSiteRuntime(nextRuntime) }, patchStyleRuntimeConfig: (fileId, patch) => { @@ -305,7 +302,7 @@ export const createSitePanelSlice: EditorStoreSliceCreator = (se const styles = { ...currentRuntime.styles } delete styles[fileId] - commitSiteRuntime({ ...currentRuntime, styles }, true) + commitSiteRuntime({ ...currentRuntime, styles }) }, setSiteDependencyLock: (lock, packageImportmap) => { diff --git a/src/admin/pages/site/store/slices/siteSlice.ts b/src/admin/pages/site/store/slices/siteSlice.ts index c3dac7cdb..646889a54 100644 --- a/src/admin/pages/site/store/slices/siteSlice.ts +++ b/src/admin/pages/site/store/slices/siteSlice.ts @@ -8,11 +8,11 @@ * * Domain layout: * - `./site/types` — SiteSlice interface + patch types + helpers contract - * - `./site/defaults` — createDefaultSiteDocument + MAX_HISTORY - * - `./site/helpers` — buildSiteHelpers (mutate* + patch-based history) + depthInTree - * - `./site/undoRedoActions` — undo / redo + * - `./site/defaults` — createDefaultSiteDocument + * - `./site/helpers` — buildSiteHelpers (mutate* + collab write path) + depthInTree + * - `./site/collabBinding` — CRDT docs, per-doc undo, remote projection + * - `./site/undoRedoActions` — undo / redo (delegates to the collab binding) * - `./site/lifecycleActions` — createSite / loadSite / clearSite / updateSiteName - * - `./site/conflictActions` — save-conflict resolution (Keep mine / Load theirs) * - `./site/pageActions` — page CRUD + template conversions * - `./site/explorerActions` — Site Explorer folder/order organization * - `./site/nodeActions` — the 11 named tree mutations + multi-select variants + dynamic bindings @@ -26,7 +26,6 @@ import type { EditorStoreSliceCreator } from '@site/store/types' import { buildSiteHelpers } from './site/helpers' import { createUndoRedoActions } from './site/undoRedoActions' import { createLifecycleActions } from './site/lifecycleActions' -import { createConflictActions } from './site/conflictActions' import { createPageActions } from './site/pageActions' import { createExplorerActions } from './site/explorerActions' import { createNodeActions } from './site/nodeActions' @@ -60,21 +59,18 @@ export const createSiteSlice: EditorStoreSliceCreator = (set, get) => // ─── Owned state ───────────────────────────────────────────────────────── site: null, - // Undo / redo history — Mutative patch-pair stacks (see HistoryEntry). - _historyPast: [], - _historyFuture: [], + // Undo / redo availability — mirrored from the collab binding's per-doc + // Y.UndoManagers (see ./site/collabBinding.ts). canUndo: false, canRedo: false, - _historyCoalesceKey: null, // mutateAllPagesAndSite is the public entry point for the Super Import // wizard — one Cmd+Z reverts the entire import. mutateAllPagesAndSite: helpers.mutateAllPagesAndSite, // ─── Action surface ────────────────────────────────────────────────────── - ...createUndoRedoActions(helpers), + ...createUndoRedoActions(), ...createLifecycleActions(helpers), - ...createConflictActions(helpers), ...createPageActions(helpers), ...createExplorerActions(helpers), ...createNodeActions(helpers), diff --git a/src/admin/pages/site/store/store.ts b/src/admin/pages/site/store/store.ts index 8f93af864..6500fde13 100644 --- a/src/admin/pages/site/store/store.ts +++ b/src/admin/pages/site/store/store.ts @@ -17,7 +17,7 @@ import { createSitePanelSlice } from './slices/sitePanelSlice' import { createClipboardSlice } from './slices/clipboardSlice' import { createInlineEditSlice } from './slices/inlineEditSlice' import { createLayoutsSlice } from './slices/layoutsSlice' -import { createSaveTrackingSlice } from './slices/saveTrackingSlice' +import { initCollabBinding } from './slices/site/collabBinding' import { bindPluginRuntimeStoreApi } from '@core/plugins/runtime' import { useAdminUi } from '@admin/state/adminUi' import { readWorkspaceLayout, workspaceFromPathname } from '@admin/state/workspaceLayoutStorage' @@ -26,11 +26,11 @@ import { restoreStoredSiteEditorLayout } from '@site/layout/siteEditorLayoutPers /** * EditorStore — the central Zustand store for the visual editor. * - * Composed of 14 slices (6 canonical Phase 0 + agentSlice + sitePanelSlice + filesSlice + visualComponentsSlice + clipboardSlice + inlineEditSlice + layoutsSlice + saveTrackingSlice): + * Composed of 13 slices (6 canonical Phase 0 + agentSlice + sitePanelSlice + filesSlice + visualComponentsSlice + clipboardSlice + inlineEditSlice + layoutsSlice): * - siteSlice: owns SiteDocument (pages, nodes, breakpoints, settings, classes, files) * - selectionSlice: selectedNodeId, hoveredNodeId * - canvasSlice: zoom, pan, activeBreakpointId, canvasMode (Constraint #317) - * - uiSlice: panel visibility, unsaved-changes flag, insert picker + * - uiSlice: panel visibility, insert picker * - styleRuleSlice: style-rule (class + ambient) CRUD + node↔class assignment * - filesSlice: SiteFile CRUD (Contribution #595 / Task #429) * - visualComponentsSlice: VisualComponent CRUD (Contribution #619 / Task #436) @@ -40,7 +40,6 @@ import { restoreStoredSiteEditorLayout } from '@site/layout/siteEditorLayoutPers * - clipboardSlice: copy / cut / paste of layer subtrees, persisted editor-wide * - inlineEditSlice: canvas inline text edit session (double-click to edit) * - layoutsSlice: user-saved layouts (save / insert / rename / delete) - * - saveTrackingSlice: unsaved-changes flag + patch-derived save-dirty accumulator * * The combined `EditorStore` type lives in `./types` so each slice can import * it without going through this module — that's how the historical @@ -76,7 +75,6 @@ export const useEditorStore = create()( ...createClipboardSlice(...args), ...createInlineEditSlice(...args), ...createLayoutsSlice(...args), - ...createSaveTrackingSlice(...args), }), { enableAutoFreeze: true }, ) @@ -116,6 +114,11 @@ if (typeof window !== 'undefined') { // (which would re-introduce the executor → store → agentSlice → executor cycle). setAgentStoreApi(useEditorStore) +// Hand the collab binding its store handle: local mutation patches flow into +// the CRDT docs, and remote/undo projections flow back via setState. See +// ./slices/site/collabBinding.ts. +initCollabBinding(useEditorStore) + // Wire the adminUi ↔ settings bridge in both directions. // - `bindSettingsBridgeStoreApi` covers adminUi → editor mirroring so // code outside the canvas (SettingsButton in AdminPageLayout) can diff --git a/src/admin/pages/site/toolbar/PublishButton.tsx b/src/admin/pages/site/toolbar/PublishButton.tsx index e52d8aa45..4fb9fa4f5 100644 --- a/src/admin/pages/site/toolbar/PublishButton.tsx +++ b/src/admin/pages/site/toolbar/PublishButton.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import type { SiteDocument } from '@core/page-tree' import { selectActivePage, useEditorStore } from '@site/store/store' import { getCmsPublishStatus, publishCmsDraft } from '@core/persistence' import { LoaderIcon } from 'pixel-art-icons/icons/loader' @@ -7,7 +8,6 @@ import { CheckIcon } from 'pixel-art-icons/icons/check' import { CircleAlertSolidIcon } from 'pixel-art-icons/icons/circle-alert-solid' import { CloudUploadSolidIcon } from 'pixel-art-icons/icons/cloud-upload-solid' import { EyeSolidIcon } from 'pixel-art-icons/icons/eye-solid' -import { SaveSolidIcon } from 'pixel-art-icons/icons/save-solid' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' import { SchedulePublishDialog } from '@admin/modals/SchedulePublishDialog' import type { PersistenceSaveStatus } from '@site/hooks/usePersistence' @@ -16,40 +16,29 @@ import { getErrorMessage } from '@core/utils/errorMessage' type PublishState = 'idle' | 'publishing' | 'published' | 'error' -async function triggerManualSave( - onSave: () => void | Promise, - setIsSaving: (v: boolean) => void, -): Promise { - setIsSaving(true) - try { - await onSave() - } catch (err) { - console.error('[toolbar] Manual save failed:', err) - } finally { - setIsSaving(false) - } -} - interface PublishButtonProps { enabled?: boolean - onSave?: () => void | Promise saveStatus?: PersistenceSaveStatus } -export function PublishButton({ enabled = true, onSave, saveStatus }: PublishButtonProps) { +export function PublishButton({ enabled = true, saveStatus }: PublishButtonProps) { const site = useEditorStore((s) => s.site) const siteId = useEditorStore((s) => s.site?.id ?? null) const activePage = useEditorStore(selectActivePage) const openPreview = useEditorStore((s) => s.openPreview) - const hasUnsavedChanges = useEditorStore((s) => s.hasUnsavedChanges) const { runStepUp } = useStepUp() const [state, setState] = useState('idle') const [message, setMessage] = useState(null) - const [isSaving, setIsSaving] = useState(false) const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false) const statusTimerRef = useRef | null>(null) - const isStatusSaving = saveStatus?.state === 'saving' - const saveError = saveStatus?.state === 'error' ? saveStatus.message ?? 'Save failed' : null + /** + * The `site` reference captured when the button entered the "published" + * state. Every store mutation (local or a remote peer's) produces a new + * reference, so `site !== publishedSiteRef.current` is the exact "the + * draft moved on since publish" signal that returns the button to idle. + */ + const publishedSiteRef = useRef(null) + const syncError = saveStatus?.state === 'error' ? saveStatus.message ?? 'Sync failed' : null useEffect(() => { return () => { @@ -66,6 +55,7 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut const status = await getCmsPublishStatus() if (cancelled) return if (status.draftMatchesPublished) { + publishedSiteRef.current = useEditorStore.getState().site setState('published') setMessage(null) } @@ -79,7 +69,7 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut }, [enabled, siteId]) useEffect(() => { - if (!hasUnsavedChanges || state !== 'published') return + if (state !== 'published' || site === publishedSiteRef.current) return if (statusTimerRef.current) clearTimeout(statusTimerRef.current) statusTimerRef.current = null const resetTimer = setTimeout(() => { @@ -87,7 +77,7 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut setMessage(null) }, 0) return () => clearTimeout(resetTimer) - }, [hasUnsavedChanges, state]) + }, [site, state]) const resetErrorLater = () => { if (statusTimerRef.current) clearTimeout(statusTimerRef.current) @@ -118,7 +108,8 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut setMessage(null) try { - await onSave?.() + // No client-side flush needed: edits stream to the server live, and + // the publish endpoint flushes the relay's debounced persist itself. // Wrap the publish call in `runStepUp` so the StepUpProvider can // intercept the server's `step_up_required` 401, prompt the user // to re-enter their password, then retry. Publish is the highest- @@ -126,6 +117,7 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut // which is why the server gates it behind a fresh step-up window // in addition to the `pages.publish` capability check. const result = await runStepUp(() => publishCmsDraft()) + publishedSiteRef.current = useEditorStore.getState().site setState('published') setMessage( result.publishedPages === 1 @@ -148,11 +140,6 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut } } - const handleManualSave = async () => { - if (!onSave || isSaving || isStatusSaving) return - await triggerManualSave(onSave, setIsSaving) - } - const isPublishing = state === 'publishing' const disabled = !site || !enabled || isPublishing const label = @@ -162,21 +149,21 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut 'Publish' const status = - saveError ? { - label: 'Draft save failed', + syncError ? { + label: 'Sync failed', tone: 'danger' as const, - ariaLabel: saveError, + ariaLabel: syncError, } : - isStatusSaving || isSaving ? { - label: 'Saving draft', - tone: 'neutral' as const, - } : - hasUnsavedChanges ? { - label: 'Unsaved draft', + saveStatus?.state === 'offline' ? { + label: 'Offline — reconnecting', tone: 'warning' as const, } : + saveStatus?.state === 'connecting' || saveStatus?.state === 'loading' ? { + label: 'Connecting', + tone: 'neutral' as const, + } : { - label: 'Draft saved', + label: 'Draft synced', tone: 'success' as const, } @@ -187,14 +174,6 @@ export function PublishButton({ enabled = true, onSave, saveStatus }: PublishBut CloudUploadSolidIcon const menuItems: PublishActionMenuItem[] = [ - { - id: 'save-draft', - label: 'Save draft', - icon: SaveSolidIcon, - disabled: !onSave || isSaving || isStatusSaving, - onSelect: handleManualSave, - testId: 'toolbar-save-draft-action', - }, { // Per-page scheduling. The Site editor's primary Publish button // still publishes ALL draft pages at once (existing behaviour); diff --git a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css deleted file mode 100644 index 02d330d95..000000000 --- a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.module.css +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Save-conflict banner — a persistent resolution surface floating below the - * toolbar. Warning identity comes from the 2px left rule (Toast convention); - * everything else stays achromatic per the two-layer color model. - */ - -.banner { - position: fixed; - top: 64px; - left: 50%; - transform: translateX(-50%); - z-index: var(--z-dropdown); - width: min(560px, calc(100vw - 32px)); - display: flex; - flex-direction: column; - gap: var(--space-s); - padding: var(--space-m) var(--space-l); - background: var(--bg-surface); - border: 1px solid var(--overlay-10); - border-left: 2px solid var(--warning); - border-radius: var(--panel-radius); - box-shadow: var(--shadow-panel); -} - -.title { - font-size: var(--text-m); - font-weight: 600; - color: var(--text); -} - -.subtitle { - font-size: var(--text-s); - color: var(--text-subtle); -} - -.conflictList { - display: flex; - flex-direction: column; - gap: var(--space-xs); - margin: 0; - padding: 0; - list-style: none; -} - -.conflictRow { - display: flex; - align-items: center; - gap: var(--space-s); - padding: var(--space-xs) 0; -} - -.conflictLabel { - flex: 1; - min-width: 0; - font-size: var(--text-s); - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.conflictKind { - color: var(--text-subtle); -} diff --git a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx b/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx deleted file mode 100644 index 1581f94ab..000000000 --- a/src/admin/pages/site/ui/SaveConflictBanner/SaveConflictBanner.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * SaveConflictBanner — the resolution UI for 409'd saves (multi-admin - * conflict safety, level A). - * - * Renders when the store holds pending `saveConflicts` (set by the save - * pipeline when the server rejects an incremental save because another admin - * stored newer versions of shipped rows). One row per conflicted target with - * the two resolutions: - * - * Load theirs — fetch the remote state (single-row `?id=` fetch, or the - * shell GET) and adopt it via `applyRemoteSnapshot`, - * discarding the local unsaved edits to that target only. - * Keep mine — `resolveSaveConflictKeepMine` bumps the base seq; the next - * save overwrites the remote version as a stated decision. - * The button label carries the "(overwrite)" warning — that - * IS the explicit confirmation. - * - * This is deliberately a persistent banner, not a toast: a conflict is not a - * transient failure but a decision the user must make per target, and the - * unsaved local edits stay parked until they do (autosave is suppressed - * while conflicts pend). Once the last conflict is resolved, the pending - * edits are flushed through the editor save queue (`flushEditorSave`). - */ - -import { useState } from 'react' -import type { SiteDocument } from '@core/page-tree' -import type { SaveConflict } from '@core/persistence/saveConflict' -import { getErrorMessage } from '@core/utils/errorMessage' -import { Button } from '@ui/components/Button' -import { pushToast } from '@ui/components/Toast' -import { useEditorStore } from '@site/store/store' -import { fetchRemoteSnapshot } from '@site/hooks/remoteSnapshot' -import { flushEditorSave } from '@site/hooks/editorSaveRef' -import styles from './SaveConflictBanner.module.css' - -const KIND_LABEL: Record = { - site: 'Site settings', - pages: 'Page', - components: 'Component', - layouts: 'Layout', -} - -/** Human label for a conflicted target, resolved from the local document. */ -function conflictLabel(site: SiteDocument | null, conflict: SaveConflict): string { - if (conflict.table === 'site') return 'Site settings & styles' - if (!site) return conflict.rowId - if (conflict.table === 'pages') { - return site.pages.find((p) => p.id === conflict.rowId)?.title ?? conflict.rowId - } - if (conflict.table === 'components') { - return site.visualComponents.find((vc) => vc.id === conflict.rowId)?.name ?? conflict.rowId - } - return site.layouts.find((layout) => layout.id === conflict.rowId)?.name ?? conflict.rowId -} - -/** Ship the surviving local edits once the last conflict is decided. */ -async function flushIfResolved(): Promise { - const { saveConflicts, hasUnsavedChanges } = useEditorStore.getState() - if (saveConflicts.length > 0 || !hasUnsavedChanges) return - await flushEditorSave() -} - -export function SaveConflictBanner() { - const conflicts = useEditorStore((s) => s.saveConflicts) - // Subscribed (not getState) so labels track renames/removals live. - const site = useEditorStore((s) => s.site) - const [busyKey, setBusyKey] = useState(null) - - if (conflicts.length === 0) return null - - async function loadTheirs(conflict: SaveConflict) { - setBusyKey(`${conflict.table}:${conflict.rowId}`) - try { - const snapshot = await fetchRemoteSnapshot(conflict) - useEditorStore.getState().applyRemoteSnapshot(snapshot) - await flushIfResolved() - } catch (err) { - console.error('[SaveConflictBanner] failed to load the remote version:', err) - pushToast({ - kind: 'error', - title: 'Could not load their version', - body: getErrorMessage(err, 'Unknown conflict-resolution error'), - }) - } finally { - setBusyKey(null) - } - } - - async function keepMine(conflict: SaveConflict) { - useEditorStore.getState().resolveSaveConflictKeepMine(conflict) - try { - await flushIfResolved() - } catch (err) { - // The save pipeline already surfaces failures via saveStatus / a - // fresh conflict banner; log for the console trail only. - console.error('[SaveConflictBanner] post-resolution save failed:', err) - } - } - - return ( -
-
Another admin saved newer changes
-
- Your edits are safe but unsaved. Decide per item: load their version - (discards your unsaved edits to it) or keep yours (overwrites theirs on - the next save). -
-
    - {conflicts.map((conflict) => { - const key = `${conflict.table}:${conflict.rowId}` - return ( -
  • - - {KIND_LABEL[conflict.table]} · - {conflictLabel(site, conflict)} - - - -
  • - ) - })} -
-
- ) -} diff --git a/src/admin/spotlight/commands/editor.ts b/src/admin/spotlight/commands/editor.ts index 561286a42..3114dac3b 100644 --- a/src/admin/spotlight/commands/editor.ts +++ b/src/admin/spotlight/commands/editor.ts @@ -4,7 +4,6 @@ * * All commands are gated to workspace: ['site'] only. * Undo/redo use useEditorStore.getState() (Zustand getState is safe outside React). - * Save flushes through `usePersistence`'s registered save (editorSaveRef) so * it rides the single-flight queue, the dirty-mark snapshot, and the * conflict-detection base seqs — a raw adapter call here would bulldoze all * three and ship a replace-mode full save. @@ -27,27 +26,6 @@ const SITE_WRITE_CAPABILITIES = [ export function getEditorCommands(): Command[] { return [ - { - id: 'editor.save', - title: 'Save', - subtitle: 'Save the current draft', - group: 'editor', - iconName: 'save-solid', - keywords: ['save', 'draft', 'write'], - workspaces: ['site'], - capability: SITE_WRITE_CAPABILITIES, - run: async (ctx) => { - ctx.closeSpotlight() - try { - // Import lazily to avoid loading editor code in non-site contexts. - // No-op when the editor isn't mounted (the command is site-only). - const { flushEditorSave } = await import('@site/hooks/editorSaveRef') - await flushEditorSave() - } catch (err) { - console.error('[spotlight] save failed:', err) - } - }, - }, { id: 'editor.publish', title: 'Publish', diff --git a/src/admin/spotlight/keybindings.ts b/src/admin/spotlight/keybindings.ts index 17ea871f9..c941056c3 100644 --- a/src/admin/spotlight/keybindings.ts +++ b/src/admin/spotlight/keybindings.ts @@ -110,13 +110,6 @@ export const KEYBINDINGS: ReadonlyArray = [ scope: 'global', }, - { - commandId: 'editor.save', - shortcut: { mac: '⌘S', win: 'Ctrl+S' }, - ariaKeyshortcuts: isPlatformMac() ? 'Meta+S' : 'Control+S', - match: (e) => (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's', - scope: 'global', - }, { commandId: 'settings.open', diff --git a/src/admin/state/adminEvents.ts b/src/admin/state/adminEvents.ts index 73d7efaa0..f761ebcf8 100644 --- a/src/admin/state/adminEvents.ts +++ b/src/admin/state/adminEvents.ts @@ -51,23 +51,3 @@ export function consumePendingCmsSiteReload(): boolean { * lists should refresh when they are mounted. */ export const CMS_SITE_BUNDLE_IMPORTED_EVENT = 'cms-site-bundle-imported' - -/** - * Fired on `window` to ask the mounted Site editor to persist the current draft - * immediately, bypassing the autosave debounce. Used by deliberate, discrete - * save actions (e.g. "Save as layout") so the change is written to storage at - * the moment the user takes the action — instead of waiting for the autosave - * timer, which is dropped entirely if the user navigates away from the editor - * before it fires. `usePersistence` listens and runs its normal save pipeline. - */ -export const EDITOR_SAVE_REQUEST_EVENT = 'editor-save-request' - -/** - * Request an immediate editor-draft save. No-op when no editor is mounted (the - * change still rides the next save the way any other unsaved edit would). - */ -export function requestEditorSave(): void { - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event(EDITOR_SAVE_REQUEST_EVENT)) - } -} diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts index 843f8d46f..e5b5bcb85 100644 --- a/src/core/collab/applyPatches.ts +++ b/src/core/collab/applyPatches.ts @@ -82,8 +82,18 @@ function repopulateRowDoc(doc: Y.Doc, kind: 'page' | 'component' | 'layout', row } /** Prefix/suffix splice of a Y.Array toward `next` — preserves - * concurrent remote insertions outside the changed window. */ + * concurrent remote insertions outside the changed window. The splice indices + * are only meaningful when the array still holds the PRE-mutation content; if + * it drifted (a remote update landed between projection and this write), fall + * back to a wholesale rewrite — deterministic and convergent, never a + * mid-transaction range error. */ function applyChildrenDiff(arr: Y.Array, pre: readonly string[], next: readonly string[]): void { + const actual = arr.toArray() + if (actual.length !== pre.length || actual.some((id, i) => id !== pre[i])) { + arr.delete(0, arr.length) + arr.insert(0, [...next]) + return + } let prefix = 0 const maxPrefix = Math.min(pre.length, next.length) while (prefix < maxPrefix && pre[prefix] === next[prefix]) prefix++ @@ -333,13 +343,21 @@ function applyRowTargets( // Entry point // --------------------------------------------------------------------------- +/** + * Returns the doc ids the mutation touched, in priority order for undo + * routing: the site doc first when rosters/shell changed, then row docs. + */ export function applySitePatchesToDocs( patches: Patches, preSite: SiteDocument, nextSite: SiteDocument, docs: CollabDocSet, origin: unknown, -): void { +): string[] { + const touchedDocs: string[] = [] + const touch = (docId: string): void => { + if (!touchedDocs.includes(docId)) touchedDocs.push(docId) + } const shellHeads = new Set() const shellEntryTargets = new Map>() // head → entry keys ('*' = whole) const collectionPatches = new Map() @@ -371,6 +389,7 @@ export function applySitePatchesToDocs( if (shellHeads.size > 0 || shellEntryTargets.size > 0 || collectionsWithMembershipOps.length > 0) { const siteDoc = docs.ensure('site:default') + touch('site:default') siteDoc.transact(() => { const shell = shellMap(siteDoc) for (const head of shellHeads) { @@ -418,6 +437,7 @@ export function applySitePatchesToDocs( const kind = COLLECTION_KIND[col] rosterWork.push(() => { const rowDoc = docs.ensure(`${kind}:${id}`) + touch(`${kind}:${id}`) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, nextById.get(id)!), origin) }) } @@ -452,6 +472,7 @@ export function applySitePatchesToDocs( if (colPatches.some((p) => patchPath(p).length === 1)) { for (const [id, row] of nextById) { const rowDoc = docs.ensure(`${kind}:${id}`) + touch(`${kind}:${id}`) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row), origin) } continue @@ -473,6 +494,7 @@ export function applySitePatchesToDocs( if (rest.length === 0) { if (preById.get(id) !== nextById.get(id)) { const rowDoc = docs.ensure(`${kind}:${id}`) + touch(`${kind}:${id}`) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row as Row), origin) } continue @@ -486,6 +508,7 @@ export function applySitePatchesToDocs( const nextRow = nextById.get(id) if (!nextRow) continue const rowDoc = docs.ensure(`${kind}:${id}`) + touch(`${kind}:${id}`) if (kind === 'layout') { // Whole-snapshot LWW — any layout content change rewrites the snapshot. rowDoc.transact(() => repopulateRowDoc(rowDoc, 'layout', nextRow), origin) @@ -497,4 +520,6 @@ export function applySitePatchesToDocs( ) } } + + return touchedDocs } diff --git a/src/core/collab/docSet.ts b/src/core/collab/docSet.ts index 23f5a000c..3a27f5a8b 100644 --- a/src/core/collab/docSet.ts +++ b/src/core/collab/docSet.ts @@ -9,6 +9,8 @@ import * as Y from 'yjs' export interface CollabDocSet { get(docId: string): Y.Doc | undefined ensure(docId: string): Y.Doc + /** Adopt an externally-owned doc (provider-bound) under this id. */ + set(docId: string, doc: Y.Doc): void delete(docId: string): void entries(): IterableIterator<[string, Y.Doc]> } @@ -25,6 +27,9 @@ export function createCollabDocSet(): CollabDocSet { } return doc }, + set: (docId, doc) => { + docs.set(docId, doc) + }, delete: (docId) => { docs.get(docId)?.destroy() docs.delete(docId) diff --git a/src/core/page-tree/pageMutations.ts b/src/core/page-tree/pageMutations.ts index 5d5c8d434..b78c806bd 100644 --- a/src/core/page-tree/pageMutations.ts +++ b/src/core/page-tree/pageMutations.ts @@ -7,8 +7,8 @@ * the node-clone primitives the tree engine shares. * * `deletePage` splices in place deliberately — a wholesale `pages` array - * replacement emits a patch the editor's incremental save cannot attribute - * to one page (see store slices/site/dirtyTracking.ts), forcing a full save. + * replacement emits a patch the editor's collab translator cannot attribute + * to one page (see @core/collab applyPatches), forcing a doc repopulate. */ import { nanoid } from 'nanoid' import type { Page } from './page' diff --git a/src/core/persistence/cms.ts b/src/core/persistence/cms.ts index 3e16aa228..ee622678c 100644 --- a/src/core/persistence/cms.ts +++ b/src/core/persistence/cms.ts @@ -7,7 +7,7 @@ import type { } from './types' import { SaveConflictError, SaveConflictsEnvelopeSchema } from './saveConflict' import { parseJsonResponse } from '@core/utils/jsonValidate' -import { apiRequest, assertOk, readEnvelope, type FetchLike } from '@core/http' +import { assertOk, readEnvelope, type FetchLike } from '@core/http' import { CmsSiteEnvelopeSchema, CmsSiteDocumentSaveEnvelopeSchema, @@ -17,7 +17,6 @@ import { } from './responseSchemas' import { validateSite, validatePages, validateVisualComponents } from './validate' import { validateSavedLayouts } from './validateLayouts' -import type { DataRow } from '@core/data/schemas' import { pageFromRow } from '@core/data/pageFromRow' import { visualComponentFromRow } from '@core/data/componentFromRow' import { savedLayoutFromRow } from '@core/data/layoutFromRow' @@ -26,15 +25,6 @@ import type { SavedLayout } from '@core/layouts' const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init) -/** The three row-backed site collections (the shell is not one of them). */ -export type SiteCollectionTable = 'pages' | 'components' | 'layouts' - -const COLLECTION_ENVELOPES = { - pages: CmsPagesEnvelopeSchema, - components: CmsComponentsEnvelopeSchema, - layouts: CmsLayoutsEnvelopeSchema, -} as const - export class CmsAdapter implements IPersistenceAdapter { private readonly fetchImpl: FetchLike private readonly basePath: string @@ -131,35 +121,6 @@ export class CmsAdapter implements IPersistenceAdapter { return { seq: saved.seq } } - /** - * Load the current shell (with its sync seq) on its own — the conflict - * banner's "Load theirs" fetch for a shell conflict. - */ - async loadSiteShell(): Promise<{ shell: SiteShell; seq: number } | undefined> { - const body = await apiRequest(`${this.basePath}/site`, { - schema: CmsSiteEnvelopeSchema, - fetchImpl: this.fetchImpl, - fallbackMessage: 'CMS shell load failed', - }) - if (!body.site) return undefined - return { shell: validateSite(body.site), seq: body.seq ?? 0 } - } - - /** - * Load a single row of one site collection — the conflict banner's - * "Load theirs" fetch. Returns null when the row is (soft-)deleted: - * absence IS the "deleted remotely" signal. - */ - async loadSiteRow(table: SiteCollectionTable, rowId: string): Promise { - const body = await apiRequest(`${this.basePath}/${table}`, { - query: { id: rowId }, - schema: COLLECTION_ENVELOPES[table], - fetchImpl: this.fetchImpl, - fallbackMessage: `CMS ${table} row load failed`, - }) - return body.rows?.[0] ?? null - } - /** * Load the full site document: * 1. GET /admin/api/cms/site — shell (validated by validateSite) diff --git a/src/core/utils/deepEqual.ts b/src/core/utils/deepEqual.ts index 939f0685e..eaf6989cd 100644 --- a/src/core/utils/deepEqual.ts +++ b/src/core/utils/deepEqual.ts @@ -3,11 +3,7 @@ * primitives) — key order insensitive, no prototype/cycle handling (persisted * CMS documents are acyclic plain data by construction). * - * Shared by the server's shell diff (`server/handlers/cms/siteDiff.ts`) and - * the editor's remote-apply echo detection (`applyRemoteSnapshot` skips the - * swap — and the undo-history clear — when the fetched remote content is - * identical to the local copy, which is exactly what an echo of one's own - * save looks like). + * Used by the server's shell diff (`server/handlers/cms/siteDiff.ts`). */ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true From c7aeddf59c70889939d62a2634e4be1958b7701d Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 12:07:40 +0200 Subject: [PATCH 12/49] feat(collab): live peer cursors, selections, carets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit awarenessState.ts publishes one typed presence per editor (identity with a deterministic HSL color, active doc, selection, inline-edit flag, throttled pointer) and validates every peer state with TypeBox at the wire boundary. PeerPresenceOverlay renders peers' selection rings, name tags (✎ while inline-editing) and pointer dots per breakpoint frame, reusing the selection overlay's measure-session + RAF architecture and the --peer-color inline custom-property pattern. Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/awareness.test.tsx | 158 +++++++++++ .../pages/site/canvas/BreakpointFrame.tsx | 4 + src/admin/pages/site/canvas/CanvasRoot.tsx | 9 + .../canvas/PeerPresenceOverlay.module.css | 48 ++++ .../pages/site/canvas/PeerPresenceOverlay.tsx | 245 ++++++++++++++++++ src/admin/pages/site/collab/awarenessState.ts | 166 ++++++++++++ .../site/store/slices/site/collabBinding.ts | 26 ++ 7 files changed, 656 insertions(+) create mode 100644 src/__tests__/collab/awareness.test.tsx create mode 100644 src/admin/pages/site/canvas/PeerPresenceOverlay.module.css create mode 100644 src/admin/pages/site/canvas/PeerPresenceOverlay.tsx create mode 100644 src/admin/pages/site/collab/awarenessState.ts diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx new file mode 100644 index 000000000..e6fd41cbd --- /dev/null +++ b/src/__tests__/collab/awareness.test.tsx @@ -0,0 +1,158 @@ +/** + * Peer presence — deterministic identity colors, local-state publishing, + * docId-scoped peer filtering (with TypeBox validation of wire states), and + * a smoke render of the canvas presence overlay against fake awareness + * states. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { render, screen, cleanup, act } from '@testing-library/react' +import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' +import { useEditorStore } from '@site/store/store' +import { + activeEditorDocId, + peerColor, + usePeerPresences, +} from '@site/collab/awarenessState' +import { + connectCollabProvider, + disconnectCollabProvider, +} from '@site/store/slices/site/collabBinding' +import type { BoundCollabDoc, CollabProvider } from '@site/collab/collabProvider' +import { PeerPresenceOverlay } from '@admin/pages/site/canvas/PeerPresenceOverlay' +import '@modules/base/index' + +/** Minimal provider stub: real Awareness, synced-on-bind docs, no transport. */ +function fakeProvider(): CollabProvider & { awareness: awarenessProtocol.Awareness } { + const presenceDoc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(presenceDoc) + const bound = new Map() + return { + bind: (docId) => { + let entry = bound.get(docId) + if (!entry) { + entry = { doc: new Y.Doc(), synced: true, whenSynced: Promise.resolve() } + bound.set(docId, entry) + } + return entry + }, + unbind: (docId) => { + bound.get(docId)?.doc.destroy() + bound.delete(docId) + }, + awareness, + status: () => 'connected', + onStatus: () => () => {}, + onReset: () => () => {}, + destroy: () => { + awareness.destroy() + presenceDoc.destroy() + }, + } +} + +/** Inject a remote peer's presence into `target` the way the wire would. */ +function injectPeerState(target: awarenessProtocol.Awareness, state: unknown): number { + const peerDoc = new Y.Doc() + const peer = new awarenessProtocol.Awareness(peerDoc) + peer.setLocalState(state) + const update = awarenessProtocol.encodeAwarenessUpdate(peer, [peer.clientID]) + awarenessProtocol.applyAwarenessUpdate(target, update, 'remote') + return peer.clientID +} + +function PeerList({ docId }: { docId: string | null }) { + const peers = usePeerPresences(docId) + return ( +
    + {peers.map((peer) => ( +
  • {peer.user.name}
  • + ))} +
+ ) +} + +afterEach(() => { + cleanup() + disconnectCollabProvider() + useEditorStore.getState().clearSite() +}) + +describe('peerColor', () => { + it('is deterministic and HSL-formatted', () => { + expect(peerColor('user-a')).toBe(peerColor('user-a')) + expect(peerColor('user-a')).toMatch(/^hsl\(\d+ 70% 45%\)$/) + expect(peerColor('user-a')).not.toBe(peerColor('user-b')) + }) +}) + +describe('activeEditorDocId', () => { + it('routes VC mode to the component doc and page mode to the page doc', () => { + expect( + activeEditorDocId({ activeDocument: { kind: 'visualComponent', vcId: 'vc-1' }, activePageId: 'p1' }), + ).toBe('component:vc-1') + expect(activeEditorDocId({ activeDocument: null, activePageId: 'p1' })).toBe('page:p1') + expect(activeEditorDocId({ activeDocument: null, activePageId: null })).toBeNull() + }) +}) + +describe('usePeerPresences', () => { + it('returns peers on the same doc, drops other docs and malformed states', async () => { + const provider = fakeProvider() + connectCollabProvider(provider) + + render() + + await act(async () => { + injectPeerState(provider.awareness, { + user: { id: 'u2', name: 'Ada', color: peerColor('u2') }, + docId: 'page:p1', + selectedNodeIds: ['n1'], + editingNodeId: null, + pointer: null, + }) + injectPeerState(provider.awareness, { + user: { id: 'u3', name: 'Grace', color: peerColor('u3') }, + docId: 'page:OTHER', + selectedNodeIds: [], + editingNodeId: null, + pointer: null, + }) + // Malformed wire state — must be dropped by validation, not crash. + injectPeerState(provider.awareness, { user: { id: 42 }, docId: 'page:p1' }) + }) + + expect(screen.getByText('Ada')).toBeTruthy() + expect(screen.queryByText('Grace')).toBeNull() + }) +}) + +describe('PeerPresenceOverlay', () => { + it('renders a name tag for a peer selection on the active page doc', async () => { + const provider = fakeProvider() + connectCollabProvider(provider) + + // The overlay derives its docId from the store's active page. + const site = useEditorStore.getState().createSite('Presence Site') + const pageId = site.pages[0].id + useEditorStore.setState({ activePageId: pageId }) + + render() + + await act(async () => { + injectPeerState(provider.awareness, { + user: { id: 'u9', name: 'Marge', color: peerColor('u9') }, + docId: `page:${pageId}`, + selectedNodeIds: [site.pages[0].rootNodeId], + editingNodeId: site.pages[0].rootNodeId, + pointer: { x: 10, y: 20, breakpointId: 'bp-desktop' }, + }) + }) + + const tag = document.querySelector('[data-peer-name-tag]') + expect(tag?.textContent).toBe('Marge') + expect(tag?.getAttribute('data-editing')).toBe('true') + expect(document.querySelector('[data-peer-selection-ring]')).toBeTruthy() + expect(document.querySelector('[data-peer-pointer]')).toBeTruthy() + }) +}) diff --git a/src/admin/pages/site/canvas/BreakpointFrame.tsx b/src/admin/pages/site/canvas/BreakpointFrame.tsx index 3b1d0aa57..2c3ca3557 100644 --- a/src/admin/pages/site/canvas/BreakpointFrame.tsx +++ b/src/admin/pages/site/canvas/BreakpointFrame.tsx @@ -22,6 +22,7 @@ import type { Page, Breakpoint } from '@core/page-tree' import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { CanvasComposedTree } from './CanvasComposedTree' import { BreakpointSelectionOverlay } from './BreakpointSelectionOverlay' +import { PeerPresenceOverlay } from './PeerPresenceOverlay' import { CanvasBreakpointContext, CanvasTemplateContext } from './CanvasContexts' import { IframeFrameSurface, type IframeFrameSurfaceHandle } from './IframeFrameSurface' import type { InjectableRuntimeScript } from './useRuntimeScriptBuild' @@ -243,6 +244,9 @@ export function BreakpointFrame({ viewportRef={viewportRef} iframeElement={iframeEl} /> + {/* Live co-editing presence — peers' selection rings, name tags and + pointer dots, positioned over the same iframe. */} + (null) const spotlight = useContext(SpotlightContext) + // Live co-editing: broadcast this editor's identity + active doc + + // selection into awareness. Peers render it via PeerPresenceOverlay. + const currentUser = useCurrentAdminUser() + usePublishEditorPresence( + currentUser ? { id: currentUser.id, name: currentUser.displayName } : null, + ) + // Store subscriptions const canvasPage = useEditorStore(selectActiveCanvasPage) const breakpoints = useEditorStore((s) => s.site?.breakpoints ?? EMPTY_BREAKPOINTS) diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css new file mode 100644 index 000000000..84e981621 --- /dev/null +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -0,0 +1,48 @@ +/* + * Peer presence chrome — selection rings, name tags, and pointer dots for + * other admins co-editing this document. All colors flow through the + * `--peer-color` custom property set inline per peer (deterministic identity + * HSL derived from the user id — identity color, per the two-layer model). + */ + +.presenceLayer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 39; /* under the local selection chrome (rings sit at 40) */ +} + +.ring { + position: absolute; + display: none; + box-shadow: inset 0 0 0 1.5px var(--peer-color); + border-radius: 2px; +} + +.nameTag { + position: absolute; + display: none; + transform: translateY(-100%); + padding: 1px 6px 2px; + border-radius: var(--editor-radius-sm); + background: var(--peer-color); + color: var(--text); + font-size: 10px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} + +.nameTag[data-editing='true']::after { + content: ' ✎'; +} + +.pointer { + position: absolute; + display: none; + width: 10px; + height: 10px; + border-radius: 50% 50% 50% 0; + background: var(--peer-color); + transform: rotate(-90deg); +} diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx new file mode 100644 index 000000000..3e5b63aaf --- /dev/null +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -0,0 +1,245 @@ +/** + * PeerPresenceOverlay — live "who's doing what" chrome for co-editing. + * + * One overlay per breakpoint frame, mounted next to + * `BreakpointSelectionOverlay` and reusing its architecture: rings are + * portaled into the canvas root (screen-px space — the 1.5px peer ring stays + * crisp at every zoom), tracked elements resolve via `[data-node-id]` inside + * the frame's iframe, and a RAF loop repositions everything through one + * shared measure session per tick. + * + * Renders, per peer on the SAME doc: + * - a selection ring around each of the peer's selected nodes, + * - a name-tag chip above the first ring (marked ✎ during the peer's + * inline text-edit session), + * - a pointer dot inside the frame the peer's cursor is over. + * + * This component also PUBLISHES the local pointer for its frame (throttled + * mousemove on the iframe document) — presence is symmetric, so the reader + * and the writer live in the same mount. + * + * All colors flow through the `--peer-color` inline custom property + * (deterministic identity HSL from the user id — see awarenessState.ts). + */ +import { use, useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react' +import { createPortal } from 'react-dom' +import { useEditorStore } from '@site/store/store' +import { + activeEditorDocId, + publishLocalPointer, + usePeerPresences, + type PeerPresence, +} from '@site/collab/awarenessState' +import { CanvasViewportActionsContext } from './CanvasContexts' +import { CanvasNodeElementCache } from './canvasNodeLookup' +import { createCanvasOverlayMeasureSession } from './canvasOverlayGeometry' +import { hideOverlayElement, positionOverlayElement } from './canvasSelectionOverlayPositioning' +import styles from './PeerPresenceOverlay.module.css' + +const POINTER_PUBLISH_INTERVAL_MS = 66 // ~15 Hz + +/** + * Point chrome (name tag, pointer dot) sizes itself from content — position + * with transform only, never width/height. `lift` raises the element above + * the anchor point (name tag sits on top of the ring's upper edge). + */ +function positionPointElement( + element: HTMLElement | null, + point: { x: number; y: number } | null, + lift = false, +): void { + if (!element) return + if (!point) { + element.style.display = 'none' + return + } + element.style.display = '' + element.style.transform = `translate(${point.x}px, ${point.y}px)${lift ? ' translateY(-100%)' : ''}` +} + +interface PeerPresenceOverlayProps { + breakpointId: string + iframeElement: HTMLIFrameElement | null +} + +function ringKey(peer: PeerPresence, nodeId: string): string { + return `${peer.clientId}:${nodeId}` +} + +export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenceOverlayProps) { + const docId = useEditorStore((s) => + activeEditorDocId({ activeDocument: s.activeDocument, activePageId: s.activePageId }), + ) + const peers = usePeerPresences(docId) + const viewportActions = use(CanvasViewportActionsContext) + const [portalCanvasRoot, setPortalCanvasRoot] = useState(null) + + const ringRefs = useRef | null>(null) + if (ringRefs.current === null) ringRefs.current = new Map() + const tagRefs = useRef | null>(null) + if (tagRefs.current === null) tagRefs.current = new Map() + const pointerRefs = useRef | null>(null) + if (pointerRefs.current === null) pointerRefs.current = new Map() + const nodeElementCacheRef = useRef(null) + if (nodeElementCacheRef.current === null) nodeElementCacheRef.current = new CanvasNodeElementCache() + + useEffect(() => { + const frame = requestAnimationFrame(() => { + const root = viewportActions?.canvasRootRef.current ?? null + setPortalCanvasRoot((current) => (current === root ? current : root)) + }) + return () => cancelAnimationFrame(frame) + }, [viewportActions]) + + // ── Local pointer publisher (this frame) ───────────────────────────────── + useEffect(() => { + const doc = iframeElement?.contentDocument + if (!doc) return undefined + + let lastSent = 0 + const handleMove = (event: MouseEvent): void => { + const now = performance.now() + if (now - lastSent < POINTER_PUBLISH_INTERVAL_MS) return + lastSent = now + publishLocalPointer({ x: event.clientX, y: event.clientY, breakpointId }) + } + const handleLeave = (): void => { + publishLocalPointer(null) + } + doc.addEventListener('mousemove', handleMove) + doc.addEventListener('mouseleave', handleLeave) + return () => { + doc.removeEventListener('mousemove', handleMove) + doc.removeEventListener('mouseleave', handleLeave) + publishLocalPointer(null) + } + }, [iframeElement, breakpointId]) + + // ── Peer chrome positioning (RAF, read-then-write like the selection overlay) ── + const tickOnce = useEffectEvent((iframe: HTMLIFrameElement | null) => { + const iframeDoc = iframe?.contentDocument ?? null + const elementCache = nodeElementCacheRef.current! + + if (!iframe || !iframeDoc) { + for (const [, ring] of ringRefs.current ?? []) hideOverlayElement(ring) + for (const [, tag] of tagRefs.current ?? []) positionPointElement(tag, null) + for (const [, dot] of pointerRefs.current ?? []) positionPointElement(dot, null) + return + } + + const session = createCanvasOverlayMeasureSession(iframe, portalCanvasRoot) + // Iframe-viewport point → canvas-root scroll-content coords (same math + // the session applies to element rects). + const iframeRect = iframe.getBoundingClientRect() + const iframeScale = iframe.offsetWidth > 0 ? iframeRect.width / iframe.offsetWidth : 1 + const originLeft = (session.canvasRect?.left ?? 0) - session.scrollLeft + const originTop = (session.canvasRect?.top ?? 0) - session.scrollTop + + const trackedIds = new Set() + const ringPlacements: Array<{ element: HTMLDivElement | null; rect: ReturnType }> = [] + const pointPlacements: Array<{ element: HTMLDivElement | null; point: { x: number; y: number } | null; lift: boolean }> = [] + + for (const peer of peers) { + let firstRect: ReturnType = null + for (const nodeId of peer.selectedNodeIds) { + trackedIds.add(nodeId) + const rect = session.measure(elementCache.resolve(iframeDoc, nodeId)) + ringPlacements.push({ element: ringRefs.current?.get(ringKey(peer, nodeId)) ?? null, rect }) + if (!firstRect && rect) firstRect = rect + } + pointPlacements.push({ + element: tagRefs.current?.get(peer.clientId) ?? null, + point: firstRect ? { x: firstRect.x, y: firstRect.y } : null, + lift: true, + }) + const pointer = peer.pointer && peer.pointer.breakpointId === breakpointId ? peer.pointer : null + pointPlacements.push({ + element: pointerRefs.current?.get(peer.clientId) ?? null, + point: pointer + ? { + x: iframeRect.left + pointer.x * iframeScale - originLeft, + y: iframeRect.top + pointer.y * iframeScale - originTop, + } + : null, + lift: false, + }) + } + elementCache.retainOnly(trackedIds) + + for (const { element, rect } of ringPlacements) positionOverlayElement(element, rect) + for (const { element, point, lift } of pointPlacements) positionPointElement(element, point, lift) + }) + + const hasPresenceWork = peers.some( + (peer) => + peer.selectedNodeIds.length > 0 || + (peer.pointer !== null && peer.pointer.breakpointId === breakpointId), + ) + + useEffect(() => { + if (!hasPresenceWork) return undefined + + let frame = 0 + let cancelled = false + const tick = (): void => { + if (cancelled) return + tickOnce(iframeElement) + frame = requestAnimationFrame(tick) + } + frame = requestAnimationFrame(tick) + return () => { + cancelled = true + cancelAnimationFrame(frame) + } + }, [hasPresenceWork, iframeElement]) + + if (!hasPresenceWork) return null + + const chrome = ( + ) diff --git a/src/admin/pages/site/collab/awarenessState.ts b/src/admin/pages/site/collab/awarenessState.ts index 87cdbdde8..ddd2e195f 100644 --- a/src/admin/pages/site/collab/awarenessState.ts +++ b/src/admin/pages/site/collab/awarenessState.ts @@ -135,6 +135,71 @@ function readPeerPresences(docId: string | null): PeerPresence[] { return peers } +/** One entry per USER (an admin with two tabs is one person). */ +export interface SitePeer { + user: EditorPresence['user'] + /** True when at least one of the user's clients is on `localDocId`. */ + onSameDoc: boolean +} + +function readSitePeers(localDocId: string | null): SitePeer[] { + const awareness = collabAwareness() + if (!awareness) return [] + const byUser = new Map() + for (const [clientId, raw] of awareness.getStates()) { + if (clientId === awareness.clientID) continue + const result = safeParseValue(EditorPresenceSchema, raw) + if (!result.ok) continue + const onSameDoc = localDocId !== null && result.value.docId === localDocId + const existing = byUser.get(result.value.user.id) + if (existing) { + existing.onSameDoc = existing.onSameDoc || onSameDoc + } else { + byUser.set(result.value.user.id, { user: result.value.user, onSameDoc }) + } + } + return [...byUser.values()] +} + +function sitePeersKey(peers: SitePeer[]): string { + return peers + .map((p) => `${p.user.id}:${p.user.name}:${p.onSameDoc ? 1 : 0}`) + .sort() + .join('|') +} + +/** + * Everyone else connected to the site socket, deduped by user — the toolbar + * avatar stack. Unlike `usePeerPresences`, this is NOT docId-scoped (it + * answers "who's here", not "what are they touching") and only re-renders + * when the roster itself changes — pointer moves don't churn it. + */ +export function useSitePeers(localDocId: string | null): SitePeer[] { + const [peers, setPeers] = useState([]) + + useEffect(() => { + let awareness: Awareness | null = null + const recompute = (): void => { + const next = readSitePeers(localDocId) + setPeers((current) => (sitePeersKey(current) === sitePeersKey(next) ? current : next)) + } + const attach = (): void => { + awareness?.off('change', recompute) + awareness = collabAwareness() + awareness?.on('change', recompute) + recompute() + } + attach() + const offProvider = onCollabProviderChange(attach) + return () => { + offProvider() + awareness?.off('change', recompute) + } + }, [localDocId]) + + return peers +} + /** * Live peer presences on `docId` (everyone except the local client). * Re-renders on every awareness change — peer pointer moves included — so diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.module.css b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css new file mode 100644 index 000000000..46b49e499 --- /dev/null +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css @@ -0,0 +1,34 @@ +/* + * Toolbar presence avatars. All identity color flows through the inline + * `--peer-color` custom property (deterministic HSL from the user id — + * see collab/awarenessState.ts), matching the canvas presence chrome. + */ + +.stack { + display: flex; + align-items: center; +} + +.stack > * + * { + margin-left: calc(-1 * var(--space-3xs)); +} + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--peer-color); + border: 2px solid var(--bg-surface); + color: var(--text); + font-size: var(--text-xs); + font-weight: 700; + line-height: 1; + user-select: none; +} + +.avatar[data-elsewhere='true'] { + opacity: 0.45; +} diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.tsx b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx new file mode 100644 index 000000000..9ec62cdd0 --- /dev/null +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx @@ -0,0 +1,50 @@ +/** + * PeerAvatarStack — always-visible "who else is here" indicator for the + * editor toolbar. + * + * One avatar per connected ADMIN (a user with two tabs is one person), + * colored with the same deterministic identity HSL the canvas presence + * chrome uses, so the avatar, the selection ring, and the cursor label of + * one person always match. Peers on a different page/component render + * dimmed with a "(viewing another page)" hint — presence answers "who's + * here" site-wide, while the canvas chrome shows "what are they touching" + * on the current doc. + * + * Renders nothing when you're alone. + */ +import type { CSSProperties } from 'react' +import { useEditorStore } from '@site/store/store' +import { activeEditorDocId, useSitePeers } from '@site/collab/awarenessState' +import styles from './PeerAvatarStack.module.css' + +function initialsOf(name: string): string { + const words = name.trim().split(/[\s._@-]+/).filter(Boolean) + if (words.length === 0) return '?' + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() + return (words[0][0] + words[1][0]).toUpperCase() +} + +export function PeerAvatarStack() { + const docId = useEditorStore((s) => + activeEditorDocId({ activeDocument: s.activeDocument, activePageId: s.activePageId }), + ) + const peers = useSitePeers(docId) + if (peers.length === 0) return null + + return ( +
+ {peers.map((peer) => ( + + {initialsOf(peer.user.name)} + + ))} +
+ ) +} From 53fcd9b1c0b39d95a1c92f05229e9efb05668e60 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 13:25:54 +0200 Subject: [PATCH 18/49] fix(collab): presence chrome was invisible; Gravatar avatars in the peer stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "I don't see the cursor": the overlay positioning helpers hide elements with INLINE display:none and show them by CLEARING the inline value — but PeerPresenceOverlay.module.css declared display:none as the stylesheet DEFAULT for .ring/.nameTag/.pointer, so every cleared element fell back to hidden forever. All peer canvas chrome (selection rings, name tags, cursors) rendered in the DOM but never painted. Removed the defaults (presence chrome starts visible; the first RAF tick places or hides it — the same contract the selection overlay uses) and added a source-scan regression test. Verified visually end-to-end: a second WebSocket peer's animated cursor + name chip now paints on the canvas. Avatar consistency: presence identity now carries avatarUrl + gravatarHash, and PeerAvatarStack renders the shared UserAvatar (upload → Gravatar → initials, same precedence as every other admin surface) inside an identity-color ring that matches the peer's canvas ring and cursor label. Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/awareness.test.tsx | 21 ++++++++++-- src/admin/pages/site/canvas/CanvasRoot.tsx | 9 ++++- .../canvas/PeerPresenceOverlay.module.css | 3 -- src/admin/pages/site/collab/awarenessState.ts | 13 ++++++-- .../site/toolbar/PeerAvatarStack.module.css | 14 ++++---- .../pages/site/toolbar/PeerAvatarStack.tsx | 33 +++++++++++-------- 6 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index e6fd41cbd..fe1659b5b 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -105,14 +105,14 @@ describe('usePeerPresences', () => { await act(async () => { injectPeerState(provider.awareness, { - user: { id: 'u2', name: 'Ada', color: peerColor('u2') }, + user: { id: 'u2', name: 'Ada', color: peerColor('u2'), avatarUrl: null, gravatarHash: null }, docId: 'page:p1', selectedNodeIds: ['n1'], editingNodeId: null, pointer: null, }) injectPeerState(provider.awareness, { - user: { id: 'u3', name: 'Grace', color: peerColor('u3') }, + user: { id: 'u3', name: 'Grace', color: peerColor('u3'), avatarUrl: null, gravatarHash: null }, docId: 'page:OTHER', selectedNodeIds: [], editingNodeId: null, @@ -128,6 +128,21 @@ describe('usePeerPresences', () => { }) describe('PeerPresenceOverlay', () => { + it('module CSS never hides presence chrome by default (regression: invisible cursors)', () => { + // The overlay positioning helpers hide with INLINE `display: none` and + // show by CLEARING the inline value — a stylesheet `display: none` + // default would make every cleared element fall back to hidden forever + // (the bug that shipped rings/tags/cursors invisible). Presence chrome + // must start visible and let the first RAF tick place or hide it. + const { readFileSync } = require('fs') as typeof import('fs') + const css = readFileSync( + new URL('../../admin/pages/site/canvas/PeerPresenceOverlay.module.css', import.meta.url), + 'utf-8', + ) + expect(css).not.toContain('display: none') + }) + + it('renders a name tag for a peer selection on the active page doc', async () => { const provider = fakeProvider() connectCollabProvider(provider) @@ -141,7 +156,7 @@ describe('PeerPresenceOverlay', () => { await act(async () => { injectPeerState(provider.awareness, { - user: { id: 'u9', name: 'Marge', color: peerColor('u9') }, + user: { id: 'u9', name: 'Marge', color: peerColor('u9'), avatarUrl: null, gravatarHash: null }, docId: `page:${pageId}`, selectedNodeIds: [site.pages[0].rootNodeId], editingNodeId: site.pages[0].rootNodeId, diff --git a/src/admin/pages/site/canvas/CanvasRoot.tsx b/src/admin/pages/site/canvas/CanvasRoot.tsx index 7f71d2d48..0bf9dc8b1 100644 --- a/src/admin/pages/site/canvas/CanvasRoot.tsx +++ b/src/admin/pages/site/canvas/CanvasRoot.tsx @@ -89,7 +89,14 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) { // selection into awareness. Peers render it via PeerPresenceOverlay. const currentUser = useCurrentAdminUser() usePublishEditorPresence( - currentUser ? { id: currentUser.id, name: currentUser.displayName } : null, + currentUser + ? { + id: currentUser.id, + name: currentUser.displayName, + avatarUrl: currentUser.avatarUrl, + gravatarHash: currentUser.gravatarHash, + } + : null, ) // Store subscriptions diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index 4817a90b1..7ef14827e 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -14,14 +14,12 @@ .ring { position: absolute; - display: none; box-shadow: inset 0 0 0 1.5px var(--peer-color); border-radius: 2px; } .nameTag { position: absolute; - display: none; transform: translateY(-100%); padding: var(--space-3xs) var(--space-xs); border-radius: var(--radius-sm); @@ -39,7 +37,6 @@ .pointer { position: absolute; - display: none; pointer-events: none; } diff --git a/src/admin/pages/site/collab/awarenessState.ts b/src/admin/pages/site/collab/awarenessState.ts index ddd2e195f..8dabf4290 100644 --- a/src/admin/pages/site/collab/awarenessState.ts +++ b/src/admin/pages/site/collab/awarenessState.ts @@ -33,6 +33,9 @@ const EditorPresenceSchema = Type.Object({ id: Type.String(), name: Type.String(), color: Type.String(), + /** Same avatar precedence as everywhere else: upload → Gravatar → initials. */ + avatarUrl: Type.Union([Type.String(), Type.Null()]), + gravatarHash: Type.Union([Type.String(), Type.Null()]), }), docId: Type.Union([Type.String(), Type.Null()]), selectedNodeIds: Type.Array(Type.String()), @@ -75,14 +78,18 @@ export function activeEditorDocId(state: { * edit) into awareness. Mount ONCE per editor (CanvasRoot). The pointer field * is owned by `publishLocalPointer` (per-frame mousemove) and preserved here. */ -export function usePublishEditorPresence(user: { id: string; name: string } | null): void { +export function usePublishEditorPresence( + user: { id: string; name: string; avatarUrl: string | null; gravatarHash: string | null } | null, +): void { const userId = user?.id ?? null const userName = user?.name ?? null + const avatarUrl = user?.avatarUrl ?? null + const gravatarHash = user?.gravatarHash ?? null useEffect(() => { if (!userId || !userName) return undefined - const identity = { id: userId, name: userName, color: peerColor(userId) } + const identity = { id: userId, name: userName, color: peerColor(userId), avatarUrl, gravatarHash } const publish = (): void => { const awareness = collabAwareness() if (!awareness) return @@ -109,7 +116,7 @@ export function usePublishEditorPresence(user: { id: string; name: string } | nu unsubscribe() collabAwareness()?.setLocalState(null) } - }, [userId, userName]) + }, [userId, userName, avatarUrl, gravatarHash]) } /** Update only the pointer field of the local presence (throttled by callers). */ diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.module.css b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css index 46b49e499..54390ab2a 100644 --- a/src/admin/pages/site/toolbar/PeerAvatarStack.module.css +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css @@ -13,19 +13,17 @@ margin-left: calc(-1 * var(--space-3xs)); } +/* + * The shared UserAvatar renders inside; this wrapper adds the identity ring + * in the peer's deterministic color so the toolbar avatar matches the same + * person's canvas ring and cursor label. + */ .avatar { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; border-radius: 50%; - background: var(--peer-color); - border: 2px solid var(--bg-surface); - color: var(--text); - font-size: var(--text-xs); - font-weight: 700; - line-height: 1; + border: 2px solid var(--peer-color); user-select: none; } diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.tsx b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx index 9ec62cdd0..777c68460 100644 --- a/src/admin/pages/site/toolbar/PeerAvatarStack.tsx +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx @@ -3,27 +3,22 @@ * editor toolbar. * * One avatar per connected ADMIN (a user with two tabs is one person), - * colored with the same deterministic identity HSL the canvas presence - * chrome uses, so the avatar, the selection ring, and the cursor label of - * one person always match. Peers on a different page/component render - * dimmed with a "(viewing another page)" hint — presence answers "who's - * here" site-wide, while the canvas chrome shows "what are they touching" - * on the current doc. + * rendered with the shared `UserAvatar` (upload → Gravatar → initials — + * identical precedence to every other admin surface) and ringed with the + * same deterministic identity color the canvas presence chrome uses, so the + * avatar, the selection ring, and the cursor label of one person always + * match. Peers on a different page/component render dimmed with a + * "(viewing another page)" hint — presence answers "who's here" site-wide, + * while the canvas chrome shows "what are they touching" on the current doc. * * Renders nothing when you're alone. */ import type { CSSProperties } from 'react' +import { UserAvatar } from '@admin/shared/UserAvatar' import { useEditorStore } from '@site/store/store' import { activeEditorDocId, useSitePeers } from '@site/collab/awarenessState' import styles from './PeerAvatarStack.module.css' -function initialsOf(name: string): string { - const words = name.trim().split(/[\s._@-]+/).filter(Boolean) - if (words.length === 0) return '?' - if (words.length === 1) return words[0].slice(0, 2).toUpperCase() - return (words[0][0] + words[1][0]).toUpperCase() -} - export function PeerAvatarStack() { const docId = useEditorStore((s) => activeEditorDocId({ activeDocument: s.activeDocument, activePageId: s.activePageId }), @@ -42,7 +37,17 @@ export function PeerAvatarStack() { data-elsewhere={peer.onSameDoc ? undefined : 'true'} title={peer.onSameDoc ? peer.user.name : `${peer.user.name} (viewing another page)`} > - {initialsOf(peer.user.name)} + ))} From 5da6205d5b363b61ae8d847aa26129d7d5e93bbe Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 13:38:41 +0200 Subject: [PATCH 19/49] feat(collab): smooth interpolated peer cursors at a lower wire rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendered cursor no longer snaps to each received sample — the RAF tick keeps a per-peer animated position that eases toward the last target with a frame-rate-independent exponential (tau 90ms; snap on oversized jumps and re-entries, per-frame state dropped when a cursor leaves the frame). Motion is glassy regardless of network cadence, which pays for itself on the wire: the publisher drops from 15 Hz to 10 Hz, skips sub-2px tremor (deadband), and adds a trailing flush so the final resting position always ships despite the throttle. Verified live: ~130 unique rendered positions per 1.5s from ~5 published targets — a continuous glide between sparse samples. Co-Authored-By: Claude Fable 5 --- docs/features/site-shell.md | 13 ++- .../pages/site/canvas/PeerPresenceOverlay.tsx | 100 ++++++++++++++++-- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index b65512911..df1122b12 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -547,10 +547,15 @@ each doc's first sync so an unseeded doc can never receive local ops. **Presence** (`src/admin/pages/site/collab/awarenessState.ts` + `PeerPresenceOverlay`): every editor publishes identity (deterministic HSL -color from the user id), active doc, selection, inline-edit state, and a -throttled pointer through y-protocols awareness; peers render selection -rings, name tags, and pointer dots per breakpoint frame. Peer states are -wire data — validated with TypeBox before rendering. +color from the user id + the same upload→Gravatar avatar fields every admin +surface uses), active doc, selection, inline-edit state, and a pointer +through y-protocols awareness; peers render selection rings, name tags, +named cursors per breakpoint frame, and a toolbar avatar stack +(`PeerAvatarStack`). The pointer ships at 10 Hz with a movement deadband +and a trailing flush; the receiving side eases the rendered cursor toward +each sparse sample every animation frame (exponential smoothing, snap on +oversized jumps), so motion stays glassy at a fraction of the wire rate. +Peer states are wire data — validated with TypeBox before rendering. MCP note: headless MCP reads hit the DB, so a browser-relayed write is visible to them after the relay's persist debounce (≤ ~1 s) — the old diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 74992ffb5..87dd72806 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -36,7 +36,19 @@ import { createCanvasOverlayMeasureSession } from './canvasOverlayGeometry' import { hideOverlayElement, positionOverlayElement } from './canvasSelectionOverlayPositioning' import styles from './PeerPresenceOverlay.module.css' -const POINTER_PUBLISH_INTERVAL_MS = 66 // ~15 Hz +// Publish at 10 Hz with a movement deadband — interpolation on the receiving +// side (below) turns the sparse samples back into smooth motion, so a lower +// wire rate COSTS nothing visually and saves frames for everyone. +const POINTER_PUBLISH_INTERVAL_MS = 100 +const POINTER_DEADBAND_PX = 2 + +// Receive-side smoothing: each frame the rendered cursor eases toward the +// last received target with an exponential time constant. ~TAU ms closes 63% +// of the remaining gap; visually settled in ~3×TAU. +const POINTER_SMOOTHING_TAU_MS = 90 +// A jump larger than this teleports instead of gliding across the canvas +// (peer re-entered the frame somewhere else entirely). +const POINTER_SNAP_DISTANCE_PX = 400 /** * Point chrome (name tag, pointer dot) sizes itself from content — position @@ -82,6 +94,10 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc if (pointerRefs.current === null) pointerRefs.current = new Map() const nodeElementCacheRef = useRef(null) if (nodeElementCacheRef.current === null) nodeElementCacheRef.current = new CanvasNodeElementCache() + /** clientId → the RENDERED cursor position, easing toward the last target. */ + const animatedPointersRef = useRef | null>(null) + if (animatedPointersRef.current === null) animatedPointersRef.current = new Map() + const lastTickAtRef = useRef(0) useEffect(() => { const frame = requestAnimationFrame(() => { @@ -102,13 +118,45 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc let attachedDoc: Document | null = null let lastSent = 0 + let lastX = Number.NaN + let lastY = Number.NaN + let trailing: ReturnType | null = null + + const publish = (x: number, y: number): void => { + lastSent = performance.now() + lastX = x + lastY = y + publishLocalPointer({ x, y, breakpointId }) + } const handleMove = (event: MouseEvent): void => { - const now = performance.now() - if (now - lastSent < POINTER_PUBLISH_INTERVAL_MS) return - lastSent = now - publishLocalPointer({ x: event.clientX, y: event.clientY, breakpointId }) + const { clientX, clientY } = event + // Sub-deadband tremor isn't worth a frame on the wire. + if (Math.hypot(clientX - lastX, clientY - lastY) < POINTER_DEADBAND_PX) return + const elapsed = performance.now() - lastSent + if (elapsed >= POINTER_PUBLISH_INTERVAL_MS) { + if (trailing) { + clearTimeout(trailing) + trailing = null + } + publish(clientX, clientY) + return + } + // Inside the throttle window: schedule ONE trailing publish so the + // cursor's final resting position always ships (a plain leading-edge + // throttle would drop the last sample and leave peers slightly off). + if (trailing) clearTimeout(trailing) + trailing = setTimeout(() => { + trailing = null + publish(clientX, clientY) + }, POINTER_PUBLISH_INTERVAL_MS - elapsed) } const handleLeave = (): void => { + if (trailing) { + clearTimeout(trailing) + trailing = null + } + lastX = Number.NaN + lastY = Number.NaN publishLocalPointer(null) } const detach = (): void => { @@ -129,6 +177,7 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc iframe.addEventListener('load', attach) return () => { iframe.removeEventListener('load', attach) + if (trailing) clearTimeout(trailing) detach() publishLocalPointer(null) } @@ -154,6 +203,15 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc const originLeft = (session.canvasRect?.left ?? 0) - session.scrollLeft const originTop = (session.canvasRect?.top ?? 0) - session.scrollTop + // Time-based smoothing factor — frame-rate independent (a dropped frame + // eases a proportionally larger step, so motion speed stays constant). + const now = performance.now() + const dt = lastTickAtRef.current === 0 ? 16 : Math.min(100, now - lastTickAtRef.current) + lastTickAtRef.current = now + const ease = 1 - Math.exp(-dt / POINTER_SMOOTHING_TAU_MS) + const animated = animatedPointersRef.current! + const livePointers = new Set() + const trackedIds = new Set() const ringPlacements: Array<{ element: HTMLDivElement | null; rect: ReturnType }> = [] const pointPlacements: Array<{ element: HTMLDivElement | null; point: { x: number; y: number } | null; lift: boolean }> = [] @@ -172,18 +230,38 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc lift: true, }) const pointer = peer.pointer && peer.pointer.breakpointId === breakpointId ? peer.pointer : null + let pointerPoint: { x: number; y: number } | null = null + if (pointer) { + livePointers.add(peer.clientId) + const target = { + x: iframeRect.left + pointer.x * iframeScale - originLeft, + y: iframeRect.top + pointer.y * iframeScale - originTop, + } + const previous = animated.get(peer.clientId) + // Ease toward the sparse network samples every frame — the cursor + // GLIDES between 10 Hz targets instead of teleporting. Snap on first + // appearance and on jumps too large to glide believably. + pointerPoint = + !previous || Math.hypot(target.x - previous.x, target.y - previous.y) > POINTER_SNAP_DISTANCE_PX + ? target + : { + x: previous.x + (target.x - previous.x) * ease, + y: previous.y + (target.y - previous.y) * ease, + } + animated.set(peer.clientId, pointerPoint) + } pointPlacements.push({ element: pointerRefs.current?.get(peer.clientId) ?? null, - point: pointer - ? { - x: iframeRect.left + pointer.x * iframeScale - originLeft, - y: iframeRect.top + pointer.y * iframeScale - originTop, - } - : null, + point: pointerPoint, lift: false, }) } elementCache.retainOnly(trackedIds) + // Drop animation state for cursors that left this frame — a re-entry + // snaps to its new position instead of gliding from the stale one. + for (const clientId of [...animated.keys()]) { + if (!livePointers.has(clientId)) animated.delete(clientId) + } for (const { element, rect } of ringPlacements) positionOverlayElement(element, rect) for (const { element, point, lift } of pointPlacements) positionPointElement(element, point, lift) From f4d7c4e8f3ea31bcd58bc5eeea9b053244614068 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 13:48:04 +0200 Subject: [PATCH 20/49] style(collab): pixel cursor glyph, aligned presence badges, square rings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The peer cursor is now the editor's own pixel-art cursor glyph (CursorMinimalSolidIcon) tinted with the identity color, hotspot-offset so the arrow tip lands on the published coordinate. - Name tag and cursor label share ONE chip recipe: inline-flex centering, line-height 1, symmetric padding, flex-gap ✎ — consistent spacing on both badges. - Peer selection rings lose their border radius — they trace the selected element's box exactly, like the local selection chrome. - The invisible-chrome regression gate now strips CSS comments before scanning (it caught its own documentation). Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/awareness.test.tsx | 4 +- .../canvas/PeerPresenceOverlay.module.css | 57 +++++++++++-------- .../pages/site/canvas/PeerPresenceOverlay.tsx | 7 ++- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index fe1659b5b..f0c74b0f7 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -139,7 +139,9 @@ describe('PeerPresenceOverlay', () => { new URL('../../admin/pages/site/canvas/PeerPresenceOverlay.module.css', import.meta.url), 'utf-8', ) - expect(css).not.toContain('display: none') + // Scan RULES only — comments may (and do) mention the forbidden pattern. + const rulesOnly = css.replace(/\/\*[\s\S]*?\*\//g, '') + expect(rulesOnly).not.toContain('display: none') }) diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index 7ef14827e..ef615a0ef 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -1,8 +1,12 @@ /* - * Peer presence chrome — selection rings, name tags, and pointer dots for + * Peer presence chrome — selection rings, name tags, and named cursors for * other admins co-editing this document. All colors flow through the * `--peer-color` custom property set inline per peer (deterministic identity * HSL derived from the user id — identity color, per the two-layer model). + * + * NO stylesheet `display: none` defaults here (gated by awareness.test.tsx): + * the overlay positioning helpers hide with INLINE display and show by + * clearing it — a stylesheet default would keep everything hidden forever. */ .presenceLayer { @@ -12,27 +16,39 @@ z-index: 39; /* under the local selection chrome (rings sit at 40) */ } +/* Square corners — the ring must trace the element's box exactly, matching + * the local selection/hover rings. */ .ring { position: absolute; box-shadow: inset 0 0 0 1.5px var(--peer-color); - border-radius: 2px; } -.nameTag { - position: absolute; - transform: translateY(-100%); - padding: var(--space-3xs) var(--space-xs); +/* One shared badge recipe: line-height 1 + flex centering + symmetric + * padding = optically centered text at any font. The name tag and the + * cursor label are the SAME chip so peer identity reads consistently. */ +.nameTag, +.pointerLabel { + display: inline-flex; + align-items: center; + gap: var(--space-3xs); + padding: var(--space-2xs) var(--space-xs); border-radius: var(--radius-sm); background: var(--peer-color); color: var(--text); font-size: var(--text-xs); font-weight: 600; - line-height: 1.5; + line-height: 1; white-space: nowrap; + width: max-content; +} + +.nameTag { + position: absolute; } .nameTag[data-editing='true']::after { - content: ' ✎'; + content: '✎'; + line-height: 1; } .pointer { @@ -40,25 +56,18 @@ pointer-events: none; } -.pointerDot { +/* The pixel cursor glyph's arrow tip sits ~1/4 into its viewBox — nudge it + * so the TIP lands on the published pointer coordinate. */ +.pointerIcon { display: block; - width: 10px; - height: 10px; - border-radius: 50% 0 50% 50%; - background: var(--peer-color); + width: 16px; + height: 16px; + /* Hotspot offset, not layout spacing: shift the glyph so its arrow TIP + * lands on the published pointer coordinate. */ + transform: translate(-4px, -3px); } .pointerLabel { - display: block; margin-top: var(--space-3xs); - margin-left: var(--space-2xs); - padding: var(--space-3xs) var(--space-xs); - border-radius: var(--radius-sm); - background: var(--peer-color); - color: var(--text); - font-size: var(--text-xs); - font-weight: 600; - line-height: 1.4; - white-space: nowrap; - width: max-content; + margin-left: var(--space-s); } diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 87dd72806..2a511ec12 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -23,6 +23,7 @@ */ import { use, useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react' import { createPortal } from 'react-dom' +import { CursorMinimalSolidIcon } from 'pixel-art-icons/icons/cursor-minimal-solid' import { useEditorStore } from '@site/store/store' import { activeEditorDocId, @@ -331,7 +332,11 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc className={styles.pointer} data-peer-pointer="true" > - + {/* Same pixel cursor glyph the editor uses elsewhere, tinted + with the peer's identity color. */} + {peer.user.name} )} From d4c0a4db0b98ff6e5f46d62114b250f21d5bfb4d Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 13:55:28 +0200 Subject: [PATCH 21/49] =?UTF-8?q?style(collab):=20notched=20peer=20name=20?= =?UTF-8?q?tag=20=E2=80=94=20seamless=20with=20the=20selection=20ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name tag on top of a peer's selection ring now uses the design system's notch shape (same radial-gradient concave-corner recipe as CanvasNotch): bigger rounding on the two top corners, a square bottom-left corner flush with the ring's corner, and an inverse bottom-right corner flaring into the ring's top edge. The ✎ editing marker moves from CSS content into markup (the flare owns ::after now), and the free-floating cursor label picks up the matching bigger radius. Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/awareness.test.tsx | 2 +- .../canvas/PeerPresenceOverlay.module.css | 24 +++++++++++++++---- .../pages/site/canvas/PeerPresenceOverlay.tsx | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index f0c74b0f7..57b40a9e8 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -167,7 +167,7 @@ describe('PeerPresenceOverlay', () => { }) const tag = document.querySelector('[data-peer-name-tag]') - expect(tag?.textContent).toBe('Marge') + expect(tag?.textContent).toContain('Marge') expect(tag?.getAttribute('data-editing')).toBe('true') expect(document.querySelector('[data-peer-selection-ring]')).toBeTruthy() expect(document.querySelector('[data-peer-pointer]')).toBeTruthy() diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index ef615a0ef..916a2d659 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -32,7 +32,6 @@ align-items: center; gap: var(--space-3xs); padding: var(--space-2xs) var(--space-xs); - border-radius: var(--radius-sm); background: var(--peer-color); color: var(--text); font-size: var(--text-xs); @@ -42,13 +41,29 @@ width: max-content; } +/* The tag is a NOTCH into the ring it sits on (the same concave-corner + * language as the canvas chrome): bigger rounding on the two top corners, + * a square bottom-left flush with the ring's corner, and an inverse + * bottom-right corner flaring seamlessly into the ring's top edge. */ .nameTag { + --peer-tag-corner: var(--radius); + --peer-tag-corner-cut: calc(var(--peer-tag-corner) - 0.5px); position: absolute; + border-radius: var(--peer-tag-corner) var(--peer-tag-corner) 0 0; } -.nameTag[data-editing='true']::after { - content: '✎'; - line-height: 1; +.nameTag::after { + content: ''; + position: absolute; + left: 100%; + bottom: 0; + width: var(--peer-tag-corner); + height: var(--peer-tag-corner); + background: radial-gradient( + circle at 100% 0, + transparent var(--peer-tag-corner-cut), + var(--peer-color) var(--peer-tag-corner) + ); } .pointer { @@ -68,6 +83,7 @@ } .pointerLabel { + border-radius: var(--radius); margin-top: var(--space-3xs); margin-left: var(--space-s); } diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 2a511ec12..335598419 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -321,6 +321,7 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc data-editing={peer.editingNodeId !== null ? 'true' : undefined} > {peer.user.name} + {peer.editingNodeId !== null && } )} {peer.pointer !== null && peer.pointer.breakpointId === breakpointId && ( From e608e09d51f06dfc74e5135d0be6fd474700106b Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 14:02:58 +0200 Subject: [PATCH 22/49] style(collab): avatar cursors with hover tooltip; avatar in the selection tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating name next to the cursor is gone — the cursor now carries a small identity-ringed avatar (18px, the same look as the toolbar stack), riding just under the arrow tip. Hovering it shows the admin's name through the shared Tooltip primitive (the avatar is the one piece of presence chrome with pointer-events re-enabled). The selection name tag gains a tiny leading avatar (14px, ringless — the tag is already the identity color). Extracted PeerAvatar (@site/collab) as THE presence avatar: shared UserAvatar (upload → Gravatar → initials) inside the identity-color ring, with rest-prop spreading so Tooltip composes onto it. PeerAvatarStack now renders it too and swaps its native title for the same Tooltip. Co-Authored-By: Claude Fable 5 --- .../canvas/PeerPresenceOverlay.module.css | 40 ++++++++-------- .../pages/site/canvas/PeerPresenceOverlay.tsx | 9 +++- .../pages/site/collab/PeerAvatar.module.css | 17 +++++++ src/admin/pages/site/collab/PeerAvatar.tsx | 46 +++++++++++++++++++ .../site/toolbar/PeerAvatarStack.module.css | 21 ++------- .../pages/site/toolbar/PeerAvatarStack.tsx | 42 +++++++---------- 6 files changed, 110 insertions(+), 65 deletions(-) create mode 100644 src/admin/pages/site/collab/PeerAvatar.module.css create mode 100644 src/admin/pages/site/collab/PeerAvatar.tsx diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index 916a2d659..54143b703 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -23,15 +23,21 @@ box-shadow: inset 0 0 0 1.5px var(--peer-color); } -/* One shared badge recipe: line-height 1 + flex centering + symmetric - * padding = optically centered text at any font. The name tag and the - * cursor label are the SAME chip so peer identity reads consistently. */ -.nameTag, -.pointerLabel { +/* The tag is a NOTCH into the ring it sits on (the same concave-corner + * language as the canvas chrome): bigger rounding on the two top corners, + * a square bottom-left flush with the ring's corner, and an inverse + * bottom-right corner flaring seamlessly into the ring's top edge. + * line-height 1 + flex centering + symmetric padding keep the avatar, + * name, and ✎ optically centered. */ +.nameTag { + --peer-tag-corner: var(--radius); + --peer-tag-corner-cut: calc(var(--peer-tag-corner) - 0.5px); + position: absolute; display: inline-flex; align-items: center; gap: var(--space-3xs); padding: var(--space-2xs) var(--space-xs); + border-radius: var(--peer-tag-corner) var(--peer-tag-corner) 0 0; background: var(--peer-color); color: var(--text); font-size: var(--text-xs); @@ -41,17 +47,6 @@ width: max-content; } -/* The tag is a NOTCH into the ring it sits on (the same concave-corner - * language as the canvas chrome): bigger rounding on the two top corners, - * a square bottom-left flush with the ring's corner, and an inverse - * bottom-right corner flaring seamlessly into the ring's top edge. */ -.nameTag { - --peer-tag-corner: var(--radius); - --peer-tag-corner-cut: calc(var(--peer-tag-corner) - 0.5px); - position: absolute; - border-radius: var(--peer-tag-corner) var(--peer-tag-corner) 0 0; -} - .nameTag::after { content: ''; position: absolute; @@ -82,8 +77,13 @@ transform: translate(-4px, -3px); } -.pointerLabel { - border-radius: var(--radius); - margin-top: var(--space-3xs); - margin-left: var(--space-s); +/* The identity avatar floats just below-right of the cursor tip. It is the + * ONE interactive piece of presence chrome: pointer-events re-enabled so + * hovering it raises the name tooltip. */ +.pointerAvatar { + pointer-events: auto; + margin-top: calc(-1 * var(--space-3xs)); + margin-left: var(--space-xs); + background: var(--bg-surface); } + diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 335598419..cfea13dfe 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -24,6 +24,8 @@ import { use, useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react' import { createPortal } from 'react-dom' import { CursorMinimalSolidIcon } from 'pixel-art-icons/icons/cursor-minimal-solid' +import { Tooltip } from '@ui/components/Tooltip' +import { PeerAvatar } from '@site/collab/PeerAvatar' import { useEditorStore } from '@site/store/store' import { activeEditorDocId, @@ -320,6 +322,7 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc data-peer-name-tag="true" data-editing={peer.editingNodeId !== null ? 'true' : undefined} > + {peer.user.name} {peer.editingNodeId !== null && } @@ -338,7 +341,11 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc - {peer.user.name} + {/* The identity avatar rides just under the cursor tip — + hover it (pointer-events re-enabled) for the name. */} + + + )} diff --git a/src/admin/pages/site/collab/PeerAvatar.module.css b/src/admin/pages/site/collab/PeerAvatar.module.css new file mode 100644 index 000000000..d91198af3 --- /dev/null +++ b/src/admin/pages/site/collab/PeerAvatar.module.css @@ -0,0 +1,17 @@ +/* + * Identity ring around the shared UserAvatar — the peer's deterministic + * color (set inline via --peer-color) matches their selection ring and + * cursor, so one person reads the same on every presence surface. + */ + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + user-select: none; +} + +.ring { + border: 2px solid var(--peer-color); +} diff --git a/src/admin/pages/site/collab/PeerAvatar.tsx b/src/admin/pages/site/collab/PeerAvatar.tsx new file mode 100644 index 000000000..b467e0a5d --- /dev/null +++ b/src/admin/pages/site/collab/PeerAvatar.tsx @@ -0,0 +1,46 @@ +/** + * PeerAvatar — one admin's presence avatar: the shared `UserAvatar` + * (upload → Gravatar → initials) inside a ring of the peer's deterministic + * identity color. The ONE avatar look for every presence surface — the + * toolbar stack, the live cursor, the selection name tag — so a person is + * recognizably the same everywhere. + * + * Spreads rest props onto the root span so the `Tooltip` primitive (which + * composes mouse handlers onto its trigger child) works out of the box. + */ +import type { CSSProperties, HTMLAttributes } from 'react' +import { UserAvatar } from '@admin/shared/UserAvatar' +import { cn } from '@ui/cn' +import type { EditorPresence } from './awarenessState' +import styles from './PeerAvatar.module.css' + +interface PeerAvatarProps extends HTMLAttributes { + user: EditorPresence['user'] + /** Avatar diameter in CSS px — the identity ring wraps around it. */ + size: number + /** Drop the identity ring (e.g. inside an already peer-colored badge). */ + ring?: boolean +} + +export function PeerAvatar({ user, size, ring = true, className, style, ...rest }: PeerAvatarProps) { + return ( + + + + ) +} diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.module.css b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css index 54390ab2a..73ee78b3b 100644 --- a/src/admin/pages/site/toolbar/PeerAvatarStack.module.css +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.module.css @@ -1,7 +1,6 @@ /* - * Toolbar presence avatars. All identity color flows through the inline - * `--peer-color` custom property (deterministic HSL from the user id — - * see collab/awarenessState.ts), matching the canvas presence chrome. + * Toolbar presence stack — layout only; the avatar itself (identity ring, + * Gravatar precedence) is the shared PeerAvatar from @site/collab. */ .stack { @@ -13,20 +12,6 @@ margin-left: calc(-1 * var(--space-3xs)); } -/* - * The shared UserAvatar renders inside; this wrapper adds the identity ring - * in the peer's deterministic color so the toolbar avatar matches the same - * person's canvas ring and cursor label. - */ -.avatar { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 50%; - border: 2px solid var(--peer-color); - user-select: none; -} - -.avatar[data-elsewhere='true'] { +.stackItem[data-elsewhere='true'] { opacity: 0.45; } diff --git a/src/admin/pages/site/toolbar/PeerAvatarStack.tsx b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx index 777c68460..a0d31ab40 100644 --- a/src/admin/pages/site/toolbar/PeerAvatarStack.tsx +++ b/src/admin/pages/site/toolbar/PeerAvatarStack.tsx @@ -2,21 +2,20 @@ * PeerAvatarStack — always-visible "who else is here" indicator for the * editor toolbar. * - * One avatar per connected ADMIN (a user with two tabs is one person), - * rendered with the shared `UserAvatar` (upload → Gravatar → initials — - * identical precedence to every other admin surface) and ringed with the - * same deterministic identity color the canvas presence chrome uses, so the - * avatar, the selection ring, and the cursor label of one person always - * match. Peers on a different page/component render dimmed with a - * "(viewing another page)" hint — presence answers "who's here" site-wide, - * while the canvas chrome shows "what are they touching" on the current doc. + * One `PeerAvatar` per connected ADMIN (a user with two tabs is one + * person) — the same identity-ringed avatar the live cursor and the + * selection tag use, so a person is recognizably the same everywhere. + * Hover shows the name through the shared Tooltip primitive; peers on a + * different page/component render dimmed with a "(viewing another page)" + * hint — presence answers "who's here" site-wide, while the canvas chrome + * shows "what are they touching" on the current doc. * * Renders nothing when you're alone. */ -import type { CSSProperties } from 'react' -import { UserAvatar } from '@admin/shared/UserAvatar' +import { Tooltip } from '@ui/components/Tooltip' import { useEditorStore } from '@site/store/store' import { activeEditorDocId, useSitePeers } from '@site/collab/awarenessState' +import { PeerAvatar } from '@site/collab/PeerAvatar' import styles from './PeerAvatarStack.module.css' export function PeerAvatarStack() { @@ -29,26 +28,17 @@ export function PeerAvatarStack() { return (
{peers.map((peer) => ( - - - + ))}
) From 0a1e2be3b41e693ccb889754da8822d6222d3dd4 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 14:17:53 +0200 Subject: [PATCH 23/49] style(collab): cursor fade in/out with exit linger; tighter avatar + tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cursors are now opacity-driven, not mount-driven: the pointer element is always rendered per frame and the RAF tick flips a data-visible attribute, so a cursor entering a frame fades in and one leaving fades out (220ms) instead of popping. A 250ms exit linger holds the frozen position first — skimming a frame edge no longer flickers, and a quick return cancels the fade and glides from where it stopped. The hover hit-target is disabled while invisible so a faded-out avatar can't ghost-trigger tooltips. The RAF loop now stays alive while any peer is on the doc so fades always complete (still stops when alone). - The cursor avatar hugs the arrow tip (space-3xs/-2xs offsets). - The selection tag padding drops one step (space-3xs / space-2xs). - De-raced the read-only integration test: its happened-after marker now writes a different Y.Map key — asserting on the same key made the test depend on Yjs' concurrent-set clientID tiebreak, which is random per run (it started failing legitimately, unrelated to this change). Co-Authored-By: Claude Fable 5 --- .../server/collabRelayIntegration.test.ts | 19 +++- .../canvas/PeerPresenceOverlay.module.css | 30 ++++-- .../pages/site/canvas/PeerPresenceOverlay.tsx | 100 ++++++++++-------- 3 files changed, 93 insertions(+), 56 deletions(-) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index 79768b877..1f3b513a6 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -209,12 +209,21 @@ describe('collab relay integration (real server, real sockets)', () => { // update frame — no other peer ever sees it. setNodeLabel(boundReadOnly.doc, rootId, 'Sneaky viewer edit') - // A writer edit AFTER the viewer's attempt gives us a happened-after - // marker: once it lands on the viewer, the server has processed both. - setNodeLabel(boundWriter.doc, rootId, 'Writer edit') - await waitFor(() => nodeLabel(boundReadOnly.doc, rootId) === 'Writer edit') + // Happened-after marker on a DIFFERENT key — writing the same key would + // make the assertion depend on Yjs' concurrent-set clientID tiebreak + // (random per run), not on the server's drop. Once the marker lands on + // the viewer, the server has processed both frames. + boundWriter.doc.transact(() => { + const nodes = treeMap(boundWriter.doc).get('nodes') as Y.Map + ;(nodes.get(rootId) as Y.Map).set('marker', 'writer-was-here') + }, LOCAL_ORIGIN) + await waitFor(() => { + const nodes = treeMap(boundReadOnly.doc).get('nodes') as Y.Map + return (nodes.get(rootId) as Y.Map).get('marker') === 'writer-was-here' + }) - expect(nodeLabel(boundWriter.doc, rootId)).toBe('Writer edit') + // The sneaky edit never reached the WRITER — the server dropped it. + expect(nodeLabel(boundWriter.doc, rootId)).not.toBe('Sneaky viewer edit') }) it('resets a doc when the row is written outside the relay', async () => { diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index 54143b703..ffe02f646 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -36,7 +36,7 @@ display: inline-flex; align-items: center; gap: var(--space-3xs); - padding: var(--space-2xs) var(--space-xs); + padding: var(--space-3xs) var(--space-2xs); border-radius: var(--peer-tag-corner) var(--peer-tag-corner) 0 0; background: var(--peer-color); color: var(--text); @@ -61,9 +61,20 @@ ); } +/* Cursors are opacity-driven, not mount-driven: the element is always + * rendered and the RAF tick flips data-visible, so entering a frame fades + * in and leaving fades out (after the linger debounce) instead of popping. + * Deliberately opacity: 0 by default (NOT display: none — see the gate in + * awareness.test.tsx); the tick actively sets the attribute. */ .pointer { position: absolute; pointer-events: none; + opacity: 0; + transition: opacity 220ms ease; +} + +.pointer[data-visible='true'] { + opacity: 1; } /* The pixel cursor glyph's arrow tip sits ~1/4 into its viewBox — nudge it @@ -77,13 +88,18 @@ transform: translate(-4px, -3px); } -/* The identity avatar floats just below-right of the cursor tip. It is the - * ONE interactive piece of presence chrome: pointer-events re-enabled so - * hovering it raises the name tooltip. */ +/* The identity avatar hugs the cursor tip. It is the ONE interactive piece + * of presence chrome: pointer-events re-enabled (only while visible — an + * invisible hover target would ghost-trigger tooltips) so hovering it + * raises the name tooltip. */ .pointerAvatar { - pointer-events: auto; - margin-top: calc(-1 * var(--space-3xs)); - margin-left: var(--space-xs); + pointer-events: none; + margin-top: calc(-1 * var(--space-2xs)); + margin-left: var(--space-3xs); background: var(--bg-surface); } +.pointer[data-visible='true'] .pointerAvatar { + pointer-events: auto; +} + diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index cfea13dfe..9b8f0120a 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -53,6 +53,14 @@ const POINTER_SMOOTHING_TAU_MS = 90 // (peer re-entered the frame somewhere else entirely). const POINTER_SNAP_DISTANCE_PX = 400 +// Exit debounce: when a peer's cursor leaves this frame, hold it in place +// for the grace period (skimming a frame edge must not flicker), THEN let +// the CSS opacity transition fade it out. Re-entering within the window +// cancels the fade and glides from the held position. +const POINTER_LINGER_MS = 250 +// Keep the frozen position around long enough for the fade to finish. +const POINTER_FADE_STATE_MS = 700 + /** * Point chrome (name tag, pointer dot) sizes itself from content — position * with transform only, never width/height. `lift` raises the element above @@ -97,8 +105,9 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc if (pointerRefs.current === null) pointerRefs.current = new Map() const nodeElementCacheRef = useRef(null) if (nodeElementCacheRef.current === null) nodeElementCacheRef.current = new CanvasNodeElementCache() - /** clientId → the RENDERED cursor position, easing toward the last target. */ - const animatedPointersRef = useRef | null>(null) + /** clientId → rendered cursor position (easing toward the last target) + * plus when this frame last owned the pointer — drives the exit linger. */ + const animatedPointersRef = useRef | null>(null) if (animatedPointersRef.current === null) animatedPointersRef.current = new Map() const lastTickAtRef = useRef(0) @@ -213,7 +222,6 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc lastTickAtRef.current = now const ease = 1 - Math.exp(-dt / POINTER_SMOOTHING_TAU_MS) const animated = animatedPointersRef.current! - const livePointers = new Set() const trackedIds = new Set() const ringPlacements: Array<{ element: HTMLDivElement | null; rect: ReturnType }> = [] @@ -233,48 +241,52 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc lift: true, }) const pointer = peer.pointer && peer.pointer.breakpointId === breakpointId ? peer.pointer : null - let pointerPoint: { x: number; y: number } | null = null + const pointerElement = pointerRefs.current?.get(peer.clientId) ?? null + const previous = animated.get(peer.clientId) if (pointer) { - livePointers.add(peer.clientId) const target = { x: iframeRect.left + pointer.x * iframeScale - originLeft, y: iframeRect.top + pointer.y * iframeScale - originTop, } - const previous = animated.get(peer.clientId) // Ease toward the sparse network samples every frame — the cursor // GLIDES between 10 Hz targets instead of teleporting. Snap on first // appearance and on jumps too large to glide believably. - pointerPoint = + const next = !previous || Math.hypot(target.x - previous.x, target.y - previous.y) > POINTER_SNAP_DISTANCE_PX ? target : { x: previous.x + (target.x - previous.x) * ease, y: previous.y + (target.y - previous.y) * ease, } - animated.set(peer.clientId, pointerPoint) + animated.set(peer.clientId, { ...next, lastSeenAt: now }) + if (pointerElement) pointerElement.dataset.visible = 'true' + pointPlacements.push({ element: pointerElement, point: next, lift: false }) + } else if (previous) { + // The cursor just left this frame: hold its last position through + // the linger window (no flicker while skimming an edge), then flip + // the attribute so the CSS opacity transition fades it out in place. + const sinceSeen = now - previous.lastSeenAt + if (pointerElement && sinceSeen > POINTER_LINGER_MS) pointerElement.dataset.visible = 'false' + if (sinceSeen > POINTER_FADE_STATE_MS) { + animated.delete(peer.clientId) + } else { + pointPlacements.push({ element: pointerElement, point: previous, lift: false }) + } + } else if (pointerElement) { + pointerElement.dataset.visible = 'false' } - pointPlacements.push({ - element: pointerRefs.current?.get(peer.clientId) ?? null, - point: pointerPoint, - lift: false, - }) } elementCache.retainOnly(trackedIds) - // Drop animation state for cursors that left this frame — a re-entry - // snaps to its new position instead of gliding from the stale one. - for (const clientId of [...animated.keys()]) { - if (!livePointers.has(clientId)) animated.delete(clientId) - } for (const { element, rect } of ringPlacements) positionOverlayElement(element, rect) for (const { element, point, lift } of pointPlacements) positionPointElement(element, point, lift) }) - const hasPresenceWork = peers.some( - (peer) => - peer.selectedNodeIds.length > 0 || - (peer.pointer !== null && peer.pointer.breakpointId === breakpointId), - ) + // Any peer on this doc keeps the loop alive — exit lingers and opacity + // fades must complete even after the pointer/selection state goes empty + // (an idle tick is a handful of map lookups; the loop still stops + // entirely when you're alone). + const hasPresenceWork = peers.length > 0 useEffect(() => { if (!hasPresenceWork) return undefined @@ -327,27 +339,27 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc {peer.editingNodeId !== null && } )} - {peer.pointer !== null && peer.pointer.breakpointId === breakpointId && ( -
{ - if (el) pointerRefs.current?.set(peer.clientId, el) - else pointerRefs.current?.delete(peer.clientId) - }} - className={styles.pointer} - data-peer-pointer="true" - > - {/* Same pixel cursor glyph the editor uses elsewhere, tinted - with the peer's identity color. */} - - {/* The identity avatar rides just under the cursor tip — - hover it (pointer-events re-enabled) for the name. */} - - - -
- )} + {/* Always mounted (per frame): visibility is opacity-driven by + the RAF tick so entry/exit fade instead of popping. */} +
{ + if (el) pointerRefs.current?.set(peer.clientId, el) + else pointerRefs.current?.delete(peer.clientId) + }} + className={styles.pointer} + data-peer-pointer="true" + > + {/* Same pixel cursor glyph the editor uses elsewhere, tinted + with the peer's identity color. */} + + {/* The identity avatar rides just under the cursor tip — + hover it (pointer-events re-enabled while visible) for the name. */} + + + +
) })} From 956c22451658c751426c002ef2c27293485ac6d1 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 14:36:49 +0200 Subject: [PATCH 24/49] feat(collab): per-category capability guard on relay writes; read-only presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay writes now obey the SAME structure/content/style rules as the HTTP save. Full site-writers skip validation (the common fast path); every update frame from a PARTIAL writer runs through the new update guard: fork the authoritative doc, apply the update to the fork, project both sides back to JSON, and reuse validateSiteWriteDiff/validatePageWriteDiff — component/layout changes and roster changes are structural wholesale, and both non-step1 sync message types are guarded so a hand-crafted step2 can't smuggle an update past the check. A rejected update never touches the authoritative doc; the sender receives a TARGETED reset frame that makes its client reseed from the server, reverting the forbidden change everywhere including its own screen. Read-only connections' awareness frames now relay — presence is not a doc write, so viewers appear in the avatar stack and presence chrome. Closes the documented v1 coarse-write-gate regression (capabilities.md, site-shell.md updated). Integration-tested end-to-end: content-only editor's text edit applies, their structural insert is rejected + reset, and a viewer's presence reaches other peers. Co-Authored-By: Claude Fable 5 --- docs/features/site-shell.md | 13 +- docs/reference/capabilities.md | 21 +-- server/collab/socket.ts | 46 ++++++- server/collab/updateGuard.ts | 120 ++++++++++++++++++ .../server/collabRelayIntegration.test.ts | 65 ++++++++++ 5 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 server/collab/updateGuard.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index df1122b12..7b2c4edc3 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -531,11 +531,14 @@ inside the debounce window. frames `docId | frameType | payload` multiplex every doc over ONE WebSocket at `/admin/api/cms/site-socket` (y-protocols sync + awareness + reset). Upgrade is gated by a session with `site.read` and `originAllowed` (CSWSH -defense); `canWrite` resolves once from `SITE_WRITE_CAPABILITIES`, and a -read-only connection's update frames are dropped server-side. Known v1 -granularity regression: the socket gate is coarse (any site-write cap = -write) — the per-category structure/content/style diff validation of the -HTTP path does not apply to relay writes yet. +defense). A read-only connection's update frames are dropped server-side +(its awareness/presence frames still relay — viewers are visible peers), +and PARTIAL writers' update frames run through the per-category guard +(`server/collab/updateGuard.ts`): fork the doc, apply, project both sides, +and reuse the HTTP path's `validateSiteWriteDiff`/`validatePageWriteDiff` — +one enforcement vocabulary on both transports. Rejected updates never touch +the authoritative doc; the sender gets a targeted reset that reverts its +local fork. **Client transport** (`src/admin/pages/site/collab/collabProvider.ts`): one socket, every bound doc multiplexed; local transactions send updates the diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index c83822454..d625185cf 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -35,16 +35,17 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- `SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/siteDocument.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. The transactional site-document save (`PUT /admin/api/cms/site-document`) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props (and site-wide SEO copy on the shell) require `site.content.edit`; inline styles/classes/breakpoint overrides and style rules require `site.style.edit`. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (`site.structure.edit`). -**Known v1 granularity regression (co-editing):** the collab socket -(`server/collab/socket.ts`) resolves one coarse `canWrite` flag at upgrade -time — any holder of a `SITE_WRITE_CAPABILITIES` member can write every doc -over the relay, and the per-category structure/content/style diff -validation above does NOT run on relay writes. Read-only connections' -update frames are dropped server-side, and the editor UI still enforces the -three-way permissions, but a hostile holder of e.g. only -`site.content.edit` could hand-craft structural updates over the socket. -Restoring per-category enforcement on the relay path is tracked as a -follow-up. +**Co-editing enforcement:** relay writes are held to the SAME per-category +rules as the HTTP save. Full site-writers (all three capabilities) skip +validation; every update frame from a PARTIAL writer runs through +`server/collab/updateGuard.ts`, which forks the authoritative doc, applies +the update to the fork, projects both sides back to JSON, and reuses +`validateSiteWriteDiff` / `validatePageWriteDiff` — component/layout +changes and roster changes are structural wholesale. A rejected update is +never applied; the server sends the offender a targeted reset so their +diverged local doc reseeds from the authoritative state. Read-only +connections may not write docs at all, but their AWARENESS frames relay — +presence is not a doc write, so viewers are visible peers. ### Page publishing diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 21999b22d..84e7fe1c5 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -42,6 +42,8 @@ import { SITE_SOCKET_PATH, } from '@core/collab' import { requireCapability, userHasCapability } from '../auth/authz' +import type { CoreCapability } from '@core/capabilities' +import { validateGuardedUpdate } from './updateGuard' import { originAllowed } from '../auth/security' import type { DbClient } from '../db/client' import { jsonResponse } from '../http' @@ -57,6 +59,15 @@ const SYNC_STEP_1 = 0 export interface CollabSocketData { userId: string canWrite: boolean + /** + * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common + * case, which skips the per-update capability guard entirely. Partial + * writers (e.g. content-only editors) pay a fork+diff validation per + * update frame instead (see ./updateGuard.ts). + */ + fullSiteWriter: boolean + /** The user's granted capabilities — the guard's validation input. */ + capabilities: readonly CoreCapability[] /** Doc ids this connection retained in the relay (released on close). */ boundDocs: Set /** Awareness clientIDs contributed by this connection. */ @@ -85,8 +96,16 @@ export async function handleCollabSocketUpgrade( const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user const canWrite = SITE_WRITE_CAPABILITIES.some((cap) => userHasCapability(user, cap)) + const fullSiteWriter = SITE_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) const upgraded = server.upgrade(req, { - data: { userId: user.id, canWrite, boundDocs: new Set(), awarenessClients: new Set() }, + data: { + userId: user.id, + canWrite, + fullSiteWriter, + capabilities: user.capabilities, + boundDocs: new Set(), + awarenessClients: new Set(), + }, }) if (!upgraded) { return jsonResponse({ error: 'WebSocket upgrade required' }, { status: 426 }) @@ -138,7 +157,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { const frame = decodeCollabFrame(new Uint8Array(raw)) if (frame.frameType === FRAME_AWARENESS) { - if (!ws.data.canWrite) return + // Presence is NOT a doc write — read-only viewers are visible peers. // Track which clientIDs this connection contributes so its peers // vanish immediately on close. const before = new Set(awareness.getStates().keys()) @@ -172,6 +191,29 @@ export function createCollabSocketLayer(relay: CollabRelay) { ws.subscribe(docTopic(frame.docId)) } + // Partial writers (canWrite but not every site capability) pass each + // update through the category guard BEFORE it touches the + // authoritative doc — the same structure/content/style rules the HTTP + // save enforces. Both non-step1 message types (step2 replies and + // updates) carry `varUint8Array update` after the type varUint, so one + // extraction covers whatever a hand-crafted client might send. + if (messageType !== SYNC_STEP_1 && !ws.data.fullSiteWriter) { + const guardDecoder = decoding.createDecoder(frame.payload) + decoding.readVarUint(guardDecoder) + const update = decoding.readVarUint8Array(guardDecoder) + const verdict = validateGuardedUpdate(frame.docId, doc, update, ws.data.capabilities) + if (!verdict.ok) { + console.warn(`[collab] rejected ${frame.docId} update from ${ws.data.userId}: ${verdict.reason}`) + // The sender's local doc holds the forbidden change — a TARGETED + // reset makes their client rebind and reseed from the server, + // reverting it everywhere (including their own screen). + ws.send(encodeCollabFrame(frame.docId, FRAME_RESET, new Uint8Array())) + return + } + Y.applyUpdate(doc, update, ws) + return + } + const decoder = decoding.createDecoder(frame.payload) const encoder = encoding.createEncoder() syncProtocol.readSyncMessage(decoder, encoder, doc, ws) diff --git a/server/collab/updateGuard.ts b/server/collab/updateGuard.ts new file mode 100644 index 000000000..ecaf03dd4 --- /dev/null +++ b/server/collab/updateGuard.ts @@ -0,0 +1,120 @@ +/** + * Per-category capability enforcement for relay writes. + * + * The HTTP save path diff-validates every batch by category (structure / + * content / style — `validateSiteWriteDiff` + `validatePageWriteDiff`). + * Relay writes arrive as opaque Yjs updates, so the guard makes them + * diff-able: fork the authoritative doc, apply the update to the fork, + * project BOTH sides back to the JSON domain, and run the SAME validators + * the HTTP path uses. One enforcement vocabulary, two transports. + * + * Full site-writers (all three capabilities) skip the guard entirely — the + * fork+project cost is only paid by partial-capability connections, whose + * human-scale edit rate makes it negligible. + * + * Component and layout docs follow the HTTP rule wholesale: ANY change to + * them is structural work. On the site doc, roster membership/order changes + * are structural; the shell diff-validates per field. + */ +import * as Y from 'yjs' +import type { SiteShell } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' +import { + parseCollabDocId, + projectComponentDoc, + projectLayoutDoc, + projectPageDoc, + projectSiteDoc, +} from '@core/collab' +import type { CoreCapability } from '@core/capabilities' +import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../handlers/cms/siteDiff' +import { validatePageWriteDiff } from '../handlers/cms/pageDiff' + +export type UpdateGuardVerdict = { ok: true } | { ok: false; reason: string } + +function hasStructure(capabilities: readonly CoreCapability[]): boolean { + return capabilities.includes('site.structure.edit') +} + +/** Projections omit the non-collaborative shell fields — pin them for the diff. */ +function asShell(shell: Record): SiteShell { + return { ...shell, id: 'default', updatedAt: 0 } as unknown as SiteShell +} + +/** + * Validate one incoming update against the connection's capabilities. + * Never mutates `doc` — the caller applies the update only on `ok: true`. + */ +export function validateGuardedUpdate( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: readonly CoreCapability[], +): UpdateGuardVerdict { + const parsed = parseCollabDocId(docId) + if (!parsed) return { ok: false, reason: `unknown doc id ${docId}` } + + const fork = new Y.Doc() + Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + try { + Y.applyUpdate(fork, update) + } catch (err) { + return { ok: false, reason: `malformed update: ${err instanceof Error ? err.message : String(err)}` } + } + + try { + if (parsed.kind === 'page') { + const previous = projectPageDoc(doc, parsed.rowId) + const next = projectPageDoc(fork, parsed.rowId) + validatePageWriteDiff({ + // An unseeded previous (no root yet) means this update CREATES the + // page content — validatePageWriteDiff treats a missing previous as + // a structural page creation. + previousPages: previous.rootNodeId ? [previous] : [], + changedPages: [next], + deletedPageIds: new Set(), + capabilities, + }) + return { ok: true } + } + + if (parsed.kind === 'component' || parsed.kind === 'layout') { + const previous = + parsed.kind === 'component' + ? projectComponentDoc(doc, parsed.rowId) + : projectLayoutDoc(doc, parsed.rowId) + const next = + parsed.kind === 'component' + ? projectComponentDoc(fork, parsed.rowId) + : projectLayoutDoc(fork, parsed.rowId) + if (!deepEqual(previous, next) && !hasStructure(capabilities)) { + return { + ok: false, + reason: `${parsed.kind} changes require site.structure.edit`, + } + } + return { ok: true } + } + + // Site doc: roster membership/order is structural; the shell diff + // validates per category, exactly like the HTTP path. + const previous = projectSiteDoc(doc) + const next = projectSiteDoc(fork) + if (!deepEqual(previous.rosters, next.rosters) && !hasStructure(capabilities)) { + return { ok: false, reason: 'roster changes require site.structure.edit' } + } + validateSiteWriteDiff( + // An empty previous shell = unseeded doc being populated — the diff + // validator treats a null previous as full-shell creation. + Object.keys(previous.shell).length === 0 ? null : asShell(previous.shell), + asShell(next.shell), + capabilities, + ) + return { ok: true } + } catch (err) { + if (err instanceof ForbiddenSiteChangeError) { + return { ok: false, reason: err.message } + } + throw err + } +} diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index 1f3b513a6..b7ace56d8 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -36,6 +36,8 @@ import { createCapabilityTestHarness, type CapabilityTestHarness, } from '../helpers/capabilityHarness' +// The update guard classifies prop changes via the module registry. +import '@modules/base/index' const PERSIST_DEBOUNCE_MS = 25 @@ -226,6 +228,69 @@ describe('collab relay integration (real server, real sockets)', () => { expect(nodeLabel(boundWriter.doc, rootId)).not.toBe('Sneaky viewer edit') }) + it('enforces per-category capabilities on partial writers and relays read-only presence', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const contentUser = await stack.harness.createRoleUser({ + name: 'Copy Editor', + slug: 'collab-content-only', + capabilities: ['site.read', 'site.content.edit'], + }) + const viewer = await stack.harness.createRoleUser({ + name: 'Viewer', + slug: 'collab-presence-viewer', + capabilities: ['site.read'], + }) + + const owner = connectClient(stack) + const contentClient = connectClient(stack, contentUser.cookie) + const viewerClient = connectClient(stack, viewer.cookie) + const boundOwner = owner.bind(docId) + const boundContent = contentClient.bind(docId) + viewerClient.bind(docId) + await boundOwner.whenSynced + await boundContent.whenSynced + + // Owner (full writer) adds a text node the content editor may write to. + insertChildNode(boundOwner.doc, 'text-node', 'base.text') + await waitFor(() => + (treeMap(boundContent.doc).get('nodes') as Y.Map).has('text-node'), + ) + + // ALLOWED: a content-category prop change from the content-only editor. + boundContent.doc.transact(() => { + const nodes = treeMap(boundContent.doc).get('nodes') as Y.Map + const props = (nodes.get('text-node') as Y.Map).get('props') as Y.Map + props.set('text', 'copy edited') + }, LOCAL_ORIGIN) + await waitFor(() => { + const nodes = treeMap(boundOwner.doc).get('nodes') as Y.Map + const props = (nodes.get('text-node') as Y.Map).get('props') as Y.Map + return props.get('text') === 'copy edited' + }) + + // REJECTED: a structural change (node insertion) from the same editor — + // the server refuses the update and resets the sender's doc. + const resets: string[] = [] + contentClient.onReset((id) => resets.push(id)) + insertChildNode(boundContent.doc, 'forbidden-node', 'base.container') + await waitFor(() => resets.includes(docId)) + // Reset was sent INSTEAD of applying — the authoritative doc never saw it. + expect((treeMap(boundOwner.doc).get('nodes') as Y.Map).has('forbidden-node')).toBe(false) + + // Read-only presence: a viewer's awareness state reaches other peers + // (presence is not a doc write). + viewerClient.awareness.setLocalState({ user: { id: 'viewer-1', name: 'Viewer' } }) + await waitFor(() => { + for (const [, state] of owner.awareness.getStates()) { + const s = state as { user?: { id?: string } } + if (s.user?.id === 'viewer-1') return true + } + return false + }) + }) + it('resets a doc when the row is written outside the relay', async () => { const stack = await startStack() const docId = `page:${stack.homeId}` From 0af6129de87cccfeedc77fc9ffaa90f3374d996c Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 14:45:08 +0200 Subject: [PATCH 25/49] feat(collab): character-level peer text carets and selection highlights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During an inline text-edit session, presence now carries the caret and selection as Y.Text RELATIVE positions (collab/caretPositions.ts) — pinned to the CRDT items around the caret, so peers resolve the exact spot even while concurrent edits shift the surrounding text (a plain character index would drift; unit-tested against a concurrent insert). Capture: the presence overlay listens to selectionchange on its frame's iframe document while that frame owns the inline session, converts the DOM selection to text offsets, encodes them against the live collab doc, and publishes via a dedicated awareness field. Render: the RAF tick resolves each peer's relative positions to current offsets, maps them to DOM ranges inside the rendered node, and paints a blinking identity-colored caret line plus 25%-opacity selection highlight rects — riding the same measure-session/iframe-coordinate machinery as the rest of the presence chrome. Verified live end-to-end: a peer's published range renders as a highlighted span + caret at the right characters, and a real DOM selection during a session publishes an encoded caret. Co-Authored-By: Claude Fable 5 --- docs/features/site-shell.md | 9 +- src/__tests__/collab/awareness.test.tsx | 3 + src/__tests__/collab/caretPositions.test.ts | 51 ++++++ .../canvas/PeerPresenceOverlay.module.css | 27 +++ .../pages/site/canvas/PeerPresenceOverlay.tsx | 167 ++++++++++++++++++ src/admin/pages/site/collab/awarenessState.ts | 28 ++- src/admin/pages/site/collab/caretPositions.ts | 77 ++++++++ .../site/store/slices/site/collabBinding.ts | 5 + 8 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/collab/caretPositions.test.ts create mode 100644 src/admin/pages/site/collab/caretPositions.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 7b2c4edc3..2e6d17bd7 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -551,9 +551,12 @@ each doc's first sync so an unseeded doc can never receive local ops. **Presence** (`src/admin/pages/site/collab/awarenessState.ts` + `PeerPresenceOverlay`): every editor publishes identity (deterministic HSL color from the user id + the same upload→Gravatar avatar fields every admin -surface uses), active doc, selection, inline-edit state, and a pointer -through y-protocols awareness; peers render selection rings, name tags, -named cursors per breakpoint frame, and a toolbar avatar stack +surface uses), active doc, selection, inline-edit state, a pointer, and — +during an inline text session — the caret/selection as **Y.Text relative +positions** (`collab/caretPositions.ts`; pinned to CRDT items, so they stay +correct while concurrent edits shift the text). Peers render selection +rings, name tags, avatar cursors, a blinking character-precise caret with +selection highlight inside the edited text, and a toolbar avatar stack (`PeerAvatarStack`). The pointer ships at 10 Hz with a movement deadband and a trailing flush; the receiving side eases the rendered cursor toward each sparse sample every animation frame (exponential smoothing, snap on diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index 57b40a9e8..3d8c68ba9 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -110,6 +110,7 @@ describe('usePeerPresences', () => { selectedNodeIds: ['n1'], editingNodeId: null, pointer: null, + textCaret: null, }) injectPeerState(provider.awareness, { user: { id: 'u3', name: 'Grace', color: peerColor('u3'), avatarUrl: null, gravatarHash: null }, @@ -117,6 +118,7 @@ describe('usePeerPresences', () => { selectedNodeIds: [], editingNodeId: null, pointer: null, + textCaret: null, }) // Malformed wire state — must be dropped by validation, not crash. injectPeerState(provider.awareness, { user: { id: 42 }, docId: 'page:p1' }) @@ -163,6 +165,7 @@ describe('PeerPresenceOverlay', () => { selectedNodeIds: [site.pages[0].rootNodeId], editingNodeId: site.pages[0].rootNodeId, pointer: { x: 10, y: 20, breakpointId: 'bp-desktop' }, + textCaret: null, }) }) diff --git a/src/__tests__/collab/caretPositions.test.ts b/src/__tests__/collab/caretPositions.test.ts new file mode 100644 index 000000000..5f68deaa8 --- /dev/null +++ b/src/__tests__/collab/caretPositions.test.ts @@ -0,0 +1,51 @@ +/** + * Caret codec — base64 relative positions round-trip through a Y.Text and + * stay CORRECT under concurrent edits (the reason plain indices don't work). + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { treeMap } from '@core/collab' +import { + encodeCaretRange, + resolveCaretRange, +} from '@site/collab/caretPositions' + +function docWithText(initial: string): { doc: Y.Doc; text: Y.Text } { + const doc = new Y.Doc() + const text = new Y.Text(initial) + doc.transact(() => { + const nodes = new Y.Map() + const node = new Y.Map() + const props = new Y.Map() + props.set('text', text) + node.set('props', props) + nodes.set('n1', node) + treeMap(doc).set('nodes', nodes) + }) + return { doc, text } +} + +describe('caretPositions', () => { + it('round-trips offsets through encode → resolve', () => { + const { doc } = docWithText('Hello world') + const caret = encodeCaretRange(doc, 'n1', 'text', 6, 11) + expect(caret).not.toBeNull() + expect(resolveCaretRange(doc, caret!)).toEqual({ anchor: 6, head: 11 }) + }) + + it('positions survive a concurrent insert BEFORE the caret', () => { + const { doc, text } = docWithText('Hello world') + const caret = encodeCaretRange(doc, 'n1', 'text', 6, 6) // before "world" + text.insert(0, '>>> ') // concurrent edit shifts everything right + expect(resolveCaretRange(doc, caret!)).toEqual({ anchor: 10, head: 10 }) + }) + + it('returns null for a non-Y.Text prop and for malformed wire data', () => { + const doc = new Y.Doc() + expect(encodeCaretRange(doc, 'missing', 'text', 0, 0)).toBeNull() + const { doc: ok } = docWithText('abc') + expect( + resolveCaretRange(ok, { nodeId: 'n1', prop: 'text', anchor: '!!notbase64', head: '!!' }), + ).toBeNull() + }) +}) diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css index ffe02f646..9469a3665 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -61,6 +61,33 @@ ); } +/* Blinking caret line inside the text a peer is editing. Positioned and + * sized imperatively (positionOverlayElement) — hidden via inline display + * until the first placement, like the rings. */ +.caret { + position: absolute; + background: var(--peer-color); + animation: peerCaretBlink 1.1s steps(2, start) infinite; +} + +@keyframes peerCaretBlink { + 50% { + opacity: 0; + } +} + +/* Container for the peer's text-selection highlight rects (children are + * created and positioned imperatively by the RAF tick). */ +.caretHighlights { + position: absolute; + inset: 0; +} + +.caretHighlights > * { + background: var(--peer-color); + opacity: 0.25; +} + /* Cursors are opacity-driven, not mount-driven: the element is always * rendered and the RAF tick flips data-visible, so entering a frame fades * in and leaving fades out (after the linger debounce) instead of popping. diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 9b8f0120a..880e5e16c 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -30,9 +30,12 @@ import { useEditorStore } from '@site/store/store' import { activeEditorDocId, publishLocalPointer, + publishLocalTextCaret, usePeerPresences, type PeerPresence, } from '@site/collab/awarenessState' +import { encodeCaretRange, resolveCaretRange } from '@site/collab/caretPositions' +import { collabDocFor } from '@site/store/slices/site/collabBinding' import { CanvasViewportActionsContext } from './CanvasContexts' import { CanvasNodeElementCache } from './canvasNodeLookup' import { createCanvasOverlayMeasureSession } from './canvasOverlayGeometry' @@ -80,6 +83,62 @@ function positionPointElement( element.style.transform = `translate(${point.x}px, ${point.y}px)${lift ? ' translateY(-100%)' : ''}` } +interface OverlayRect { + x: number + y: number + width: number + height: number +} + +/** Resolve the DOM position `offset` characters into `root`'s text. */ +function domPositionAtTextOffset( + iframeDoc: Document, + root: HTMLElement, + offset: number, +): { node: Node; offset: number } { + const walker = iframeDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let remaining = offset + let last: Text | null = null + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node as Text + const length = text.data.length + if (remaining <= length) return { node: text, offset: remaining } + remaining -= length + last = text + } + return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 } +} + +/** Character offset of a DOM selection point within `root` (walk order). */ +function textOffsetAtDomPosition( + iframeDoc: Document, + root: HTMLElement, + node: Node, + offset: number, +): number { + const range = iframeDoc.createRange() + range.setStart(root, 0) + range.setEnd(node, offset) + return range.toString().length +} + +/** Sync `container`'s children to one absolutely-positioned div per rect. */ +function syncHighlightRects(container: HTMLElement | null, rects: OverlayRect[]): void { + if (!container) return + while (container.children.length > rects.length) container.lastElementChild?.remove() + while (container.children.length < rects.length) { + container.appendChild(container.ownerDocument.createElement('div')) + } + for (let i = 0; i < rects.length; i++) { + const el = container.children[i] as HTMLElement + const rect = rects[i] + el.style.position = 'absolute' + el.style.transform = `translate(${rect.x}px, ${rect.y}px)` + el.style.width = `${rect.width}px` + el.style.height = `${rect.height}px` + } +} + interface PeerPresenceOverlayProps { breakpointId: string iframeElement: HTMLIFrameElement | null @@ -103,6 +162,10 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc if (tagRefs.current === null) tagRefs.current = new Map() const pointerRefs = useRef | null>(null) if (pointerRefs.current === null) pointerRefs.current = new Map() + const caretRefs = useRef | null>(null) + if (caretRefs.current === null) caretRefs.current = new Map() + const highlightRefs = useRef | null>(null) + if (highlightRefs.current === null) highlightRefs.current = new Map() const nodeElementCacheRef = useRef(null) if (nodeElementCacheRef.current === null) nodeElementCacheRef.current = new CanvasNodeElementCache() /** clientId → rendered cursor position (easing toward the last target) @@ -195,6 +258,54 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc } }, [iframeElement, breakpointId]) + // ── Local text-caret publisher (this frame) ────────────────────────────── + // During an inline-edit session owned by THIS frame, every selection + // change publishes the caret as Y.Text RELATIVE positions — pinned to the + // CRDT items around the caret, so peers resolve the right spot even while + // concurrent edits shift the text (see collab/caretPositions.ts). + useEffect(() => { + const iframe = iframeElement + if (!iframe) return undefined + + let attachedDoc: Document | null = null + const handleSelectionChange = (): void => { + const store = useEditorStore.getState() + const session = store.activeInlineEdit + if (!session || session.breakpointId !== breakpointId) return + const doc = attachedDoc + if (!doc) return + const element = doc.querySelector(`[data-node-id="${session.nodeId}"]`) + const selection = doc.getSelection() + if (!element || !selection || selection.rangeCount === 0) return + if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) return + const collabDoc = collabDocFor( + activeEditorDocId({ activeDocument: store.activeDocument, activePageId: store.activePageId }) ?? '', + ) + if (!collabDoc || !selection.anchorNode || !selection.focusNode) return + const anchor = textOffsetAtDomPosition(doc, element, selection.anchorNode, selection.anchorOffset) + const head = textOffsetAtDomPosition(doc, element, selection.focusNode, selection.focusOffset) + publishLocalTextCaret(encodeCaretRange(collabDoc, session.nodeId, session.prop, anchor, head)) + } + const detach = (): void => { + attachedDoc?.removeEventListener('selectionchange', handleSelectionChange) + attachedDoc = null + } + const attach = (): void => { + const doc = iframe.contentDocument + if (!doc || doc === attachedDoc) return + detach() + attachedDoc = doc + doc.addEventListener('selectionchange', handleSelectionChange) + } + + attach() + iframe.addEventListener('load', attach) + return () => { + iframe.removeEventListener('load', attach) + detach() + } + }, [iframeElement, breakpointId]) + // ── Peer chrome positioning (RAF, read-then-write like the selection overlay) ── const tickOnce = useEffectEvent((iframe: HTMLIFrameElement | null) => { const iframeDoc = iframe?.contentDocument ?? null @@ -275,6 +386,44 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc } else if (pointerElement) { pointerElement.dataset.visible = 'false' } + + // ── Peer text caret + selection highlight ──────────────────────────── + const caretElement = caretRefs.current?.get(peer.clientId) ?? null + const highlightContainer = highlightRefs.current?.get(peer.clientId) ?? null + let caretRect: OverlayRect | null = null + let highlightRects: OverlayRect[] = [] + if (peer.textCaret) { + const collabDoc = collabDocFor(docId ?? '') + const range = collabDoc ? resolveCaretRange(collabDoc, peer.textCaret) : null + const element = range ? elementCache.resolve(iframeDoc, peer.textCaret.nodeId) : null + if (range && element) { + trackedIds.add(peer.textCaret.nodeId) + const toOverlayRect = (r: DOMRect): OverlayRect => ({ + x: iframeRect.left + r.left * iframeScale - originLeft, + y: iframeRect.top + r.top * iframeScale - originTop, + width: r.width * iframeScale, + height: r.height * iframeScale, + }) + const head = domPositionAtTextOffset(iframeDoc, element, range.head) + const headRange = iframeDoc.createRange() + headRange.setStart(head.node, head.offset) + headRange.collapse(true) + const headRect = headRange.getClientRects()[0] ?? headRange.getBoundingClientRect() + if (headRect.height > 0 || headRect.width > 0) { + caretRect = { ...toOverlayRect(headRect), width: Math.max(1.5, 2 * iframeScale) } + } + if (range.anchor !== range.head) { + const start = domPositionAtTextOffset(iframeDoc, element, Math.min(range.anchor, range.head)) + const end = domPositionAtTextOffset(iframeDoc, element, Math.max(range.anchor, range.head)) + const selectionRange = iframeDoc.createRange() + selectionRange.setStart(start.node, start.offset) + selectionRange.setEnd(end.node, end.offset) + highlightRects = [...selectionRange.getClientRects()].slice(0, 24).map(toOverlayRect) + } + } + } + positionOverlayElement(caretElement, caretRect) + syncHighlightRects(highlightContainer, highlightRects) } elementCache.retainOnly(trackedIds) @@ -339,6 +488,24 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc {peer.editingNodeId !== null && } )} + {/* Peer caret + selection highlight inside the text they are + editing — positioned imperatively by the RAF tick. */} +
{ + if (el) caretRefs.current?.set(peer.clientId, el) + else caretRefs.current?.delete(peer.clientId) + }} + className={styles.caret} + data-peer-caret="true" + /> +
{ + if (el) highlightRefs.current?.set(peer.clientId, el) + else highlightRefs.current?.delete(peer.clientId) + }} + className={styles.caretHighlights} + data-peer-caret-highlights="true" + /> {/* Always mounted (per frame): visibility is opacity-driven by the RAF tick so entry/exit fade instead of popping. */}
@@ -94,14 +105,18 @@ export function usePublishEditorPresence( const awareness = collabAwareness() if (!awareness) return const s = useEditorStore.getState() - const previous = awareness.getLocalState() + const previous = awareness.getLocalState() as EditorPresence | null awareness.setLocalState({ ...previous, user: identity, docId: activeEditorDocId(s), selectedNodeIds: s.selectedNodeIds, editingNodeId: s.activeInlineEdit?.nodeId ?? null, - pointer: (previous as EditorPresence | null)?.pointer ?? null, + pointer: previous?.pointer ?? null, + // The caret field is OWNED by the per-frame capture + // (publishLocalTextCaret); preserve it during a session, force-clear + // it the moment the session ends so no stale caret lingers. + textCaret: s.activeInlineEdit ? (previous?.textCaret ?? null) : null, }) } @@ -119,6 +134,15 @@ export function usePublishEditorPresence( }, [userId, userName, avatarUrl, gravatarHash]) } +/** Update only the caret field of the local presence (per-frame capture). */ +export function publishLocalTextCaret( + caret: EditorPresence['textCaret'], +): void { + const awareness = collabAwareness() + if (!awareness || awareness.getLocalState() === null) return + awareness.setLocalStateField('textCaret', caret) +} + /** Update only the pointer field of the local presence (throttled by callers). */ export function publishLocalPointer( pointer: Static | null, diff --git a/src/admin/pages/site/collab/caretPositions.ts b/src/admin/pages/site/collab/caretPositions.ts new file mode 100644 index 000000000..247dea109 --- /dev/null +++ b/src/admin/pages/site/collab/caretPositions.ts @@ -0,0 +1,77 @@ +/** + * Caret position codec for text presence. + * + * A peer's caret is broadcast as Yjs RELATIVE positions (anchor + head) + * inside the edited node's Y.Text — relative positions pin to the CRDT item + * the caret sits next to, so they stay correct on every replica while + * concurrent edits shift the surrounding text (a plain character index + * would drift). Encoded to base64 for the JSON awareness state. + * + * Both sides no-op gracefully when the prop isn't a Y.Text (a node that has + * never been through the collab translator stores plain values). + */ +import * as Y from 'yjs' +import { fromBase64, toBase64 } from 'lib0/buffer' +import { treeMap } from '@core/collab' + +export interface EncodedCaretRange { + nodeId: string + prop: string + /** base64-encoded Y.RelativePosition */ + anchor: string + head: string +} + +function textOf(doc: Y.Doc, nodeId: string, prop: string): Y.Text | null { + const nodes = treeMap(doc).get('nodes') + if (!(nodes instanceof Y.Map)) return null + const node = nodes.get(nodeId) + if (!(node instanceof Y.Map)) return null + const props = node.get('props') + if (!(props instanceof Y.Map)) return null + const value = props.get(prop) + return value instanceof Y.Text ? value : null +} + +/** Character offsets → wire-encodable relative positions. */ +export function encodeCaretRange( + doc: Y.Doc, + nodeId: string, + prop: string, + anchorIndex: number, + headIndex: number, +): EncodedCaretRange | null { + const text = textOf(doc, nodeId, prop) + if (!text) return null + const clamp = (index: number) => Math.max(0, Math.min(text.length, index)) + return { + nodeId, + prop, + anchor: toBase64(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, clamp(anchorIndex)))), + head: toBase64(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, clamp(headIndex)))), + } +} + +/** Wire positions → character offsets in THIS replica's current text. */ +export function resolveCaretRange( + doc: Y.Doc, + caret: EncodedCaretRange, +): { anchor: number; head: number } | null { + const text = textOf(doc, caret.nodeId, caret.prop) + if (!text) return null + try { + const anchor = Y.createAbsolutePositionFromRelativePosition( + Y.decodeRelativePosition(fromBase64(caret.anchor)), + doc, + ) + const head = Y.createAbsolutePositionFromRelativePosition( + Y.decodeRelativePosition(fromBase64(caret.head)), + doc, + ) + if (!anchor || !head || anchor.type !== text || head.type !== text) return null + return { anchor: anchor.index, head: head.index } + } catch { + // Malformed wire data from a peer — treat as "no caret", never crash. + return null + } +} diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 2d95bb3bb..16eb5f448 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -117,6 +117,11 @@ export function collabAwareness(): Awareness | null { return provider?.awareness ?? null } +/** The live Y doc for a docId — caret presence encodes/resolves against it. */ +export function collabDocFor(docId: string): Y.Doc | null { + return docs.get(docId) ?? null +} + /** Notifies when the provider connects/disconnects — re-grab the awareness. */ export function onCollabProviderChange(listener: () => void): () => void { providerChangeListeners.add(listener) From 482df727cf1e9fd0abbd992ca2baab7520e78cfe Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 15:05:06 +0200 Subject: [PATCH 26/49] refactor(collab): write-policy module, socket hardening, presence publisher hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security + maintenance pass over the co-editing foundation: - Move validateSiteWriteDiff/validatePageWriteDiff out of server/handlers/cms/ into a new server/writePolicy/ module — they are pure per-capability policy shared by BOTH write transports (HTTP save handler and the collab relay's update guard), not handler code. - Harden server/collab/socket.ts: per-frame payload caps (64 KB awareness, 4 MB sync, transport maxPayloadLength) and awareness identity enforcement — frames claiming another user's identity are decoded, checked against the session, and dropped, so presence can't be spoofed. - Extract the per-frame pointer + caret presence publishers from PeerPresenceOverlay into @site/collab/framePresencePublishers with a shared attach-across-iframe-loads helper; the overlay is render-only (537 → ~400 lines). - Relay integration test: viewer presence resolves the real user id from the db and asserts spoofed states never relay. - Docs track the moves (site-shell.md, server.md, deepEqual header). Co-Authored-By: Claude Fable 5 --- docs/features/site-shell.md | 14 +- docs/server.md | 1 + server/ai/tools/site/writeTools.ts | 2 +- server/collab/socket.ts | 43 +++++ server/collab/updateGuard.ts | 4 +- server/handlers/cms/siteDocument.ts | 4 +- .../{handlers/cms => writePolicy}/pageDiff.ts | 7 +- .../{handlers/cms => writePolicy}/siteDiff.ts | 7 +- .../cms-handlers-capability-gated.test.ts | 2 - .../server/collabRelayIntegration.test.ts | 19 +- .../pages/site/canvas/PeerPresenceOverlay.tsx | 161 ++-------------- .../site/collab/framePresencePublishers.ts | 181 ++++++++++++++++++ src/core/module-engine/propertySchema.ts | 2 +- src/core/utils/deepEqual.ts | 2 +- 14 files changed, 280 insertions(+), 169 deletions(-) rename server/{handlers/cms => writePolicy}/pageDiff.ts (95%) rename server/{handlers/cms => writePolicy}/siteDiff.ts (96%) create mode 100644 src/admin/pages/site/collab/framePresencePublishers.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 2e6d17bd7..bb641cc96 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -536,9 +536,14 @@ defense). A read-only connection's update frames are dropped server-side and PARTIAL writers' update frames run through the per-category guard (`server/collab/updateGuard.ts`): fork the doc, apply, project both sides, and reuse the HTTP path's `validateSiteWriteDiff`/`validatePageWriteDiff` — -one enforcement vocabulary on both transports. Rejected updates never touch +one enforcement vocabulary on both transports (the validators live in +`server/writePolicy/` for exactly that reason). Rejected updates never touch the authoritative doc; the sender gets a targeted reset that reverts its -local fork. +local fork. Two more socket-level defenses: per-frame payload caps (64 KB +awareness / 4 MB sync, plus the transport `maxPayloadLength`) drop oversized +frames before any decode work, and every awareness frame is decoded and +checked against the session — a state claiming another user's identity +(`state.user.id !== session user`) is dropped, so presence can't be spoofed. **Client transport** (`src/admin/pages/site/collab/collabProvider.ts`): one socket, every bound doc multiplexed; local transactions send updates the @@ -548,7 +553,8 @@ vectors pull exactly the missed delta. `usePersistence` HTTP-loads the document once for first paint, then connects the provider — edits gate on each doc's first sync so an unseeded doc can never receive local ops. -**Presence** (`src/admin/pages/site/collab/awarenessState.ts` + +**Presence** (`src/admin/pages/site/collab/awarenessState.ts`; per-frame +publishers in `collab/framePresencePublishers.ts`, rendering in `PeerPresenceOverlay`): every editor publishes identity (deterministic HSL color from the user id + the same upload→Gravatar avatar fields every admin surface uses), active doc, selection, inline-edit state, a pointer, and — @@ -569,7 +575,7 @@ client-side flush is gone. ### Atomic diff validation -The save handler validates the shell diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The shell diff validator is `validateSiteWriteDiff` (`server/handlers/cms/siteDiff.ts`); per-page category diffs run through `validatePageWriteDiff` (`server/handlers/cms/pageDiff.ts`). +The save handler validates the shell diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The shell diff validator is `validateSiteWriteDiff` (`server/writePolicy/siteDiff.ts`); per-page category diffs run through `validatePageWriteDiff` (`server/writePolicy/pageDiff.ts`). The same two validators back the collab relay's update guard (`server/collab/updateGuard.ts`) — `server/writePolicy/` is the one write-policy module shared by both transports. --- diff --git a/docs/server.md b/docs/server.md index b9a3d1fc7..ede700c57 100644 --- a/docs/server.md +++ b/docs/server.md @@ -14,6 +14,7 @@ The server is a single `Bun.serve` process that boots the DB, runs migrations, a - **Auth:** session cookie (`SESSION_COOKIE_NAME`) → `findUserBySessionHash` → `requireCapability(req, db, 'site.read')`. Every state-changing handler starts with one of these guards. - **DB:** one `DbClient` interface (`server/db/client.ts`) — tagged-template callable returning `{ rows, rowCount }`. Two adapters: `postgres.ts` (via `Bun.sql`) and `sqlite.ts` (via `bun:sqlite`). Selected by `DATABASE_URL`. - **Repositories** (`server/repositories/`) hold all SQL. Handlers never write SQL directly. +- **Write policy** (`server/writePolicy/`) — pure per-capability diff validators (`validateSiteWriteDiff`, `validatePageWriteDiff`) shared by BOTH write transports: the HTTP save handler (`server/handlers/cms/siteDocument.ts`) and the collab relay's CRDT update guard (`server/collab/updateGuard.ts`). No DB, no HTTP — plain data in, verdict out. - **Plugins:** `server/plugins/runtime.ts` activates installed plugins at boot. Server entrypoints run in per-plugin Bun workers that host QuickJS-WASM (`server/plugins/pluginWorker.ts`, `server/plugins/host/workerPool.ts`, `server/plugins/quickjs/vm.ts`); module packs use `server/plugins/modulePackVm.ts` for server-side evaluation. - **Published pages and content rows** are served by `tryServePublicRoute`, which delegates resolution + render to `server/publish/publicRouter.ts`. A warm Layer B cache entry is served before any DB work; on a miss the live render reads the published `SiteDocument` from `site_snapshots` (stored once per publish, referenced by `data_row_versions.site_snapshot_id`, memoised per publish version). Uploads + admin SPA assets are served from disk by `tryServeUpload` and `tryServeStaticAsset`. diff --git a/server/ai/tools/site/writeTools.ts b/server/ai/tools/site/writeTools.ts index 384d1398c..d7edc0881 100644 --- a/server/ai/tools/site/writeTools.ts +++ b/server/ai/tools/site/writeTools.ts @@ -56,7 +56,7 @@ import type { AiTool } from '../types' // --------------------------------------------------------------------------- // Capability requirements (ANY-OF) — mirror the editor's change-class model -// (structure / content / style — see server/handlers/cms/siteDiff.ts and the +// (structure / content / style — see server/writePolicy/siteDiff.ts and the // `site.structure.edit` gate on PUT /admin/api/cms/pages). Selection-time // gating only: persistence is independently re-validated server-side. // `site_get_node_html`, `site_read_document`, `site_open_document`, and `site_render_snapshot` are diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 84e7fe1c5..4424bddad 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -56,6 +56,39 @@ const SITE_WRITE_CAPABILITIES = ['site.structure.edit', 'site.content.edit', 'si /** y-protocols/sync message types (the payload's first varUint). */ const SYNC_STEP_1 = 0 +// Frame-size ceilings (belt and braces on top of Bun's maxPayloadLength): +// presence states are a few hundred bytes; doc updates can legitimately be +// large (a big paste, a whole-doc repopulate) but never tens of megabytes. +const MAX_AWARENESS_PAYLOAD_BYTES = 64 * 1024 +const MAX_SYNC_PAYLOAD_BYTES = 4 * 1024 * 1024 + +/** + * Presence identity must be the SESSION's identity — decode the awareness + * update (varUint count, then per client: varUint id, varUint clock, + * varString stateJSON) and reject any non-null state claiming a different + * user id. Without this, any authenticated connection could impersonate + * another admin's name/avatar in every peer's UI. + */ +function awarenessUpdateMatchesUser(payload: Uint8Array, userId: string): boolean { + try { + const decoder = decoding.createDecoder(payload) + const count = decoding.readVarUint(decoder) + for (let i = 0; i < count; i++) { + decoding.readVarUint(decoder) // clientID + decoding.readVarUint(decoder) // clock + const raw = decoding.readVarString(decoder) + if (raw === 'null') continue // disconnect / clear — always allowed + const state: unknown = JSON.parse(raw) + const claimed = (state as { user?: { id?: unknown } } | null)?.user?.id + if (claimed !== undefined && claimed !== userId) return false + } + return true + } catch { + // Malformed update — reject rather than relay garbage. + return false + } +} + export interface CollabSocketData { userId: string canWrite: boolean @@ -142,6 +175,10 @@ export function createCollabSocketLayer(relay: CollabRelay) { }) const handlers: WebSocketHandler = { + // Transport-level ceiling — the per-frame-type caps in `message` are the + // fine-grained guards; this stops oversized frames before they buffer. + maxPayloadLength: MAX_SYNC_PAYLOAD_BYTES + 1024, + open(ws: ServerWebSocket) { ws.subscribe(docTopic(PRESENCE_DOC_ID)) // Late joiners need the current presence roster. @@ -158,6 +195,11 @@ export function createCollabSocketLayer(relay: CollabRelay) { if (frame.frameType === FRAME_AWARENESS) { // Presence is NOT a doc write — read-only viewers are visible peers. + if (frame.payload.byteLength > MAX_AWARENESS_PAYLOAD_BYTES) return + if (!awarenessUpdateMatchesUser(frame.payload, ws.data.userId)) { + console.warn(`[collab] dropped awareness frame with spoofed identity from ${ws.data.userId}`) + return + } // Track which clientIDs this connection contributes so its peers // vanish immediately on close. const before = new Set(awareness.getStates().keys()) @@ -177,6 +219,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { if (frame.frameType !== FRAME_SYNC) return if (!parseCollabDocId(frame.docId)) return + if (frame.payload.byteLength > MAX_SYNC_PAYLOAD_BYTES) return // Read-only connections may REQUEST state (step1) but never write. const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) diff --git a/server/collab/updateGuard.ts b/server/collab/updateGuard.ts index ecaf03dd4..f86d087ed 100644 --- a/server/collab/updateGuard.ts +++ b/server/collab/updateGuard.ts @@ -27,8 +27,8 @@ import { projectSiteDoc, } from '@core/collab' import type { CoreCapability } from '@core/capabilities' -import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../handlers/cms/siteDiff' -import { validatePageWriteDiff } from '../handlers/cms/pageDiff' +import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../writePolicy/siteDiff' +import { validatePageWriteDiff } from '../writePolicy/pageDiff' export type UpdateGuardVerdict = { ok: true } | { ok: false; reason: string } diff --git a/server/handlers/cms/siteDocument.ts b/server/handlers/cms/siteDocument.ts index bc2a789ad..61de9f44d 100644 --- a/server/handlers/cms/siteDocument.ts +++ b/server/handlers/cms/siteDocument.ts @@ -87,8 +87,8 @@ import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '. import { bumpPublishVersionSerialized } from '../../publish/publishState' import { Type, type Static } from '@core/utils/typeboxHelpers' import { CMS_API_PREFIX } from './shared' -import { ForbiddenSiteChangeError, validateSiteWriteDiff } from './siteDiff' -import { validatePageWriteDiff } from './pageDiff' +import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../../writePolicy/siteDiff' +import { validatePageWriteDiff } from '../../writePolicy/pageDiff' const SITE_WRITE_CAPABILITIES = [ 'site.structure.edit', diff --git a/server/handlers/cms/pageDiff.ts b/server/writePolicy/pageDiff.ts similarity index 95% rename from server/handlers/cms/pageDiff.ts rename to server/writePolicy/pageDiff.ts index d0063089e..a2f7a4d2a 100644 --- a/server/handlers/cms/pageDiff.ts +++ b/server/writePolicy/pageDiff.ts @@ -1,5 +1,8 @@ /** - * Page write diff validator for PUT /admin/api/cms/pages. + * Page write diff validator — the per-category capability POLICY for page + * changes, shared by BOTH write transports: the transactional HTTP save + * (server/handlers/cms/siteDocument.ts) and the collab relay's update guard + * (server/collab/updateGuard.ts). * * The pages endpoint owns both dangerous roster reconciliation and ordinary * node edits. A coarse `site.structure.edit` gate protects deletion, but it @@ -12,7 +15,7 @@ * - content: props whose module schema marks them content-editable. * - style: class assignments, inline styles, breakpoint overrides. */ -import type { CoreCapability } from '../../auth/capabilities' +import type { CoreCapability } from '../auth/capabilities' import { ForbiddenSiteChangeError } from './siteDiff' import { registry, resolvePropertyControlCategory } from '@core/module-engine' import type { Page, PageNode } from '@core/page-tree' diff --git a/server/handlers/cms/siteDiff.ts b/server/writePolicy/siteDiff.ts similarity index 96% rename from server/handlers/cms/siteDiff.ts rename to server/writePolicy/siteDiff.ts index b298ac2bd..0f2c6fc63 100644 --- a/server/handlers/cms/siteDiff.ts +++ b/server/writePolicy/siteDiff.ts @@ -1,5 +1,8 @@ /** - * Site-shell write diff validator — enforces granular capabilities on PUT /admin/api/cms/site. + * Site-shell write diff validator — the per-category capability POLICY for + * shell changes, shared by BOTH write transports: the transactional HTTP + * save (server/handlers/cms/siteDocument.ts) and the collab relay's update + * guard (server/collab/updateGuard.ts). * * The save endpoint accepts the site shell and replaces the draft. * To support a "Client" role with `site.content.edit` only, we walk the diff @@ -28,7 +31,7 @@ * the incoming document is treated as a structural change in its entirety — * a content-only caller cannot bootstrap a site from nothing. */ -import type { CoreCapability } from '../../auth/capabilities' +import type { CoreCapability } from '../auth/capabilities' import type { StyleRule, SiteShell, diff --git a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts index 4170023eb..6dd18c42e 100644 --- a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts +++ b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts @@ -51,8 +51,6 @@ const ALLOWLIST: ReadonlyMap = new Map([ // exports. No request handlers live here. ['shared.ts', 'Shared request helpers; no handlers.'], ['session.ts', 'Session lookup helper; called from auth.ts which gates.'], - ['siteDiff.ts', 'Diff validator called from site.ts after that file gates.'], - ['pageDiff.ts', 'Diff validator called from pages.ts after that file gates.'], // Media upload helpers — `acceptUploadedMedia`, `readUploadedFile`, // file-magic sniffing. Always called by an already-gated parent // handler (`/me/avatar`, `/media`). diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index b7ace56d8..ed5b4e9d5 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -280,15 +280,26 @@ describe('collab relay integration (real server, real sockets)', () => { expect((treeMap(boundOwner.doc).get('nodes') as Y.Map).has('forbidden-node')).toBe(false) // Read-only presence: a viewer's awareness state reaches other peers - // (presence is not a doc write). - viewerClient.awareness.setLocalState({ user: { id: 'viewer-1', name: 'Viewer' } }) + // (presence is not a doc write). Identity is server-verified — the state + // must claim the SESSION's real user id or the frame is dropped. + const { rows: viewerRows } = await stack.harness.db<{ id: string }>` + select id from users where email = ${viewer.email} + ` + const viewerUserId = viewerRows[0].id + viewerClient.awareness.setLocalState({ user: { id: 'someone-else', name: 'Spoof' } }) + viewerClient.awareness.setLocalState({ user: { id: viewerUserId, name: 'Viewer' } }) await waitFor(() => { for (const [, state] of owner.awareness.getStates()) { - const s = state as { user?: { id?: string } } - if (s.user?.id === 'viewer-1') return true + const s = state as { user?: { id?: string; name?: string } } + if (s.user?.id === viewerUserId) return true } return false }) + // The spoofed frame never relayed. + for (const [, state] of owner.awareness.getStates()) { + const s = state as { user?: { id?: string } } + expect(s.user?.id).not.toBe('someone-else') + } }) it('resets a doc when the row is written outside the relay', async () => { diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index 880e5e16c..d35980577 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -14,9 +14,10 @@ * inline text-edit session), * - a pointer dot inside the frame the peer's cursor is over. * - * This component also PUBLISHES the local pointer for its frame (throttled - * mousemove on the iframe document) — presence is symmetric, so the reader - * and the writer live in the same mount. + * This is the RENDER side only. The write side — publishing the local + * pointer and text caret for this frame — lives in + * `@site/collab/framePresencePublishers`, mounted here so reader and writer + * share the same per-frame lifecycle. * * All colors flow through the `--peer-color` inline custom property * (deterministic identity HSL from the user id — see awarenessState.ts). @@ -29,12 +30,14 @@ import { PeerAvatar } from '@site/collab/PeerAvatar' import { useEditorStore } from '@site/store/store' import { activeEditorDocId, - publishLocalPointer, - publishLocalTextCaret, usePeerPresences, type PeerPresence, } from '@site/collab/awarenessState' -import { encodeCaretRange, resolveCaretRange } from '@site/collab/caretPositions' +import { + useCaretPresencePublisher, + usePointerPresencePublisher, +} from '@site/collab/framePresencePublishers' +import { resolveCaretRange } from '@site/collab/caretPositions' import { collabDocFor } from '@site/store/slices/site/collabBinding' import { CanvasViewportActionsContext } from './CanvasContexts' import { CanvasNodeElementCache } from './canvasNodeLookup' @@ -42,12 +45,6 @@ import { createCanvasOverlayMeasureSession } from './canvasOverlayGeometry' import { hideOverlayElement, positionOverlayElement } from './canvasSelectionOverlayPositioning' import styles from './PeerPresenceOverlay.module.css' -// Publish at 10 Hz with a movement deadband — interpolation on the receiving -// side (below) turns the sparse samples back into smooth motion, so a lower -// wire rate COSTS nothing visually and saves frames for everyone. -const POINTER_PUBLISH_INTERVAL_MS = 100 -const POINTER_DEADBAND_PX = 2 - // Receive-side smoothing: each frame the rendered cursor eases toward the // last received target with an exponential time constant. ~TAU ms closes 63% // of the remaining gap; visually settled in ~3×TAU. @@ -109,19 +106,6 @@ function domPositionAtTextOffset( return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 } } -/** Character offset of a DOM selection point within `root` (walk order). */ -function textOffsetAtDomPosition( - iframeDoc: Document, - root: HTMLElement, - node: Node, - offset: number, -): number { - const range = iframeDoc.createRange() - range.setStart(root, 0) - range.setEnd(node, offset) - return range.toString().length -} - /** Sync `container`'s children to one absolutely-positioned div per rect. */ function syncHighlightRects(container: HTMLElement | null, rects: OverlayRect[]): void { if (!container) return @@ -182,129 +166,10 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc return () => cancelAnimationFrame(frame) }, [viewportActions]) - // ── Local pointer publisher (this frame) ───────────────────────────────── - // The frame boots via `srcDoc`, which REPLACES the iframe's initial - // about:blank document on load — listeners attached to the pre-load - // document die silently. Attach to the CURRENT document and re-attach on - // every `load` so the publisher survives the swap (and any reload). - useEffect(() => { - const iframe = iframeElement - if (!iframe) return undefined - - let attachedDoc: Document | null = null - let lastSent = 0 - let lastX = Number.NaN - let lastY = Number.NaN - let trailing: ReturnType | null = null - - const publish = (x: number, y: number): void => { - lastSent = performance.now() - lastX = x - lastY = y - publishLocalPointer({ x, y, breakpointId }) - } - const handleMove = (event: MouseEvent): void => { - const { clientX, clientY } = event - // Sub-deadband tremor isn't worth a frame on the wire. - if (Math.hypot(clientX - lastX, clientY - lastY) < POINTER_DEADBAND_PX) return - const elapsed = performance.now() - lastSent - if (elapsed >= POINTER_PUBLISH_INTERVAL_MS) { - if (trailing) { - clearTimeout(trailing) - trailing = null - } - publish(clientX, clientY) - return - } - // Inside the throttle window: schedule ONE trailing publish so the - // cursor's final resting position always ships (a plain leading-edge - // throttle would drop the last sample and leave peers slightly off). - if (trailing) clearTimeout(trailing) - trailing = setTimeout(() => { - trailing = null - publish(clientX, clientY) - }, POINTER_PUBLISH_INTERVAL_MS - elapsed) - } - const handleLeave = (): void => { - if (trailing) { - clearTimeout(trailing) - trailing = null - } - lastX = Number.NaN - lastY = Number.NaN - publishLocalPointer(null) - } - const detach = (): void => { - attachedDoc?.removeEventListener('mousemove', handleMove) - attachedDoc?.removeEventListener('mouseleave', handleLeave) - attachedDoc = null - } - const attach = (): void => { - const doc = iframe.contentDocument - if (!doc || doc === attachedDoc) return - detach() - attachedDoc = doc - doc.addEventListener('mousemove', handleMove) - doc.addEventListener('mouseleave', handleLeave) - } - - attach() - iframe.addEventListener('load', attach) - return () => { - iframe.removeEventListener('load', attach) - if (trailing) clearTimeout(trailing) - detach() - publishLocalPointer(null) - } - }, [iframeElement, breakpointId]) - - // ── Local text-caret publisher (this frame) ────────────────────────────── - // During an inline-edit session owned by THIS frame, every selection - // change publishes the caret as Y.Text RELATIVE positions — pinned to the - // CRDT items around the caret, so peers resolve the right spot even while - // concurrent edits shift the text (see collab/caretPositions.ts). - useEffect(() => { - const iframe = iframeElement - if (!iframe) return undefined - - let attachedDoc: Document | null = null - const handleSelectionChange = (): void => { - const store = useEditorStore.getState() - const session = store.activeInlineEdit - if (!session || session.breakpointId !== breakpointId) return - const doc = attachedDoc - if (!doc) return - const element = doc.querySelector(`[data-node-id="${session.nodeId}"]`) - const selection = doc.getSelection() - if (!element || !selection || selection.rangeCount === 0) return - if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) return - const collabDoc = collabDocFor( - activeEditorDocId({ activeDocument: store.activeDocument, activePageId: store.activePageId }) ?? '', - ) - if (!collabDoc || !selection.anchorNode || !selection.focusNode) return - const anchor = textOffsetAtDomPosition(doc, element, selection.anchorNode, selection.anchorOffset) - const head = textOffsetAtDomPosition(doc, element, selection.focusNode, selection.focusOffset) - publishLocalTextCaret(encodeCaretRange(collabDoc, session.nodeId, session.prop, anchor, head)) - } - const detach = (): void => { - attachedDoc?.removeEventListener('selectionchange', handleSelectionChange) - attachedDoc = null - } - const attach = (): void => { - const doc = iframe.contentDocument - if (!doc || doc === attachedDoc) return - detach() - attachedDoc = doc - doc.addEventListener('selectionchange', handleSelectionChange) - } - - attach() - iframe.addEventListener('load', attach) - return () => { - iframe.removeEventListener('load', attach) - detach() - } - }, [iframeElement, breakpointId]) + // Presence publishing (pointer + caret) lives in @site/collab — + // the overlay owns only the render side. + usePointerPresencePublisher(iframeElement, breakpointId) + useCaretPresencePublisher(iframeElement, breakpointId) // ── Peer chrome positioning (RAF, read-then-write like the selection overlay) ── const tickOnce = useEffectEvent((iframe: HTMLIFrameElement | null) => { diff --git a/src/admin/pages/site/collab/framePresencePublishers.ts b/src/admin/pages/site/collab/framePresencePublishers.ts new file mode 100644 index 000000000..5cc184e4b --- /dev/null +++ b/src/admin/pages/site/collab/framePresencePublishers.ts @@ -0,0 +1,181 @@ +/** + * Frame-scoped presence PUBLISHERS — the write side of canvas presence, + * extracted from PeerPresenceOverlay so the overlay owns only rendering. + * + * Both hooks attach listeners to a breakpoint frame's iframe DOCUMENT. The + * frame boots via `srcDoc`, which REPLACES the iframe's initial about:blank + * document on load — listeners attached to the pre-load document die + * silently — so both re-attach on every iframe `load` (the bug class that + * once shipped cursor publishing dead). + * + * - usePointerPresencePublisher: throttled mousemove → awareness pointer + * (10 Hz + deadband + trailing flush; the receive side interpolates). + * - useCaretPresencePublisher: selectionchange during an inline-edit + * session owned by this frame → awareness textCaret as Y.Text RELATIVE + * positions (see ./caretPositions.ts). + */ +import { useEffect } from 'react' +import { useEditorStore } from '@site/store/store' +import { collabDocFor } from '@site/store/slices/site/collabBinding' +import { + activeEditorDocId, + publishLocalPointer, + publishLocalTextCaret, +} from './awarenessState' +import { encodeCaretRange } from './caretPositions' + +// Publish at 10 Hz with a movement deadband — interpolation on the receiving +// side turns the sparse samples back into smooth motion, so a lower wire +// rate costs nothing visually and saves frames for everyone. +const POINTER_PUBLISH_INTERVAL_MS = 100 +const POINTER_DEADBAND_PX = 2 + +/** + * Attach `setup` to the iframe's CURRENT document and re-attach on every + * `load`. `setup` returns its own teardown for the document it attached to. + */ +function attachAcrossLoads( + iframe: HTMLIFrameElement, + setup: (doc: Document) => () => void, +): () => void { + let attachedDoc: Document | null = null + let teardown: (() => void) | null = null + const detach = (): void => { + teardown?.() + teardown = null + attachedDoc = null + } + const attach = (): void => { + const doc = iframe.contentDocument + if (!doc || doc === attachedDoc) return + detach() + attachedDoc = doc + teardown = setup(doc) + } + attach() + iframe.addEventListener('load', attach) + return () => { + iframe.removeEventListener('load', attach) + detach() + } +} + +/** Broadcast the local mouse position over `breakpointId`'s frame. */ +export function usePointerPresencePublisher( + iframeElement: HTMLIFrameElement | null, + breakpointId: string, +): void { + useEffect(() => { + const iframe = iframeElement + if (!iframe) return undefined + + let lastSent = 0 + let lastX = Number.NaN + let lastY = Number.NaN + let trailing: ReturnType | null = null + + const publish = (x: number, y: number): void => { + lastSent = performance.now() + lastX = x + lastY = y + publishLocalPointer({ x, y, breakpointId }) + } + const handleMove = (event: MouseEvent): void => { + const { clientX, clientY } = event + // Sub-deadband tremor isn't worth a frame on the wire. + if (Math.hypot(clientX - lastX, clientY - lastY) < POINTER_DEADBAND_PX) return + const elapsed = performance.now() - lastSent + if (elapsed >= POINTER_PUBLISH_INTERVAL_MS) { + if (trailing) { + clearTimeout(trailing) + trailing = null + } + publish(clientX, clientY) + return + } + // Inside the throttle window: schedule ONE trailing publish so the + // cursor's final resting position always ships (a plain leading-edge + // throttle would drop the last sample and leave peers slightly off). + if (trailing) clearTimeout(trailing) + trailing = setTimeout(() => { + trailing = null + publish(clientX, clientY) + }, POINTER_PUBLISH_INTERVAL_MS - elapsed) + } + const handleLeave = (): void => { + if (trailing) { + clearTimeout(trailing) + trailing = null + } + lastX = Number.NaN + lastY = Number.NaN + publishLocalPointer(null) + } + + const detach = attachAcrossLoads(iframe, (doc) => { + doc.addEventListener('mousemove', handleMove) + doc.addEventListener('mouseleave', handleLeave) + return () => { + doc.removeEventListener('mousemove', handleMove) + doc.removeEventListener('mouseleave', handleLeave) + } + }) + return () => { + if (trailing) clearTimeout(trailing) + detach() + publishLocalPointer(null) + } + }, [iframeElement, breakpointId]) +} + +/** Character offset of a DOM selection point within `root` (walk order). */ +function textOffsetAtDomPosition( + iframeDoc: Document, + root: HTMLElement, + node: Node, + offset: number, +): number { + const range = iframeDoc.createRange() + range.setStart(root, 0) + range.setEnd(node, offset) + return range.toString().length +} + +/** + * Broadcast the local caret/selection while an inline text-edit session + * owned by `breakpointId`'s frame is active — encoded as Y.Text relative + * positions so peers resolve the exact spot under concurrent edits. + */ +export function useCaretPresencePublisher( + iframeElement: HTMLIFrameElement | null, + breakpointId: string, +): void { + useEffect(() => { + const iframe = iframeElement + if (!iframe) return undefined + + return attachAcrossLoads(iframe, (doc) => { + const handleSelectionChange = (): void => { + const store = useEditorStore.getState() + const session = store.activeInlineEdit + if (!session || session.breakpointId !== breakpointId) return + const element = doc.querySelector(`[data-node-id="${session.nodeId}"]`) + const selection = doc.getSelection() + if (!element || !selection || selection.rangeCount === 0) return + if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) return + const collabDoc = collabDocFor( + activeEditorDocId({ + activeDocument: store.activeDocument, + activePageId: store.activePageId, + }) ?? '', + ) + if (!collabDoc || !selection.anchorNode || !selection.focusNode) return + const anchor = textOffsetAtDomPosition(doc, element, selection.anchorNode, selection.anchorOffset) + const head = textOffsetAtDomPosition(doc, element, selection.focusNode, selection.focusOffset) + publishLocalTextCaret(encodeCaretRange(collabDoc, session.nodeId, session.prop, anchor, head)) + } + doc.addEventListener('selectionchange', handleSelectionChange) + return () => doc.removeEventListener('selectionchange', handleSelectionChange) + }) + }, [iframeElement, breakpointId]) +} diff --git a/src/core/module-engine/propertySchema.ts b/src/core/module-engine/propertySchema.ts index 7166fd7d9..c71cc6947 100644 --- a/src/core/module-engine/propertySchema.ts +++ b/src/core/module-engine/propertySchema.ts @@ -200,7 +200,7 @@ export type PropertySchema = Static // Used by: // - the editor PropertyControlRenderer to compute the disabled overlay // when a content-only role hits a non-content control -// - the server `siteDiff` validator to classify prop edits when the caller +// - the server `writePolicy/siteDiff` validator to classify prop edits when the caller // is a content-only role // --------------------------------------------------------------------------- diff --git a/src/core/utils/deepEqual.ts b/src/core/utils/deepEqual.ts index eaf6989cd..38ad046a4 100644 --- a/src/core/utils/deepEqual.ts +++ b/src/core/utils/deepEqual.ts @@ -3,7 +3,7 @@ * primitives) — key order insensitive, no prototype/cycle handling (persisted * CMS documents are acyclic plain data by construction). * - * Used by the server's shell diff (`server/handlers/cms/siteDiff.ts`). + * Used by the server's write policy (`server/writePolicy/siteDiff.ts`). */ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true From ccbf579b576552e203afa5ca80148eba732299bd Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 21:51:03 +0200 Subject: [PATCH 27/49] fix(collab): correctness + robustness bugs from the feature review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - collabBinding: the DocSet.ensure path adopted a provider doc with a synced:false gate but never wired whenSynced, so a locally-created doc froze the gate forever and applyLocalSitePatches then rejected every subsequent local edit. Route through bindDocThroughProvider. - integrity: orphan healing appended every unclaimed node flat under root, double-parenting the descendants of a multi-node orphaned subtree (they landed in both root.children and their orphan parent). Re-attach only orphan SUBTREE ROOTS and heal-walk into them; break orphan-only cycles deterministically by lowest id. - relay: persistDerivedJson INSERTed via createDataRow when getDataRow returned null, but getDataRow filters soft-deleted rows — an undo-of-delete hit a permanent primary-key conflict. New upsertDataRowDraft repo helper resurrects the soft-deleted row in place instead. - socket: the async message handler had no try/catch, so a malformed frame or a projection crash in the guard escaped as an unhandled rejection. Wrap it; a failed sync-write frame now sends a targeted FRAME_RESET. High: - socket: full-identity presence check (id + name + avatar + gravatar), not id alone — a peer could keep its own id but paint another admin's name/avatar. Null (clear) states are gated to the connection's own clientIDs so a peer can't erase others' presence. Boundary now validated with TypeBox, not an `as` cast. - server: SIGTERM/SIGINT flush the relay before exit (relay.destroy) so a redeploy mid-debounce no longer drops un-persisted edits. - collabProvider: unbind settles whenSynced so continuations chained on a never-synced doc (FRAME_RESET) don't hang forever. - socket: claim boundDocs synchronously before awaiting retain so two concurrent frames for one unbound doc can't double-increment refs. - usePersistence: only connect the provider when the store holds a site (no zero-doc reconnect loop on hard load failure); a live connection now supersedes a stale load error in saveStatus instead of showing a permanent "Sync failed" over working sync. Tests: orphan-subtree + cycle reconcile, whenSynced-settles-on-unbind, upsertDataRowDraft resurrect, full-identity presence spoof rejection. Co-Authored-By: Claude Fable 5 --- server/collab/relay.ts | 25 ++- server/collab/socket.ts | 149 ++++++++++++++---- server/index.ts | 20 +++ server/repositories/data/index.ts | 1 + .../data/rows/__tests__/mutations.test.ts | 50 +++++- server/repositories/data/rows/index.ts | 1 + server/repositories/data/rows/mutations.ts | 34 ++++ src/__tests__/collab/provider.test.ts | 19 +++ src/__tests__/collab/seedProject.test.ts | 44 ++++++ .../server/collabRelayIntegration.test.ts | 27 +++- src/admin/pages/site/collab/collabProvider.ts | 7 + src/admin/pages/site/hooks/usePersistence.ts | 18 ++- .../site/store/slices/site/collabBinding.ts | 9 +- src/core/collab/integrity.ts | 74 ++++++--- 14 files changed, 393 insertions(+), 85 deletions(-) diff --git a/server/collab/relay.ts b/server/collab/relay.ts index 50aeddd65..4061434de 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -52,11 +52,10 @@ import { layoutSlugFromName } from '@core/layouts' import { validateSite } from '@core/persistence/validate' import type { DbClient } from '../db/client' import { - createDataRow, getDataRow, listDataRowIdSlugs, - saveDataRowDraft, softDeleteDataRow, + upsertDataRowDraft, } from '../repositories/data' import { getDraftSite, saveDraftSite } from '../repositories/site' import { @@ -216,18 +215,16 @@ export function createCollabRelay( slug = layoutSlugFromName(layout.name) } - const existing = await getDataRow(db, parsed.rowId) - if (existing) { - await saveDataRowDraft(db, parsed.rowId, { cells, slug }, null, null, { collabInternal: true }) - } else { - await createDataRow( - db, - { id: parsed.rowId, tableId: table, cells, slug }, - null, - null, - { collabInternal: true }, - ) - } + // upsert = update live / resurrect soft-deleted / create fresh. A row the + // roster sweep soft-deleted and a peer then restored (Cmd+Z of a page + // delete) still holds its primary key, so a plain insert would conflict + // forever — the upsert revives it in place instead. + await upsertDataRowDraft( + db, + { id: parsed.rowId, tableId: table, cells, slug }, + null, + { collabInternal: true }, + ) } async function persistNow(docId: string): Promise { diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 4424bddad..1631d2d76 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -40,8 +40,10 @@ import { parseCollabDocId, PRESENCE_DOC_ID, SITE_SOCKET_PATH, + type CollabFrame, } from '@core/collab' import { requireCapability, userHasCapability } from '../auth/authz' +import { safeParseValue, Type } from '@core/utils/typeboxHelpers' import type { CoreCapability } from '@core/capabilities' import { validateGuardedUpdate } from './updateGuard' import { originAllowed } from '../auth/security' @@ -62,25 +64,64 @@ const SYNC_STEP_1 = 0 const MAX_AWARENESS_PAYLOAD_BYTES = 64 * 1024 const MAX_SYNC_PAYLOAD_BYTES = 4 * 1024 * 1024 +/** The canonical presence identity a connection is allowed to publish. */ +export interface CollabPresenceIdentity { + id: string + name: string + avatarUrl: string | null + gravatarHash: string +} + +// Validate (not `as`-cast) the identity block of a decoded awareness state — +// TypeBox at the JSON.parse boundary, per the boundary-validation rule. +const PresenceUserSchema = Type.Object( + { + user: Type.Object({ + id: Type.String(), + name: Type.String(), + avatarUrl: Type.Union([Type.String(), Type.Null()]), + gravatarHash: Type.Union([Type.String(), Type.Null()]), + }), + }, + { additionalProperties: true }, +) + /** - * Presence identity must be the SESSION's identity — decode the awareness - * update (varUint count, then per client: varUint id, varUint clock, - * varString stateJSON) and reject any non-null state claiming a different - * user id. Without this, any authenticated connection could impersonate - * another admin's name/avatar in every peer's UI. + * A client may only publish presence that matches ITS OWN session, and may + * only clear clientIDs it contributed. Decode the awareness update (varUint + * count, then per client: varUint id, varUint clock, varString stateJSON) and: + * - reject any non-null state whose full identity (id + name + avatar + + * gravatar) differs from the session — pinning id alone let a peer keep + * its own id but paint another admin's name/avatar in every UI; + * - reject a `null` (clear) for a clientID this connection never announced — + * otherwise any peer could erase another's presence for everyone. */ -function awarenessUpdateMatchesUser(payload: Uint8Array, userId: string): boolean { +function awarenessUpdateFromSession( + payload: Uint8Array, + data: Pick, +): boolean { try { const decoder = decoding.createDecoder(payload) const count = decoding.readVarUint(decoder) for (let i = 0; i < count; i++) { - decoding.readVarUint(decoder) // clientID + const clientId = decoding.readVarUint(decoder) decoding.readVarUint(decoder) // clock const raw = decoding.readVarString(decoder) - if (raw === 'null') continue // disconnect / clear — always allowed - const state: unknown = JSON.parse(raw) - const claimed = (state as { user?: { id?: unknown } } | null)?.user?.id - if (claimed !== undefined && claimed !== userId) return false + if (raw === 'null') { + if (!data.awarenessClients.has(clientId)) return false + continue + } + const parsed = safeParseValue(PresenceUserSchema, JSON.parse(raw)) + if (!parsed.ok) return false + const u = parsed.value.user + if ( + u.id !== data.identity.id || + u.name !== data.identity.name || + u.avatarUrl !== data.identity.avatarUrl || + u.gravatarHash !== data.identity.gravatarHash + ) { + return false + } } return true } catch { @@ -91,6 +132,8 @@ function awarenessUpdateMatchesUser(payload: Uint8Array, userId: string): boolea export interface CollabSocketData { userId: string + /** The session identity this connection may publish over presence. */ + identity: CollabPresenceIdentity canWrite: boolean /** * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common @@ -133,6 +176,12 @@ export async function handleCollabSocketUpgrade( const upgraded = server.upgrade(req, { data: { userId: user.id, + identity: { + id: user.id, + name: user.displayName, + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + }, canWrite, fullSiteWriter, capabilities: user.capabilities, @@ -174,29 +223,19 @@ export function createCollabSocketLayer(relay: CollabRelay) { publisher?.publish(docTopic(docId), encodeCollabFrame(docId, FRAME_RESET, new Uint8Array())) }) - const handlers: WebSocketHandler = { - // Transport-level ceiling — the per-frame-type caps in `message` are the - // fine-grained guards; this stops oversized frames before they buffer. - maxPayloadLength: MAX_SYNC_PAYLOAD_BYTES + 1024, - - open(ws: ServerWebSocket) { - ws.subscribe(docTopic(PRESENCE_DOC_ID)) - // Late joiners need the current presence roster. - const known = [...awareness.getStates().keys()] - if (known.length > 0) { - const update = awarenessProtocol.encodeAwarenessUpdate(awareness, known) - ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update)) - } - }, - - async message(ws: ServerWebSocket, raw: string | Buffer) { - if (typeof raw === 'string') return // binary protocol only - const frame = decodeCollabFrame(new Uint8Array(raw)) - + /** + * Dispatch one decoded frame. Extracted so `message` can wrap it in a + * single try/catch — a malformed frame or a projection crash inside the + * guard must NOT escape as an unhandled rejection. + */ + async function dispatchFrame( + ws: ServerWebSocket, + frame: CollabFrame, + ): Promise { if (frame.frameType === FRAME_AWARENESS) { // Presence is NOT a doc write — read-only viewers are visible peers. if (frame.payload.byteLength > MAX_AWARENESS_PAYLOAD_BYTES) return - if (!awarenessUpdateMatchesUser(frame.payload, ws.data.userId)) { + if (!awarenessUpdateFromSession(frame.payload, ws.data)) { console.warn(`[collab] dropped awareness frame with spoofed identity from ${ws.data.userId}`) return } @@ -229,9 +268,19 @@ export function createCollabSocketLayer(relay: CollabRelay) { if (ws.data.boundDocs.has(frame.docId)) { doc = await relay.openDoc(frame.docId) } else { - doc = await relay.retain(frame.docId) + // Claim the doc SYNCHRONOUSLY before awaiting retain — otherwise two + // concurrent frames for the same not-yet-bound doc both take this + // branch and double-increment refs (close releases only once, leaking + // the entry). A racing frame now sees boundDocs and takes openDoc. ws.data.boundDocs.add(frame.docId) ws.subscribe(docTopic(frame.docId)) + try { + doc = await relay.retain(frame.docId) + } catch (err) { + ws.data.boundDocs.delete(frame.docId) + ws.unsubscribe(docTopic(frame.docId)) + throw err + } } // Partial writers (canWrite but not every site capability) pass each @@ -263,6 +312,42 @@ export function createCollabSocketLayer(relay: CollabRelay) { if (encoding.length(encoder) > 0) { ws.send(encodeCollabFrame(frame.docId, FRAME_SYNC, encoding.toUint8Array(encoder))) } + } + + const handlers: WebSocketHandler = { + // Transport-level ceiling — the per-frame-type caps in `message` are the + // fine-grained guards; this stops oversized frames before they buffer. + maxPayloadLength: MAX_SYNC_PAYLOAD_BYTES + 1024, + + open(ws: ServerWebSocket) { + ws.subscribe(docTopic(PRESENCE_DOC_ID)) + // Late joiners need the current presence roster. + const known = [...awareness.getStates().keys()] + if (known.length > 0) { + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, known) + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update)) + } + }, + + async message(ws: ServerWebSocket, raw: string | Buffer) { + if (typeof raw === 'string') return // binary protocol only + let frame: CollabFrame | null = null + try { + frame = decodeCollabFrame(new Uint8Array(raw)) + await dispatchFrame(ws, frame) + } catch (err) { + console.error('[collab] socket message handler failed:', err) + // A sync-write frame whose guard/apply threw left the sender's local + // doc diverged from the authoritative one — reset it so their client + // rebinds and reseeds. Awareness/malformed frames just get dropped. + if (frame && frame.frameType === FRAME_SYNC && parseCollabDocId(frame.docId)) { + try { + ws.send(encodeCollabFrame(frame.docId, FRAME_RESET, new Uint8Array())) + } catch (_sendErr) { + // Socket already closing — nothing to recover. + } + } + } }, close(ws: ServerWebSocket) { diff --git a/server/index.ts b/server/index.ts index fce424a2a..0084f8d42 100644 --- a/server/index.ts +++ b/server/index.ts @@ -148,4 +148,24 @@ const server = Bun.serve({ // server handle now that `Bun.serve` returned. collabSocket.setPublisher(server) +// Graceful shutdown: the relay persists on an 800 ms debounce, so a redeploy +// (SIGTERM) or Ctrl-C (SIGINT) mid-window would drop the un-persisted edits +// the old transactional save made durable on ack. Flush every dirty doc +// before exiting. Idempotent + guarded so a double signal can't double-run. +let shuttingDown = false +async function shutdown(signal: string): Promise { + if (shuttingDown) return + shuttingDown = true + console.log(`[server] ${signal} received — flushing collab docs before exit`) + try { + await collabRelay.destroy() // final-persists every live doc, detaches sources + } catch (err) { + console.error('[server] collab flush on shutdown failed:', err) + } + server.stop() + process.exit(0) +} +process.on('SIGTERM', () => void shutdown('SIGTERM')) +process.on('SIGINT', () => void shutdown('SIGINT')) + console.log(`[server] Listening on http://localhost:${config.port}`) diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 6a45583ec..3e55ba96c 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -43,6 +43,7 @@ export { createDataRow, createDataRowMany, saveDataRowDraft, + upsertDataRowDraft, updateDataRowDraftCells, saveDataRowDraftMany, softDeleteDataRow, diff --git a/server/repositories/data/rows/__tests__/mutations.test.ts b/server/repositories/data/rows/__tests__/mutations.test.ts index ab2b840ca..756560983 100644 --- a/server/repositories/data/rows/__tests__/mutations.test.ts +++ b/server/repositories/data/rows/__tests__/mutations.test.ts @@ -3,7 +3,7 @@ import { createSqliteClient } from '../../../../db/sqlite' import { sqliteMigrations } from '../../../../db/migrations-sqlite' import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' -import { softDeleteDataRow } from '../mutations' +import { softDeleteDataRow, upsertDataRowDraft } from '../mutations' import { getDataRow } from '../read' const USER_ID = 'user-author' @@ -71,3 +71,51 @@ describe('softDeleteDataRow', () => { expect(await softDeleteDataRow(db, 'missing', USER_ID)).toBeNull() }) }) + +describe('upsertDataRowDraft', () => { + let db: DbClient + beforeEach(async () => { + db = await freshDb() + }) + + it('updates a live row in place', async () => { + await seedRow(db, 'post-1') + await upsertDataRowDraft( + db, + { id: 'post-1', tableId: 'posts', cells: { title: 'Updated' }, slug: 'updated' }, + USER_ID, + ) + const row = await getDataRow(db, 'post-1') + expect(row?.cells.title).toBe('Updated') + expect(row?.slug).toBe('updated') + }) + + it('creates a fresh row when the id is unknown', async () => { + await upsertDataRowDraft( + db, + { id: 'post-new', tableId: 'posts', cells: { title: 'Fresh' }, slug: 'fresh' }, + USER_ID, + ) + expect((await getDataRow(db, 'post-new'))?.cells.title).toBe('Fresh') + }) + + it('RESURRECTS a soft-deleted row instead of hitting its primary key', async () => { + // The undo-of-delete flow: a row the roster sweep soft-deleted, then a + // peer restored. getDataRow filters soft-deleted rows, so a plain insert + // would conflict on the still-present primary key forever. + await seedRow(db, 'post-1') + await softDeleteDataRow(db, 'post-1', USER_ID) + expect(await getDataRow(db, 'post-1')).toBeNull() // soft-deleted, hidden + + await upsertDataRowDraft( + db, + { id: 'post-1', tableId: 'posts', cells: { title: 'Revived' }, slug: 'revived' }, + USER_ID, + ) + + const revived = await getDataRow(db, 'post-1') + expect(revived).not.toBeNull() + expect(revived?.cells.title).toBe('Revived') + expect(revived?.deletedAt).toBeNull() + }) +}) diff --git a/server/repositories/data/rows/index.ts b/server/repositories/data/rows/index.ts index d40b93129..0d452f16a 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -37,6 +37,7 @@ export { listDataRowsWithFilter } from './filter' export { createDataRow, saveDataRowDraft, + upsertDataRowDraft, updateDataRowDraftCells, softDeleteDataRow, updateDataRowTable, diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index a279ede91..756cf643d 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -138,6 +138,40 @@ export async function resurrectDataRow( ` } +/** + * Idempotent draft write by id — update a live row, RESURRECT a soft-deleted + * one, or create it fresh. The three-way decision the collab relay needs: a + * row the roster sweep soft-deleted and a peer then restored (undo of a page + * delete) still occupies its primary key, so a plain insert would conflict — + * exactly the flow `apply.ts` handles for HTTP batches, here for one row. + */ +export async function upsertDataRowDraft( + db: DbClient, + input: InsertDataRowInput & { id: string }, + actorUserId: string | null = null, + opts: { collabInternal?: boolean } = {}, +): Promise { + const draft = { cells: input.cells, slug: input.slug } + const updated = await updateDataRowDraftCells(db, input.id, draft, actorUserId) + if (updated) { + if (!opts.collabInternal) { + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update' }) + } + return + } + const { rows } = await db<{ id: string }>` + select id from data_rows where id = ${input.id} and deleted_at is not null + ` + if (rows.length > 0) { + await resurrectDataRow(db, input.id, draft, actorUserId) + } else { + await createDataRow(db, input, actorUserId, null, { collabInternal: true }) + } + if (!opts.collabInternal) { + notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'create' }) + } +} + /** * Slug-only write — the second phase of the roster reconcile's two-phase * slug update (see rows/reconcile.ts). The row's cells and audit columns were diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index a8b225914..822cede13 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -115,4 +115,23 @@ describe('collab provider', () => { expect(rebound.synced).toBe(false) provider.destroy() }) + + it('settles whenSynced when a never-synced doc is unbound (no hanging awaiters)', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const binding = provider.bind('page:p1') // bound but never sent step2 + + let settled = false + void binding.whenSynced.then(() => { + settled = true + }) + // Reset (or any unbind) before the first sync must resolve the promise, + // not leave every chained continuation pending forever. + socket.emit(encodeCollabFrame('page:p1', FRAME_RESET, new Uint8Array())) + await binding.whenSynced // resolves instead of hanging the test + await Promise.resolve() + expect(settled).toBe(true) + provider.destroy() + }) }) diff --git a/src/__tests__/collab/seedProject.test.ts b/src/__tests__/collab/seedProject.test.ts index 176362a88..c409d5e73 100644 --- a/src/__tests__/collab/seedProject.test.ts +++ b/src/__tests__/collab/seedProject.test.ts @@ -18,12 +18,14 @@ import { projectLayoutDoc, projectPageDoc, projectSiteDoc, + reconcileTreeIntegrity, seedComponentDoc, seedLayoutDoc, seedPageDoc, seedSiteDoc, treeMap, } from '@core/collab' +import type { BaseNode } from '@core/page-tree' import { makeNode, makePage, makeSite, makeVC } from '../fixtures' function fixturePage() { @@ -192,4 +194,46 @@ describe('tree-integrity reconcile', () => { expect(projected.nodes.root.children).toContain('ghost') expect(projected.nodes.ghost.parentId).toBe('root') }) + + it('re-attaches only orphan SUBTREE ROOTS, keeping the subtree single-parented', () => { + // Regression: a move-vs-delete race can orphan a MULTI-node subtree (O + // containing C). Appending every unclaimed node flat under root left C + // referenced by both root.children AND O.children — a node with two + // parents that renders twice. Only O (the subtree root) may re-attach. + const nodes: Record = { + root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [], parentId: null }, + orphanParent: { + id: 'orphanParent', moduleId: 'base.container', props: {}, breakpointOverrides: {}, + children: ['orphanChild'], parentId: null, + }, + orphanChild: { + id: 'orphanChild', moduleId: 'base.text', props: {}, breakpointOverrides: {}, + children: [], parentId: null, + }, + } + reconcileTreeIntegrity(nodes, 'root') + + // The subtree root is under root exactly once; the child is NOT. + expect(nodes.root.children).toEqual(['orphanParent']) + // The child stays under its orphan parent — single-parent preserved. + expect(nodes.orphanParent.children).toEqual(['orphanChild']) + // No node appears in two children arrays. + const allChildRefs = Object.values(nodes).flatMap((n) => n.children) + expect(new Set(allChildRefs).size).toBe(allChildRefs.length) + }) + + it('breaks orphan-only cycles deterministically instead of hanging', () => { + // A and B reference each other but neither is reachable from root. + const nodes: Record = { + root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, children: [], parentId: null }, + a: { id: 'a', moduleId: 'base.text', props: {}, breakpointOverrides: {}, children: ['b'], parentId: null }, + b: { id: 'b', moduleId: 'base.text', props: {}, breakpointOverrides: {}, children: ['a'], parentId: null }, + } + reconcileTreeIntegrity(nodes, 'root') + // Lowest id ('a') breaks the cycle and re-attaches under root; 'b' stays + // its child; every node is claimed exactly once. + expect(nodes.root.children).toEqual(['a']) + expect(nodes.a.children).toEqual(['b']) + expect(nodes.b.children).toEqual([]) // the back-edge to 'a' is dropped + }) }) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index ed5b4e9d5..3ae122edf 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -32,6 +32,8 @@ import { } from '../../../server/collab/socket' import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' import { getDataRow, saveDataRowDraft } from '../../../server/repositories/data' +import { findUserByEmail } from '../../../server/repositories/users' +import { peerColor } from '@site/collab/awarenessState' import { createCapabilityTestHarness, type CapabilityTestHarness, @@ -281,13 +283,21 @@ describe('collab relay integration (real server, real sockets)', () => { // Read-only presence: a viewer's awareness state reaches other peers // (presence is not a doc write). Identity is server-verified — the state - // must claim the SESSION's real user id or the frame is dropped. - const { rows: viewerRows } = await stack.harness.db<{ id: string }>` - select id from users where email = ${viewer.email} - ` - const viewerUserId = viewerRows[0].id + // must claim the SESSION's FULL identity (id + name + avatar + gravatar) + // or the frame is dropped, so a peer can't paint another admin's name. + const viewerUser = await findUserByEmail(stack.harness.db, viewer.email) + const viewerUserId = viewerUser!.id + const realIdentity = { + id: viewerUserId, + name: viewerUser!.displayName, + color: peerColor(viewerUserId), + avatarUrl: viewerUser!.avatarUrl, + gravatarHash: viewerUser!.gravatarHash, + } + // A frame keeping the real id but faking the name is still a spoof. + viewerClient.awareness.setLocalState({ user: { ...realIdentity, name: 'Owner Impersonator' } }) viewerClient.awareness.setLocalState({ user: { id: 'someone-else', name: 'Spoof' } }) - viewerClient.awareness.setLocalState({ user: { id: viewerUserId, name: 'Viewer' } }) + viewerClient.awareness.setLocalState({ user: realIdentity }) await waitFor(() => { for (const [, state] of owner.awareness.getStates()) { const s = state as { user?: { id?: string; name?: string } } @@ -295,10 +305,11 @@ describe('collab relay integration (real server, real sockets)', () => { } return false }) - // The spoofed frame never relayed. + // Neither spoofed frame relayed: no foreign id, no impersonated name. for (const [, state] of owner.awareness.getStates()) { - const s = state as { user?: { id?: string } } + const s = state as { user?: { id?: string; name?: string } } expect(s.user?.id).not.toBe('someone-else') + if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(viewerUser!.displayName) } }) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index a72281c72..f98a685d5 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -234,6 +234,13 @@ export function createCollabProvider( function unbind(docId: string): void { const entry = bound.get(docId) if (!entry) return + // Settle whenSynced before tearing the doc down — a doc unbound before it + // ever synced (FRAME_RESET, explicit unbind) would otherwise leave every + // chained `.then(...)` continuation pending forever. + if (!entry.synced) { + entry.synced = true + entry.resolveSynced() + } entry.doc.off('update', entry.updateHandler) entry.doc.destroy() bound.delete(docId) diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index c88d6b07c..f7b9fc389 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -179,6 +179,13 @@ export function usePersistence( const providerModule = import('@site/collab/collabProvider') await load() if (cancelled) return + // Only connect when the store actually holds a site to bind. A hard load + // failure with NO in-memory site would otherwise open a socket with zero + // docs and an infinite reconnect loop; skip it and let saveStatus show + // the error. When a stale in-memory site survived a transient reload + // failure, we DO connect — live sync recovers against those docs and the + // connected state then supersedes the stale load error in saveStatus. + if (useEditorStore.getState().site === null) return const { createCollabProvider } = await providerModule if (cancelled) return provider = createCollabProvider() @@ -213,10 +220,13 @@ export function usePersistence( const saveStatus: PersistenceSaveStatus = loadState.phase === 'loading' ? { state: 'loading' } - : loadState.phase === 'error' - ? { state: 'error', message: loadState.message } - : collabState === 'connected' - ? { state: 'synced' } + : // A live connection supersedes a stale load error: if a transient + // reload failed but the provider synced the in-memory docs anyway, + // editing works — don't show a permanent "Sync failed". + collabState === 'connected' + ? { state: 'synced' } + : loadState.phase === 'error' + ? { state: 'error', message: loadState.message } : collabState === 'connecting' ? { state: 'connecting' } : { state: 'offline', message: 'Reconnecting' } diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 16eb5f448..81c89d29c 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -177,9 +177,12 @@ const managedDocSet: CollabDocSet = { get: (docId) => docs.get(docId), ensure: (docId) => { if (provider) { - const binding = provider.bind(docId) - if (docs.get(docId) !== binding.doc) adoptProviderDoc(docId, binding.doc) - return binding.doc + // Route through bindDocThroughProvider so the sync gate is wired to + // `whenSynced` — adopting the doc directly here left the gate stuck at + // synced:false forever, and applyLocalSitePatches then rejected every + // subsequent local mutation. Idempotent: both calls no-op when bound. + bindDocThroughProvider(docId) + return provider.bind(docId).doc } const doc = docs.ensure(docId) ensureManaged(docId, doc) diff --git a/src/core/collab/integrity.ts b/src/core/collab/integrity.ts index 052039283..81f2a89cd 100644 --- a/src/core/collab/integrity.ts +++ b/src/core/collab/integrity.ts @@ -22,33 +22,61 @@ export function reconcileTreeIntegrity( ): void { const claimed = new Set([rootNodeId]) - // Walk from root, healing children arrays in place. - const queue: string[] = [rootNodeId] - while (queue.length > 0) { - const id = queue.shift()! - const node = nodes[id] - if (!node) continue - const healed: string[] = [] - for (const childId of node.children) { - if (childId === rootNodeId) continue // a root can never be a child - if (!nodes[childId]) continue // dangling reference - if (claimed.has(childId)) continue // duplicate / second parent - claimed.add(childId) - healed.push(childId) - queue.push(childId) + /** + * BFS-heal one subtree in place from `startId`: claim every reachable node + * and rewrite each children array to drop root-refs, dangling ids, and any + * child already claimed by another parent (single-parent invariant). + */ + const healFrom = (startId: string): void => { + const queue: string[] = [startId] + while (queue.length > 0) { + const id = queue.shift()! + const node = nodes[id] + if (!node) continue + const healed: string[] = [] + for (const childId of node.children) { + if (childId === rootNodeId) continue // a root can never be a child + if (!nodes[childId]) continue // dangling reference + if (claimed.has(childId)) continue // duplicate / second parent + claimed.add(childId) + healed.push(childId) + queue.push(childId) + } + if (healed.length !== node.children.length) node.children = healed } - if (healed.length !== node.children.length) node.children = healed } - // Orphans: nodes never reached from the root — re-attach under root, - // sorted by id so every peer picks the same order. + healFrom(rootNodeId) + const root = nodes[rootNodeId] if (!root) return - const orphans = Object.keys(nodes) - .filter((id) => !claimed.has(id)) - .sort() - if (orphans.length > 0) { - root.children = [...root.children, ...orphans] - for (const id of orphans) claimed.add(id) + + // Orphans: nodes unreachable from the root. Re-attach only orphan SUBTREE + // ROOTS — orphans not referenced as a child by any OTHER orphan — so a + // multi-node orphaned subtree keeps its shape instead of every node landing + // flat under root with two parents (root AND its orphan parent). Healing + // each attached root then claims its descendants, dropping the duplicate + // parent links inside the subtree. + const orphanIds = Object.keys(nodes).filter((id) => !claimed.has(id)) + const referencedByOrphan = new Set() + for (const id of orphanIds) { + for (const childId of nodes[id]!.children) referencedByOrphan.add(childId) + } + const orphanRoots = orphanIds.filter((id) => !referencedByOrphan.has(id)).sort() + for (const id of orphanRoots) { + if (claimed.has(id)) continue + claimed.add(id) + root.children = [...root.children, id] + healFrom(id) + } + + // Any nodes STILL unclaimed form orphan-only cycles (no reachable root): + // break each cycle by attaching its lowest id, then heal-walk claims the + // rest. Deterministic: lowest id first, re-checked after every attach. + for (const id of Object.keys(nodes).sort()) { + if (claimed.has(id)) continue + claimed.add(id) + root.children = [...root.children, id] + healFrom(id) } } From 7da3147ff706782d89510a3fa997d3cfa9b7c3a6 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 21:56:41 +0200 Subject: [PATCH 28/49] fix(publish): make collab-flush intrinsic to every publish path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay persists on an ~800 ms debounce, so any path that reads rows to publish can bake stale content. Only the full-publish HTTP route flushed (via runtime-threaded flushCollabDocs); per-row publish, the scheduled tick, and plugin-host publish did not. Replace the bolted-on runtime plumbing with one publish-flush seam (server/publish/publishFlush.ts): the relay registers its flushAll at boot, and publishDraftSite + publishDataRow both await runPublishFlush() before reading. Headless MCP reads flush too (cheap — a clean doc's flush is a no-op). Removes flushCollabDocs from the runtime/router/handler options. Client: PublishButton is disabled until the client is synced — offline edits live only in the client's Y docs, and the server flush can't bake what it never received. The existing status chip states the reason inline. Co-Authored-By: Claude Fable 5 --- server/ai/mcp/server.ts | 7 ++++ server/collab/relay.ts | 32 ++++++++++++------- server/handlers/cms/publish.ts | 6 ++-- server/handlers/cms/shared.ts | 2 -- server/index.ts | 1 - server/publish/publishFlush.ts | 25 +++++++++++++++ server/publish/publishRow.ts | 5 +++ server/publish/publishSite.ts | 5 +++ server/router.ts | 7 ---- .../pages/site/toolbar/PublishButton.tsx | 8 ++++- 10 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 server/publish/publishFlush.ts diff --git a/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index b2a29a36f..98d8049a7 100644 --- a/server/ai/mcp/server.ts +++ b/server/ai/mcp/server.ts @@ -25,6 +25,7 @@ import { getEditorBridgeForUser, type EditorBridgeScope, } from './editorBridge' +import { runPublishFlush } from '../../publish/publishFlush' export interface McpServerContext { db: DbClient @@ -112,6 +113,12 @@ export function buildMcpServer(ctx: McpServerContext): Server { }, } : live + } else { + // Headless reads hit the DB directly, but live co-editing persists on an + // ~800 ms debounce — flush the relay first so a headless read reflects + // edits still in flight in an open editor. Cheap: a clean doc's flush is + // a no-op (persistNow early-returns when not dirty). + await runPublishFlush() } const controller = new AbortController() diff --git a/server/collab/relay.ts b/server/collab/relay.ts index 4061434de..bc2f7ad1a 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -68,6 +68,7 @@ import { registerShellWriteListener, } from '../repositories/rowWriteEvents' import { bumpPublishVersionSerialized } from '../publish/publishState' +import { registerPublishFlush } from '../publish/publishFlush' const KIND_TABLE: Record, string> = { page: 'pages', @@ -338,6 +339,23 @@ export function createCollabRelay( }) }) + async function flushAll(): Promise { + for (const docId of [...entries.keys()]) { + const entry = entries.get(docId) + if (!entry) continue + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + await entry.persistChain + } + } + + // Every publish path flushes the relay first (see publishFlush.ts) so the + // baked output includes edits still inside the persist debounce window. + const detachPublishFlush = registerPublishFlush(flushAll) + return { openDoc, retain: async (docId) => { @@ -368,21 +386,11 @@ export function createCollabRelay( return () => resetListeners.delete(listener) }, resetDocs, - flushAll: async () => { - for (const docId of [...entries.keys()]) { - const entry = entries.get(docId) - if (!entry) continue - if (entry.persistTimer) { - clearTimeout(entry.persistTimer) - entry.persistTimer = null - } - entry.persistChain = entry.persistChain.then(() => persistNow(docId)) - await entry.persistChain - } - }, + flushAll, destroy: async () => { detachRowListener() detachShellListener() + detachPublishFlush() for (const docId of [...entries.keys()]) { await evict(docId, { persist: true }) } diff --git a/server/handlers/cms/publish.ts b/server/handlers/cms/publish.ts index 368c82578..8b7934c31 100644 --- a/server/handlers/cms/publish.ts +++ b/server/handlers/cms/publish.ts @@ -41,10 +41,8 @@ export async function handlePublishRoutes( const stepUp = await requireStepUp(req, db, user) if (stepUp) return stepUp - // Live co-editing persists through a debounced relay — flush it so the - // published snapshot includes edits still inside the debounce window - // (publish must bake exactly what the admins see). - await options.flushCollabDocs?.() + // publishDraftSite flushes the collab relay itself (see publishFlush.ts), + // so the snapshot includes edits still inside the debounce window. const result = await publishDraftSite(db, user.id, options.uploadsDir) await createAuditEvent(db, { actorUserId: user.id, diff --git a/server/handlers/cms/shared.ts b/server/handlers/cms/shared.ts index e4a8ae764..25cfe2209 100644 --- a/server/handlers/cms/shared.ts +++ b/server/handlers/cms/shared.ts @@ -35,8 +35,6 @@ export interface CmsHandlerOptions { * branch on `db.dialect` instead of inspecting the URL themselves. */ databaseUrl?: string - /** Flush the collab relay's debounced persists before draft-reading actions (publish). */ - flushCollabDocs?: () => Promise } export function requestAuditContext(req: Request): { ipAddress: string | null; userAgent: string | null } { diff --git a/server/index.ts b/server/index.ts index 0084f8d42..7d5b7579b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -113,7 +113,6 @@ const server = Bun.serve({ staticDir: config.staticDir, uploadsDir: config.uploadsDir, databaseUrl: config.databaseUrl, - flushCollabDocs: () => collabRelay.flushAll(), }) for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) diff --git a/server/publish/publishFlush.ts b/server/publish/publishFlush.ts new file mode 100644 index 000000000..975310500 --- /dev/null +++ b/server/publish/publishFlush.ts @@ -0,0 +1,25 @@ +/** + * Publish-flush seam — EVERY publish path awaits this before it reads rows, + * so the baked output includes collab edits still inside the relay's persist + * debounce window ("publish bakes exactly what the admins see"). + * + * The collab relay registers its flush here at boot; publish just calls + * `runPublishFlush()`. This keeps the dependency one-way (publish must not + * import the relay) and makes the flush INTRINSIC to publishing rather than + * bolted onto the one HTTP route that happened to have the relay handle — + * per-row publish, the scheduled-publish tick, and plugin-host publish all + * ride the same guarantee now. + */ +let flush: (() => Promise) | null = null + +export function registerPublishFlush(fn: () => Promise): () => void { + flush = fn + return () => { + if (flush === fn) flush = null + } +} + +/** Flush the collab relay (no-op before the relay registers, e.g. in tests). */ +export async function runPublishFlush(): Promise { + await flush?.() +} diff --git a/server/publish/publishRow.ts b/server/publish/publishRow.ts index 4e699d72d..5feb28ede 100644 --- a/server/publish/publishRow.ts +++ b/server/publish/publishRow.ts @@ -30,6 +30,7 @@ import { renderPublishedDataRowTemplate } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' import { removeArtefactInPlace, updateArtefactInPlace } from './staticArtefact' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { runPublishFlush } from './publishFlush' export interface PublishDataRowResult { row: DataRow @@ -47,6 +48,10 @@ export async function publishDataRow( publisherUserId: string | null, uploadsDir?: string, ): Promise { + // Flush the collab relay before reading the row — a page/component/row doc + // edited live may still hold un-persisted changes inside the debounce + // window, and per-row publish must bake exactly what the admins see. + await runPublishFlush() // Serialize against every other publish so the version read→bake→bump window // can't interleave and mis-stamp baked hole shells (ISS-038). return withPublishLock(() => publishDataRowLocked(db, rowId, publisherUserId, uploadsDir)) diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 925249bc9..0c39dd896 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -50,6 +50,7 @@ import { import { buildPublishedSiteCssBundle } from './siteCssBundle' import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' +import { runPublishFlush } from './publishFlush' interface PublishResult { publishedPages: number @@ -81,6 +82,10 @@ export async function publishDraftSite( adminUserId: string, uploadsDir?: string, ): Promise { + // Flush the collab relay so the published snapshot includes edits still + // inside the debounce window (publish bakes exactly what the admins see). + // Intrinsic to publishing now, not bolted onto the HTTP route. + await runPublishFlush() // Serialize against every other publish so the version read→bake→bump window // can't interleave and mis-stamp baked hole shells (ISS-038). return withPublishLock(() => publishDraftSiteLocked(db, adminUserId, uploadsDir)) diff --git a/server/router.ts b/server/router.ts index 82a388ceb..8945ccec4 100644 --- a/server/router.ts +++ b/server/router.ts @@ -28,12 +28,6 @@ interface ServerRuntime { db: DbClient staticDir?: string uploadsDir?: string - /** - * Flush the collab relay's debounced persists to the DB. Wired by the - * server boot; handlers that read-then-act on the stored draft (publish) - * call it so the snapshot includes edits still inside the debounce window. - */ - flushCollabDocs?: () => Promise /** * The raw `DATABASE_URL` the server booted with — forwarded down to * CMS handlers that need to resolve the on-disk SQLite file (e.g. the @@ -158,7 +152,6 @@ function tryServeCmsApi(req: Request, runtime: ServerRuntime, _url: URL, pathnam return handleCmsRequest(req, runtime.db, { uploadsDir: runtime.uploadsDir, databaseUrl: runtime.databaseUrl, - flushCollabDocs: runtime.flushCollabDocs, }) } diff --git a/src/admin/pages/site/toolbar/PublishButton.tsx b/src/admin/pages/site/toolbar/PublishButton.tsx index 4fb9fa4f5..8922365d8 100644 --- a/src/admin/pages/site/toolbar/PublishButton.tsx +++ b/src/admin/pages/site/toolbar/PublishButton.tsx @@ -141,7 +141,13 @@ export function PublishButton({ enabled = true, saveStatus }: PublishButtonProps } const isPublishing = state === 'publishing' - const disabled = !site || !enabled || isPublishing + // Block publish until the client is synced: local edits live only in this + // client's Y docs until they reach the server, and the server-side publish + // flush can only bake what it has received. Offline/connecting/error → the + // status chip states the reason inline (never available-then-blocked). An + // absent saveStatus (collab info unavailable) doesn't gate. + const notSynced = saveStatus ? saveStatus.state !== 'synced' : false + const disabled = !site || !enabled || isPublishing || notSynced const label = isPublishing ? 'Publishing' : state === 'published' ? 'Published' : From eb70329b20fcaeea8cf5f96c79da2061f7f8d6ec Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 22:00:16 +0200 Subject: [PATCH 29/49] perf(collab): skip cross-doc presence re-renders and unchanged roster sweeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two safe scaling wins from the review: - usePeerPresences bailed on no awareness change: a peer moving their pointer on ANY doc re-rendered every overlay. Add the useSitePeers-style structural key so a change on another doc is a no-op here; same-doc pointer moves still re-render (the canvas RAF reads pointer positions from the array). - The relay's roster-deletion sweep ran three full-table scans on every site-doc persist, including shell-field-only edits. Skip it when the roster key is unchanged since the last sweep; cleared on site-doc reset so an out-of-relay change still reconciles. The three architectural scaling boundaries (guard fork+project per keystroke, connect-binds-all-docs, whole-row reproject on remote edits) are documented in the design doc as tracked follow-ups — each needs before/after measurement rather than a blind rewrite of a live-tested hot path. Co-Authored-By: Claude Fable 5 --- server/collab/relay.ts | 17 +++++++++++ src/admin/pages/site/collab/awarenessState.ts | 28 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/server/collab/relay.ts b/server/collab/relay.ts index bc2f7ad1a..07eba9972 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -115,6 +115,9 @@ export function createCollabRelay( const persistDebounceMs = opts.persistDebounceMs ?? 800 const entries = new Map() const opening = new Map>() + // Last roster set the site-doc persist actually swept, so shell-field-only + // persists skip the three full-table scans. Cleared when the site doc resets. + let lastSweptRostersKey: string | null = null const updateListeners = new Set() const resetListeners = new Set() @@ -178,6 +181,16 @@ export function createCollabRelay( await saveDraftSite(db, shell, null, { collabInternal: true }) // Roster-driven deletions: live rows missing from the roster are gone. + // The three full-table scans below are wasted when only a shell FIELD + // changed (settings/styleRules edits, the common case) and the rosters + // are identical to the last sweep — a heavy edit session would otherwise + // run them on every debounced persist. Skip when the roster is unchanged; + // out-of-relay deletions reset the doc, so a stale roster can't linger. + const rostersKey = + projected.rosters.pages.join(',') + '|' + + projected.rosters.components.join(',') + '|' + + projected.rosters.layouts.join(',') + if (rostersKey === lastSweptRostersKey) return let deletedPublished = false for (const [table, ids] of [ ['pages', projected.rosters.pages], @@ -192,6 +205,7 @@ export function createCollabRelay( if (deleted?.status === 'published') deletedPublished = true } } + lastSweptRostersKey = rostersKey if (deletedPublished) await bumpPublishVersionSerialized() return } @@ -312,6 +326,9 @@ export function createCollabRelay( async function resetDocs(docIds: readonly string[]): Promise { const affected = docIds.filter((id) => parseCollabDocId(id) !== null) if (affected.length === 0) return + // The site doc reseeds from the DB on next bind — force a full roster + // sweep on its first persist afterwards. + if (affected.includes(SITE_DOC_ID)) lastSweptRostersKey = null for (const docId of affected) { await evict(docId, { persist: false }) } diff --git a/src/admin/pages/site/collab/awarenessState.ts b/src/admin/pages/site/collab/awarenessState.ts index 3a54a7a56..aea79e7ce 100644 --- a/src/admin/pages/site/collab/awarenessState.ts +++ b/src/admin/pages/site/collab/awarenessState.ts @@ -233,8 +233,12 @@ export function useSitePeers(localDocId: string | null): SitePeer[] { /** * Live peer presences on `docId` (everyone except the local client). - * Re-renders on every awareness change — peer pointer moves included — so - * keep consumers small and presentational. + * + * A y-protocols awareness 'change' fires for EVERY peer on EVERY doc — so a + * peer moving their pointer on another page would otherwise re-render every + * overlay here. Bail out unless THIS doc's filtered presences actually + * changed (same pattern as `useSitePeers`). Same-doc pointer moves still + * re-render — the canvas RAF reads pointer positions from the returned array. */ export function usePeerPresences(docId: string | null): PeerPresence[] { const [peers, setPeers] = useState([]) @@ -242,7 +246,10 @@ export function usePeerPresences(docId: string | null): PeerPresence[] { useEffect(() => { let awareness: Awareness | null = null const recompute = (): void => { - setPeers(readPeerPresences(docId)) + const next = readPeerPresences(docId) + setPeers((current) => + peerPresencesKey(current) === peerPresencesKey(next) ? current : next, + ) } const attach = (): void => { awareness?.off('change', recompute) @@ -260,3 +267,18 @@ export function usePeerPresences(docId: string | null): PeerPresence[] { return peers } + +/** Structural key over this doc's presences — includes pointer + caret so the + * canvas RAF still sees same-doc moves, but a change on ANOTHER doc is a + * no-op here. */ +function peerPresencesKey(peers: PeerPresence[]): string { + return peers + .map( + (p) => + `${p.clientId}:${p.selectedNodeIds.join(',')}:${p.editingNodeId ?? ''}:` + + `${p.pointer ? `${p.pointer.x},${p.pointer.y},${p.pointer.breakpointId}` : ''}:` + + `${p.textCaret ? `${p.textCaret.nodeId},${p.textCaret.anchor},${p.textCaret.head}` : ''}`, + ) + .sort() + .join('|') +} From 4c8faa249c9c9ba43b48c9cdc85980e412ccc111 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 22:08:18 +0200 Subject: [PATCH 30/49] refactor(collab): dedup helpers, canonical doc-ids, delete dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanliness pass from the review: - pageDiff.ts dropped its private byte-identical deepEqual for the shared @core/utils/deepEqual its sibling siteDiff.ts already imports. - usePersistence dropped its private errorMessage for the canonical getErrorMessage. - collabBinding + applyPatches build doc-ids through encodeCollabDocId / SITE_DOC_ID instead of `page:${id}` / 'site:default' string literals. - SHELL_PER_ENTRY_KEYS (the shell doc's Y-shape) lives once in schema.ts, imported by seed + applyPatches instead of two copies that must stay in lockstep. - applyPatches: removed a literal no-op statement and an if/else with two byte-identical branches (the preNode lookup + condition were dead). - Removed dead barrel surface (buildNodeMap/buildPropsMap/projectNodeMap/ inlineTextPropOf were only used intra-module) and the fully-dead RECONCILE_ORIGIN constant (integrity reconciles run as pure JSON, never a tagged Y transaction). - Deleted the stale 'editor.save' spotlight shortcut + fixed the editor command header (no Save command — the relay persists continuously). - PeerPresenceOverlay hides caret + selection highlights in the no-iframe tick branch so a peer's caret doesn't freeze on a frame detach. Left deliberately: project.ts's Y→object casts (internal reconstruction, not an untyped external boundary; architecture gates pass), and the vestigial per-row baseSeqs save protocol (a separate persistence-layer refactor). Co-Authored-By: Claude Fable 5 --- server/writePolicy/pageDiff.ts | 27 +------------- .../pages/site/canvas/PeerPresenceOverlay.tsx | 4 ++ src/admin/pages/site/hooks/usePersistence.ts | 9 ++--- .../site/store/slices/site/collabBinding.ts | 21 ++++++----- src/admin/spotlight/commands/editor.ts | 9 ++--- src/admin/spotlight/shortcutDispatch.ts | 1 - src/core/collab/applyPatches.ts | 37 ++++++++----------- src/core/collab/index.ts | 3 -- src/core/collab/schema.ts | 14 +++++-- src/core/collab/seed.ts | 3 +- 10 files changed, 52 insertions(+), 76 deletions(-) diff --git a/server/writePolicy/pageDiff.ts b/server/writePolicy/pageDiff.ts index a2f7a4d2a..f6877b01b 100644 --- a/server/writePolicy/pageDiff.ts +++ b/server/writePolicy/pageDiff.ts @@ -16,6 +16,7 @@ * - style: class assignments, inline styles, breakpoint overrides. */ import type { CoreCapability } from '../auth/capabilities' +import { deepEqual } from '@core/utils/deepEqual' import { ForbiddenSiteChangeError } from './siteDiff' import { registry, resolvePropertyControlCategory } from '@core/module-engine' import type { Page, PageNode } from '@core/page-tree' @@ -188,29 +189,3 @@ function propChangeKind(moduleId: string, propKey: string): PageChangeKind { return resolvePropertyControlCategory(control) === 'content' ? 'content' : 'structure' } -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null) return a === b - if (typeof a !== typeof b) return false - if (typeof a !== 'object') return false - if (Array.isArray(a)) { - if (!Array.isArray(b)) return false - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false - } - return true - } - if (Array.isArray(b)) return false - - const aKeys = Object.keys(a as Record) - const bKeys = Object.keys(b as Record) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, key)) return false - if (!deepEqual((a as Record)[key], (b as Record)[key])) { - return false - } - } - return true -} diff --git a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx index d35980577..942dc93c2 100644 --- a/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -180,6 +180,10 @@ export function PeerPresenceOverlay({ breakpointId, iframeElement }: PeerPresenc for (const [, ring] of ringRefs.current ?? []) hideOverlayElement(ring) for (const [, tag] of tagRefs.current ?? []) positionPointElement(tag, null) for (const [, dot] of pointerRefs.current ?? []) positionPointElement(dot, null) + // Carets + selection highlights must hide too, or a peer's text caret + // freezes on screen after the frame detaches (breakpoint swap / reload). + for (const [, caret] of caretRefs.current ?? []) hideOverlayElement(caret) + for (const [, highlights] of highlightRefs.current ?? []) syncHighlightRects(highlights, []) return } diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index f7b9fc389..feee10c88 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -28,6 +28,7 @@ import type { SiteDocument } from '@core/page-tree' import type { IPersistenceAdapter } from '@core/persistence/types' import { cmsAdapter } from '@core/persistence/cms' import { SiteValidationError } from '@core/persistence/validate' +import { getErrorMessage } from '@core/utils/errorMessage' import { readEditorSelectPreference } from '@site/preferences/editorPreferences' import type { CollabProvider } from '@site/collab/collabProvider' import { @@ -48,10 +49,6 @@ interface PersistenceController { saveStatus: PersistenceSaveStatus } -function errorMessage(err: unknown, fallback: string): string { - return err instanceof Error && err.message.trim() ? err.message : fallback -} - function currentEditorDataDeepLink(): { table: 'pages' | 'components'; rowId: string } | null { if (typeof window === 'undefined') return null const params = new URLSearchParams(window.location.search) @@ -146,7 +143,7 @@ export function usePersistence( console.warn('[persistence] Failed to load CMS site:', err) } if (!cancelled) { - setLoadState({ phase: 'error', message: errorMessage(err, 'Failed to load CMS site') }) + setLoadState({ phase: 'error', message: getErrorMessage(err, 'Failed to load CMS site') }) } return } @@ -165,7 +162,7 @@ export function usePersistence( if (!cancelled) setLoadState({ phase: 'ready' }) } catch (err) { if (!cancelled) { - setLoadState({ phase: 'error', message: errorMessage(err, 'Draft not saved yet') }) + setLoadState({ phase: 'error', message: getErrorMessage(err, 'Draft not saved yet') }) } } } diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 81c89d29c..83ff847b4 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -503,9 +503,9 @@ const providerBindings = new Map() function allDocIdsForSite(site: SiteDocument): string[] { return [ SITE_DOC_ID, - ...site.pages.map((p) => `page:${p.id}`), - ...site.visualComponents.map((vc) => `component:${vc.id}`), - ...site.layouts.map((l) => `layout:${l.id}`), + ...site.pages.map((p) => encodeCollabDocId({ kind: 'page', rowId: p.id })), + ...site.visualComponents.map((vc) => encodeCollabDocId({ kind: 'component', rowId: vc.id })), + ...site.layouts.map((l) => encodeCollabDocId({ kind: 'layout', rowId: l.id })), ] } @@ -546,19 +546,22 @@ function seedDetachedDocs(site: SiteDocument): void { seedSiteDoc(siteDoc, site) ensureManaged(SITE_DOC_ID, siteDoc) for (const page of site.pages) { - const doc = docs.ensure(`page:${page.id}`) + const docId = encodeCollabDocId({ kind: 'page', rowId: page.id }) + const doc = docs.ensure(docId) seedPageDoc(doc, page) - ensureManaged(`page:${page.id}`, doc) + ensureManaged(docId, doc) } for (const vc of site.visualComponents) { - const doc = docs.ensure(`component:${vc.id}`) + const docId = encodeCollabDocId({ kind: 'component', rowId: vc.id }) + const doc = docs.ensure(docId) seedComponentDoc(doc, vc) - ensureManaged(`component:${vc.id}`, doc) + ensureManaged(docId, doc) } for (const layout of site.layouts) { - const doc = docs.ensure(`layout:${layout.id}`) + const docId = encodeCollabDocId({ kind: 'layout', rowId: layout.id }) + const doc = docs.ensure(docId) seedLayoutDoc(doc, layout) - ensureManaged(`layout:${layout.id}`, doc) + ensureManaged(docId, doc) } } diff --git a/src/admin/spotlight/commands/editor.ts b/src/admin/spotlight/commands/editor.ts index 3114dac3b..c45450a48 100644 --- a/src/admin/spotlight/commands/editor.ts +++ b/src/admin/spotlight/commands/editor.ts @@ -1,12 +1,11 @@ /** - * Editor commands — Save, Publish, Undo, Redo. + * Editor commands — Publish, Undo, Redo. * §4.2 of the Command Spotlight master plan. * - * All commands are gated to workspace: ['site'] only. + * All commands are gated to workspace: ['site'] only. There is no Save + * command: the collab relay persists continuously, so there is nothing to + * flush from a keystroke. * Undo/redo use useEditorStore.getState() (Zustand getState is safe outside React). - * it rides the single-flight queue, the dirty-mark snapshot, and the - * conflict-detection base seqs — a raw adapter call here would bulldoze all - * three and ship a replace-mode full save. * Publish calls publishCmsDraft() from the persistence layer, wrapped in * `ctx.runStepUp` so the StepUpProvider's password re-entry dialog opens * when the server replies with `step_up_required` (publish is gated on a diff --git a/src/admin/spotlight/shortcutDispatch.ts b/src/admin/spotlight/shortcutDispatch.ts index df03e8819..5e593290a 100644 --- a/src/admin/spotlight/shortcutDispatch.ts +++ b/src/admin/spotlight/shortcutDispatch.ts @@ -7,7 +7,6 @@ const LAYER_TREE_SELECTOR = '[data-instatic-layer-tree="true"]' const COMPONENT_OWNED_SHORTCUTS = new Set([ 'spotlight.open', - 'editor.save', 'editor.undo', 'editor.redo', 'layers.delete', diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts index e5b5bcb85..2460b27e7 100644 --- a/src/core/collab/applyPatches.ts +++ b/src/core/collab/applyPatches.ts @@ -26,7 +26,8 @@ import type { Patches } from 'mutative' import type { BaseNode, Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' -import { dataMap, inlineTextPropOf, metaMap, rostersMap, shellMap, treeMap } from './schema' +import { encodeCollabDocId, SITE_DOC_ID } from './docIds' +import { dataMap, inlineTextPropOf, metaMap, rostersMap, SHELL_PER_ENTRY_KEYS, shellMap, treeMap } from './schema' import { buildBreakpointOverridesMap, buildNodeMap, buildPropsMap } from './nodeY' import { populateComponentDoc, populateLayoutDoc, populatePageDoc } from './seed' import { applyTextDiff } from './textDiff' @@ -39,7 +40,6 @@ const COLLECTION_KIND: Record = { visualComponents: 'component', layouts: 'layout', } -const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) const SHELL_SKIPPED_KEYS = new Set(['updatedAt', 'id']) type Row = Page | VisualComponent | SavedLayout @@ -196,7 +196,6 @@ function applyRowTargets( } else if (field === 'props') { const key = path[3] if (key === undefined) { - scalarFields.get(nodeId) // no-op read for lint symmetry propKeys.set(nodeId, propKeys.get(nodeId) ?? new Set()) propKeys.get(nodeId)!.add('*') } else { @@ -248,14 +247,10 @@ function applyRowTargets( yNodes.delete(nodeId) continue } - const preNode = preTree?.nodes[nodeId] - if (preNode && yNodes.has(nodeId)) { - // Whole-node replace of an EXISTING node (paste-over, template ops): - // repopulate that node only. - yNodes.set(nodeId, buildNodeMap(nextNode)) - } else { - yNodes.set(nodeId, buildNodeMap(nextNode)) - } + // Whole-node write (insert, paste-over, template op): (re)build the node + // map from scratch — this is correct whether the node existed before or + // not, so there's no need to branch on its prior presence. + yNodes.set(nodeId, buildNodeMap(nextNode)) } const nodeYMap = (nodeId: string): Y.Map | undefined => { @@ -388,8 +383,8 @@ export function applySitePatchesToDocs( } if (shellHeads.size > 0 || shellEntryTargets.size > 0 || collectionsWithMembershipOps.length > 0) { - const siteDoc = docs.ensure('site:default') - touch('site:default') + const siteDoc = docs.ensure(SITE_DOC_ID) + touch(SITE_DOC_ID) siteDoc.transact(() => { const shell = shellMap(siteDoc) for (const head of shellHeads) { @@ -436,8 +431,8 @@ export function applySitePatchesToDocs( // Client-created row: populate a fresh doc (single author — safe). const kind = COLLECTION_KIND[col] rosterWork.push(() => { - const rowDoc = docs.ensure(`${kind}:${id}`) - touch(`${kind}:${id}`) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) + touch(encodeCollabDocId({ kind, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, nextById.get(id)!), origin) }) } @@ -471,8 +466,8 @@ export function applySitePatchesToDocs( // Wholesale collection replacement (imports) → repopulate every row doc. if (colPatches.some((p) => patchPath(p).length === 1)) { for (const [id, row] of nextById) { - const rowDoc = docs.ensure(`${kind}:${id}`) - touch(`${kind}:${id}`) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) + touch(encodeCollabDocId({ kind, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row), origin) } continue @@ -493,8 +488,8 @@ export function applySitePatchesToDocs( const id = (row as Row).id if (rest.length === 0) { if (preById.get(id) !== nextById.get(id)) { - const rowDoc = docs.ensure(`${kind}:${id}`) - touch(`${kind}:${id}`) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) + touch(encodeCollabDocId({ kind, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row as Row), origin) } continue @@ -507,8 +502,8 @@ export function applySitePatchesToDocs( for (const [id, subPaths] of rowSubPaths) { const nextRow = nextById.get(id) if (!nextRow) continue - const rowDoc = docs.ensure(`${kind}:${id}`) - touch(`${kind}:${id}`) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) + touch(encodeCollabDocId({ kind, rowId: id })) if (kind === 'layout') { // Whole-snapshot LWW — any layout content change rewrites the snapshot. rowDoc.transact(() => repopulateRowDoc(rowDoc, 'layout', nextRow), origin) diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 2ae96c8ad..6ae75f2a5 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -16,10 +16,8 @@ export { } from './docIds' export { dataMap, - inlineTextPropOf, LOCAL_ORIGIN, metaMap, - RECONCILE_ORIGIN, REMOTE_ORIGIN, rostersMap, SEED_CLIENT_ID, @@ -27,7 +25,6 @@ export { shellMap, treeMap, } from './schema' -export { buildNodeMap, buildPropsMap, projectNodeMap } from './nodeY' export { reconcileTreeIntegrity } from './integrity' export { applyTextDiff } from './textDiff' export { diff --git a/src/core/collab/schema.ts b/src/core/collab/schema.ts index 2aedd3d86..30918c786 100644 --- a/src/core/collab/schema.ts +++ b/src/core/collab/schema.ts @@ -15,16 +15,16 @@ * who caused a change: * LOCAL_ORIGIN — this user's edits (undoable) * REMOTE_ORIGIN — updates applied from the wire (never undoable) - * RECONCILE_ORIGIN — derived corrections (slot sync, integrity) that run - * identically on every peer (never undoable) * SEED_ORIGIN — initial construction from persisted JSON + * + * (Integrity reconciles run as PURE JSON in the projection, never as tagged Y + * transactions — so there is no reconcile origin.) */ import * as Y from 'yjs' import { registry } from '@core/module-engine' export const LOCAL_ORIGIN = Symbol('collab-local') export const REMOTE_ORIGIN = 'collab-remote' -export const RECONCILE_ORIGIN = 'collab-reconcile' export const SEED_ORIGIN = 'collab-seed' /** Deterministic clientID for seeding — the server is the only seeder. */ @@ -36,6 +36,14 @@ export const dataMap = (doc: Y.Doc): Y.Map => doc.getMap('data export const shellMap = (doc: Y.Doc): Y.Map => doc.getMap('shell') export const rostersMap = (doc: Y.Doc): Y.Map => doc.getMap('rosters') +/** + * Shell fields stored as a per-ENTRY Y.Map (granular per-key merge) rather + * than one whole-value LWW slot. Owns the shell doc's layout, so seed + + * patch-translation must agree — kept here, the module that defines the + * Y-doc shape, and imported by both. + */ +export const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) + /** * The one prop of a module stored as Y.Text (character-level merge). * Resolved from the module registry — callers must have registered modules diff --git a/src/core/collab/seed.ts b/src/core/collab/seed.ts index 5f654a83d..9fa79e19a 100644 --- a/src/core/collab/seed.ts +++ b/src/core/collab/seed.ts @@ -12,7 +12,7 @@ import * as Y from 'yjs' import type { Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' -import { dataMap, metaMap, rostersMap, SEED_CLIENT_ID, SEED_ORIGIN, shellMap, treeMap } from './schema' +import { dataMap, metaMap, rostersMap, SEED_CLIENT_ID, SEED_ORIGIN, SHELL_PER_ENTRY_KEYS, shellMap, treeMap } from './schema' import { buildNodeMap } from './nodeY' function seeding(doc: Y.Doc, fn: () => void): void { @@ -89,7 +89,6 @@ export function seedLayoutDoc(doc: Y.Doc, layout: SavedLayout): void { } /** Shell keys stored as per-entry Y.Maps (granular co-editing); the rest are plain LWW values. */ -const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer']) const SHELL_SKIPPED_KEYS = new Set(['pages', 'visualComponents', 'layouts', 'id', 'updatedAt']) export interface SiteDocRosterIds { From 2722c7272d6f784c0d84709291dee7d94541b74f Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 22:42:57 +0200 Subject: [PATCH 31/49] fix(publisher): one malformed style rule no longer blanks the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bagToDeclarations did `Object.entries(bag)` on the rule's `styles` bag, which threw on a non-object bag and took the entire ClassStyleInjector / canvas down with a white screen. A corrupt or legacy rule (bad import, plugin write, older data — e.g. a packageJson-shaped object wrongly stored under a style-rule key) must degrade to emitting nothing, not crash the whole stylesheet. Guard the shared serializer core (base styles, context styles, and inline styles all funnel through it): a non-object bag yields no declarations, every other rule still renders and publishes. Co-Authored-By: Claude Fable 5 --- .../publisher/classStyleInjector.test.ts | 18 ++++++++++++++++++ src/core/publisher/classCss.ts | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/src/__tests__/publisher/classStyleInjector.test.ts b/src/__tests__/publisher/classStyleInjector.test.ts index a33efc0af..7758418d7 100644 --- a/src/__tests__/publisher/classStyleInjector.test.ts +++ b/src/__tests__/publisher/classStyleInjector.test.ts @@ -84,6 +84,24 @@ describe('bagToCSS', () => { expect(css).not.toContain('display: block !important') }) + it('emits nothing for a non-object bag instead of throwing (corrupt rule resilience)', () => { + // A malformed persisted rule can carry a non-object `styles` (bad import, + // plugin write, older data). `Object.entries(null)` would throw and blank + // the whole canvas — one bad rule must degrade to empty, not crash. + expect(bagToCSS(undefined as never)).toBe('') + expect(bagToCSS(null as never)).toBe('') + expect(bagToCSS(42 as never)).toBe('') + }) + + it('one malformed rule does not stop the rest of the stylesheet emitting', () => { + const good = makeClass('good', { color: 'red' }) + // A packageJson-shaped object wrongly stored as a style rule: no `styles`. + const corrupt = { dependencies: {}, devDependencies: {} } as unknown as StyleRule + const css = generateClassCSS({ corrupt, good }, [], []) + expect(css).toContain('color: red') + expect(css).not.toContain('dependencies') + }) + it('converts camelCase to kebab-case', () => { const css = bagToCSS({ backgroundColor: '#fff', borderTopLeftRadius: '4px' }) expect(css).toContain('background-color: #fff;') diff --git a/src/core/publisher/classCss.ts b/src/core/publisher/classCss.ts index fe197bbf7..87321ea25 100644 --- a/src/core/publisher/classCss.ts +++ b/src/core/publisher/classCss.ts @@ -158,6 +158,11 @@ function bagToDeclarations( priorities: CSSDeclarationPriorityBag = {}, ): Array<[string, string, boolean]> { const decls: Array<[string, string, boolean]> = [] + // The bag is the wide persistence type: a corrupt/legacy rule (bad import, + // plugin write, older data) can carry a non-object `styles`/context bag, and + // `Object.entries(null)` throws. One malformed rule must NOT blank the whole + // canvas — emit nothing for it and let every other rule render. + if (bag === null || typeof bag !== 'object') return decls // Track which prefixes have already been emitted as a collapsed shorthand // so we skip the remaining three side properties for that prefix. const collapsedPrefixes = new Set() From aac542aa68a15d61ea5d74f844182d261797be20 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 3 Jul 2026 23:04:03 +0200 Subject: [PATCH 32/49] fix(collab): validate the projected site shell before it enters the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site-doc projection spread `projected.shell as Partial` into the store unvalidated — an `as` cast at a wire boundary. A malformed styleRules entry in the live doc (a runtime/packageJson-shaped value stored under a rule key) crashed the SelectorsPanel/ClassPicker on `.name` access the moment an element was selected, and the corruption could never self-heal because only the relay's persist path ran validateSite. Run the projection through validateSite exactly like the HTTP load path and the relay's persist path — one validation vocabulary at all three shell boundaries. validateSite is per-entry tolerant (parseStyleRuleRegistry drops malformed rules, keeps the rest), so one corrupt entry degrades to being dropped instead of taking the editor down. An incoherent mid-sync shell skips the projection tick; the next projection re-runs. Test: seeds a shell, injects a packageJson-shaped object under a style-rule key in the Y map, asserts the projection is faithful but validateSite drops exactly the bad entry while the good rule survives. Co-Authored-By: Claude Fable 5 --- src/__tests__/collab/seedProject.test.ts | 31 +++++++++++++++++++ .../site/store/slices/site/collabBinding.ts | 25 +++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/__tests__/collab/seedProject.test.ts b/src/__tests__/collab/seedProject.test.ts index c409d5e73..e93fb0f1a 100644 --- a/src/__tests__/collab/seedProject.test.ts +++ b/src/__tests__/collab/seedProject.test.ts @@ -23,9 +23,11 @@ import { seedLayoutDoc, seedPageDoc, seedSiteDoc, + shellMap, treeMap, } from '@core/collab' import type { BaseNode } from '@core/page-tree' +import { validateSite } from '@core/persistence/validate' import { makeNode, makePage, makeSite, makeVC } from '../fixtures' function fixturePage() { @@ -150,6 +152,35 @@ describe('site doc (shell + rosters)', () => { expect(projected.rosters.components).toEqual(['vc1']) }) + it('validating the projected shell drops a malformed style rule instead of crashing', () => { + // A whole-shell-field value (packageJson / runtime) can end up wrongly + // stored under a style-rule key. The client adopts the projection through + // validateSite (same as the HTTP load path), which must drop the bad entry + // rather than reject the whole shell — otherwise a downstream panel that + // reads `rule.name`/`rule.styles` crashes the editor. + const site = makeSite({ + styleRules: { + good: { + id: 'good', name: 'hero', kind: 'class', selector: '.hero', order: 0, + styles: { color: 'red' }, contextStyles: {}, createdAt: 1, updatedAt: 1, + }, + }, + }) + const doc = new Y.Doc() + seedSiteDoc(doc, site) + // Inject a packageJson-shaped object under a nanoid style-rule key. + doc.transact(() => { + const styleRules = shellMap(doc).get('styleRules') as Y.Map + styleRules.set('bad', { dependencies: { '@x/y': '1.0.0' }, devDependencies: {} }) + }) + const projected = projectSiteDoc(doc) + expect('bad' in projected.shell.styleRules).toBe(true) // projection is faithful + + const shell = validateSite({ ...projected.shell, id: 'default', updatedAt: 0 }) + expect(shell.styleRules.good.selector).toBe('.hero') // good rule survives + expect('bad' in shell.styleRules).toBe(false) // malformed rule dropped + }) + it('reconciles roster order against membership (dedupe, append missing, drop deleted)', () => { const site = makeSite({ pages: [makePage({ id: 'a', slug: 'index' }), makePage({ id: 'b', slug: 'b' })], diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 83ff847b4..724bcc0c3 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -32,7 +32,7 @@ import * as Y from 'yjs' import type { Patches } from 'mutative' import type { StoreApi } from 'zustand' -import type { Page, PageNode, SiteDocument } from '@core/page-tree' +import type { Page, PageNode, SiteDocument, SiteShell } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' import { @@ -60,6 +60,7 @@ import { } from '@core/collab' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' +import { validateSite } from '@core/persistence/validate' import type { EditorStore } from '@site/store/types' import type { Awareness } from 'y-protocols/awareness' import type { CollabProvider } from '@site/collab/collabProvider' @@ -403,6 +404,26 @@ function projectDocIntoStore(docId: string): void { if (!doc) return const projected = projectSiteDoc(doc) if (Object.keys(projected.shell).length === 0) return + // The projected shell is untyped wire data — validate it before it enters + // the store, exactly like the HTTP load path (validateSite) and the relay's + // persist path both do. `validateSite` is tolerant of individual malformed + // entries (drops bad style rules / conditions / files rather than + // rejecting the whole shell), so one corrupt rule from any source can't + // crash a panel. `id`/`updatedAt` are non-collaborative — inject them like + // the persist path. If the shell is not yet coherent (mid-sync), skip this + // tick; the next projection re-runs once it is. + let shell: SiteShell + try { + shell = validateSite({ + ...projected.shell, + id: 'default', + updatedAt: + typeof projected.shell.updatedAt === 'number' ? projected.shell.updatedAt : Date.now(), + }) + } catch (err) { + console.warn('[collabBinding] projected shell failed validation — projection skipped:', err) + return + } const byId = { pages: new Map(site.pages.map((p) => [p.id, p])), components: new Map(site.visualComponents.map((vc) => [vc.id, vc])), @@ -434,7 +455,7 @@ function projectDocIntoStore(docId: string): void { } const nextSite: SiteDocument = { ...site, - ...(projected.shell as Partial), + ...shell, pages: assemble(projected.rosters.pages, byId.pages, 'page'), visualComponents: assemble(projected.rosters.components, byId.components, 'component'), layouts: assemble(projected.rosters.layouts, byId.layouts, 'layout'), From 386998773861611adf7b575bd392b26d6b444272 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sun, 12 Jul 2026 10:53:07 +0200 Subject: [PATCH 33/49] fix(collab): prune canvas selection through the projection path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collab projection carried its own hand-rolled selection cleanup — a weaker duplicate of `pruneCanvasSelectionDraft`. It only inspected the `selectedNodeId` anchor, so a multi-selection kept phantom ids for deleted nodes and lost its survivors wholesale when the anchor died; it never closed an inline-edit session whose node had vanished (a peer deleting the node you were typing in left a dangling session); and the site/roster branch pruned nothing at all when a whole page disappeared. The duplicate existed because `initCollabBinding` typed the store as a bare `StoreApi`, which erases the zustand-mutative augmentation of `setState`. Draft-mutating helpers were therefore uncallable and a subset of one got reimplemented inline as a partial patch. The same missing type had already forced a cast in the plugin runtime. Type the store api once (`EditorStoreApi`, via zustand's `Mutate` with the `zustand/mutative` mutator) and the workarounds delete themselves: - collabBinding: all three projection exits (site/roster, row-removed, row-updated) now call `pruneCanvasSelectionDraft`. One implementation serves local deletes, undo/redo, and remote peer edits alike. - plugins/runtime: both casts and the comment excusing them are gone. Selection is still not undoable — it is reconciled. Documented in docs/reference/editor-history.md, which previously left that contract out. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/editor-history.md | 9 +++ src/__tests__/editor-store/undo-redo.test.ts | 43 +++++++++++++ .../site/store/slices/site/collabBinding.ts | 64 +++++++++---------- src/admin/pages/site/store/types.ts | 20 ++++-- src/core/plugins/runtime.ts | 22 ++----- 5 files changed, 104 insertions(+), 54 deletions(-) diff --git a/docs/reference/editor-history.md b/docs/reference/editor-history.md index d2b11a12f..c8713c715 100644 --- a/docs/reference/editor-history.md +++ b/docs/reference/editor-history.md @@ -59,6 +59,15 @@ mode every doc rebinds through the provider and the server seeds it. Editor-local state (selection, zoom, panel visibility) is not undoable — only document content flows through the docs. +It is, however, **reconciled**. A projection can remove nodes the editor is +still pointing at — an undo reverting an insertion, or a peer deleting the +subtree you had selected. The projection path therefore runs +`pruneCanvasSelectionDraft` after the new site lands, exactly as a local +`deleteNode` does: selections are pruned by tree-membership (survivors keep +theirs, the anchor re-syncs, descendants swept with a subtree drop out), and +an inline-edit session whose node vanished is closed. This is why selection +state has one pruning implementation rather than one per write path. + ## Key files - `src/admin/pages/site/store/slices/site/collabBinding.ts` — undo managers, diff --git a/src/__tests__/editor-store/undo-redo.test.ts b/src/__tests__/editor-store/undo-redo.test.ts index 0edd698d1..a9007b58d 100644 --- a/src/__tests__/editor-store/undo-redo.test.ts +++ b/src/__tests__/editor-store/undo-redo.test.ts @@ -9,6 +9,8 @@ */ import { describe, it, expect, beforeEach } from 'bun:test' import { useEditorStore } from '@site/store/store' +// `startInlineEdit` resolves its `inlineTextEdit` spec from the module registry. +import '@modules/base/text' // Helper: get fresh store state (Zustand is module-singleton — reset between tests) function getStore() { @@ -99,6 +101,47 @@ describe('Undo / Redo — basic lifecycle', () => { expect(useEditorStore.getState().site!.pages[0].nodes[nextId]).toBeDefined() }) + it('undo keeps surviving selections and only drops the reverted node', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const survivorId = useEditorStore.getState().insertNode('base.text', {}, rootId) + const revertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().selectNode(survivorId) + useEditorStore.getState().addToSelection(revertedId) + expect(useEditorStore.getState().selectedNodeIds).toEqual([survivorId, revertedId]) + + // Reverts only the second insertion. + useEditorStore.getState().undo() + + const afterUndo = useEditorStore.getState() + expect(afterUndo.site!.pages[0].nodes[revertedId]).toBeUndefined() + expect(afterUndo.site!.pages[0].nodes[survivorId]).toBeDefined() + // The survivor keeps its selection; the anchor re-syncs to it rather than + // the whole multi-selection being cleared. + expect(afterUndo.selectedNodeIds).toEqual([survivorId]) + expect(afterUndo.selectedNodeId).toBe(survivorId) + }) + + it('undo closes an inline-edit session on the node it reverts', () => { + const site = getStore().createSite('Test SiteDocument') + const rootId = site.pages[0].rootNodeId + const breakpointId = site.breakpoints[0]!.id + const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId) + + useEditorStore.getState().startInlineEdit(insertedId, breakpointId) + expect(useEditorStore.getState().activeInlineEdit?.nodeId).toBe(insertedId) + + useEditorStore.getState().undo() + + const afterUndo = useEditorStore.getState() + expect(afterUndo.site!.pages[0].nodes[insertedId]).toBeUndefined() + // A session pointing at a node that no longer exists must not survive — + // it is not necessarily part of the selection, so it is pruned by + // tree-membership. + expect(afterUndo.activeInlineEdit).toBeNull() + }) + it('redo prunes selection when replaying a deletion', () => { const site = getStore().createSite('Test SiteDocument') const rootId = site.pages[0].rootNodeId diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 724bcc0c3..55b7c50d0 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -31,8 +31,7 @@ */ import * as Y from 'yjs' import type { Patches } from 'mutative' -import type { StoreApi } from 'zustand' -import type { Page, PageNode, SiteDocument, SiteShell } from '@core/page-tree' +import type { Page, SiteDocument, SiteShell } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' import { @@ -61,7 +60,8 @@ import { import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' import { validateSite } from '@core/persistence/validate' -import type { EditorStore } from '@site/store/types' +import type { EditorStoreApi } from '@site/store/types' +import { pruneCanvasSelectionDraft } from '../selectionSlice' import type { Awareness } from 'y-protocols/awareness' import type { CollabProvider } from '@site/collab/collabProvider' @@ -71,7 +71,7 @@ interface ManagedDoc { detach: () => void } -let storeApi: StoreApi | null = null +let storeApi: EditorStoreApi | null = null let docs: CollabDocSet = createCollabDocSet() let managed = new Map() /** @@ -99,7 +99,7 @@ let projectionFlushScheduled = false let alignedSiteRef: SiteDocument | null = null /** Called once by store creation — the binding's only handle into Zustand. */ -export function initCollabBinding(api: StoreApi): void { +export function initCollabBinding(api: EditorStoreApi): void { storeApi = api } @@ -465,13 +465,17 @@ function projectDocIntoStore(docId: string): void { const siteRuntime = cloneSiteRuntimeConfig(nextSite.runtime) const alignedSite = { ...nextSite, packageJson, runtime: siteRuntime } alignedSiteRef = alignedSite - api.setState({ - site: alignedSite, - packageJson, - siteRuntime, - ...(nextSite.pages.some((p) => p.id === state.activePageId) - ? {} - : { activePageId: nextSite.pages[0]?.id ?? null }), + api.setState((draft) => { + draft.site = alignedSite + draft.packageJson = packageJson + draft.siteRuntime = siteRuntime + if (!nextSite.pages.some((p) => p.id === draft.activePageId)) { + draft.activePageId = nextSite.pages[0]?.id ?? null + } + // A roster change can drop the whole document the selection lives in (a + // peer deleted the page, or an undo removed it). Prune AFTER site + + // activePageId land, since the pruner resolves the active tree from them. + pruneCanvasSelectionDraft(draft) }) return } @@ -486,33 +490,25 @@ function projectDocIntoStore(docId: string): void { const nextRows = rows.filter((r) => r.id !== parsed.rowId) const nextSite = { ...site, [collection]: nextRows } as SiteDocument alignedSiteRef = nextSite - api.setState({ site: nextSite }) + api.setState((draft) => { + draft.site = nextSite + pruneCanvasSelectionDraft(draft) + }) return } const nextRows = index === -1 ? [...rows, row] : rows.map((r, i) => (i === index ? row : r)) const nextSite = { ...site, [collection]: nextRows } as SiteDocument alignedSiteRef = nextSite - const patch: Partial = { - site: nextSite, - } - // Selection can only reference the ACTIVE tree — clear it when its node - // vanished from the freshly projected row. - if (state.selectedNodeId) { - const activeTree: Record | undefined = - state.activeDocument?.kind === 'visualComponent' - ? state.activeDocument.vcId === parsed.rowId - ? ((row as VisualComponent).tree.nodes as Record) - : undefined - : state.activePageId === parsed.rowId - ? ((row as Page).nodes as Record) - : undefined - if (activeTree && !activeTree[state.selectedNodeId]) { - patch.selectedNodeId = null - patch.selectedNodeIds = [] - patch.hoveredNodeId = null - } - } - api.setState(patch) + api.setState((draft) => { + draft.site = nextSite + // The freshly projected row may have lost nodes — a peer deleted them, or a + // Y.UndoManager undo reverted their creation. Prune by tree-membership, the + // same way a local delete does: survivors keep their selection, dead ids + // (including descendants swept with a subtree) drop out, and an inline-edit + // session on a vanished node is closed. `pruneCanvasSelectionDraft` reads + // the ACTIVE tree, so it self-limits to the doc the user is looking at. + pruneCanvasSelectionDraft(draft) + }) } // --------------------------------------------------------------------------- diff --git a/src/admin/pages/site/store/types.ts b/src/admin/pages/site/store/types.ts index 265f2c494..dcf6ed945 100644 --- a/src/admin/pages/site/store/types.ts +++ b/src/admin/pages/site/store/types.ts @@ -55,14 +55,26 @@ export interface EditorStore {} * * IMPORTANT: never call `create()`/`produce()` manually inside `set()`. The * middleware already does. Manual nesting yields revoked proxies in subscribers. - * (The patch-based undo history is the one deliberate exception — it calls - * mutative `create(get().site, …, { enablePatches: true })` on a plain snapshot, - * NOT on a live draft, then assigns the result via `set`.) + * (`runHistoricMutation` is the one deliberate exception — it calls mutative + * `create(get(), …, { enablePatches: true })` on a plain snapshot, NOT on a live + * draft, to harvest the patches that feed the collab write path.) */ -import type { StateCreator } from 'zustand' +import type { Mutate, StateCreator, StoreApi } from 'zustand' export type EditorStoreSliceCreator = StateCreator< EditorStore, [['zustand/mutative', never]], [], T > + +/** + * The editor store's api as the mutative middleware actually augments it: its + * `setState` accepts a void-returning draft recipe, not just a partial. + * + * Anything that holds the store by reference (the collab binding, the plugin + * runtime) must type it with this alias. A bare `StoreApi` erases + * the augmentation, which forces callers to either cast or hand-roll partial + * patches — and a hand-rolled patch cannot reuse the draft-mutating helpers the + * slices share, so state invariants get reimplemented and drift. + */ +export type EditorStoreApi = Mutate, [['zustand/mutative', never]]> diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts index bb382a72e..8f602c345 100644 --- a/src/core/plugins/runtime.ts +++ b/src/core/plugins/runtime.ts @@ -1,5 +1,4 @@ -import type { StoreApi } from 'zustand' -import type { EditorStore } from '@site/store/types' +import type { EditorStoreApi } from '@site/store/types' import { createCmsPluginResourceRecord, deleteCmsPluginResourceRecord, @@ -29,7 +28,7 @@ import { * only valid inside the editor canvas (Site / Content / Data / Media * pages). */ -let editorStoreApi: StoreApi | null = null +let editorStoreApi: EditorStoreApi | null = null /** * Wire the editor store into the plugin runtime so granted plugins can call @@ -42,11 +41,11 @@ let editorStoreApi: StoreApi | null = null * `settingsSlice.ts` — both used to be exported as `bindEditorStoreApi` * and required call-site aliases to disambiguate. */ -export function bindPluginRuntimeStoreApi(api: StoreApi): void { - editorStoreApi = api as StoreApi +export function bindPluginRuntimeStoreApi(api: EditorStoreApi): void { + editorStoreApi = api } -function requireEditorStore(): StoreApi { +function requireEditorStore(): EditorStoreApi { if (!editorStoreApi) { throw new Error( '[plugin-runtime] editor store accessed before initialization. ' + @@ -503,16 +502,7 @@ export function createEditorPluginApi( // full via `editor.store.read`. Operators gate the capability at // install time; that single grant is the trust boundary. assertPluginPermission(manifest, 'editor.store.write') - // The underlying editor store is created with the mutative middleware - // (see `useEditorStore` in `@site/store/store`), so `setState` - // accepts a void-returning mutator that mutates the draft in place. - // The bare `StoreApi` type can't see the mutative-augmented - // signature, hence the cast — runtime behavior is fully covered by - // mutative's `create`. - const setState = requireEditorStore().setState as ( - updater: (state: EditorStore) => void, - ) => void - setState((state) => { + requireEditorStore().setState((state) => { mutate(state) }) }, From 25ccc5838c5c0e414c7a6f5f25f737ffce477667 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sun, 12 Jul 2026 11:57:41 +0200 Subject: [PATCH 34/49] test(content): make the posts-rows mock stateful so create tests stop racing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared ContentPage fetch mock answered every `GET /tables/posts/rows` with an empty list — even after a `POST` had created `entry_1`. That is not a shape a real server can produce, and it made the whole file's create-then-assert tests depend on request ordering. `useContentWorkspace.loadEntries` treats a list response as authoritative unless the selected row changed *during* that request. That is correct: a GET issued after a POST returns the new row. But when scheduling happened to issue the initial list GET after the create, the mock's bogus empty list wiped the just-created row, and `findByText('Untitled')` failed. Which request won that race was luck — main sits on the winning side of it, so the flake never surfaced there. Model the endpoint instead: rows created by POST are visible to later GETs, and a DELETE removes them. Both orderings now hold. Verified: full suite 5/5 green (previously failed ~40% of runs). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/data/contentAdmin.test.tsx | 40 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index 73516e019..dac0d1ea8 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -326,6 +326,27 @@ beforeEach(() => { const calls: FetchCall[] = [] ;(globalThis as typeof globalThis & { __contentFetchCalls?: FetchCall[] }).__contentFetchCalls = calls + + // The posts-rows endpoint is STATEFUL, like the real server: a row created by + // POST is visible to every later GET, and a deleted row is gone from it. + // + // A stateless `rows: []` made this mock lie about the one ordering the app is + // allowed to produce. The workspace treats a list response as authoritative + // unless the selected row changed *during* that request (see + // useContentWorkspace.loadEntries) — correct, because a real GET issued after + // a POST returns the new row. But the mock returned an empty list forever, so + // whenever the initial list GET happened to be issued after the create, its + // (bogus) empty response wiped the new row and the test failed. Which request + // won that race was pure scheduling luck, making every create-then-assert test + // in this file order-dependent. + let postsRows: ReturnType[] = [] + const putRow = (row: ReturnType) => { + const i = postsRows.findIndex((r) => r.id === row.id) + if (i === -1) postsRows.push(row) + else postsRows[i] = row + return row + } + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }) const url = String(input) @@ -337,7 +358,7 @@ beforeEach(() => { } if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'GET') { - return json({ rows: [] }) + return json({ rows: postsRows }) } if (url === '/admin/api/cms/data/authors' && init?.method === 'GET') { @@ -346,24 +367,25 @@ beforeEach(() => { if (url === '/admin/api/cms/data/tables/posts/rows' && init?.method === 'POST') { return json({ - row: makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { + row: putRow(makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { authorUserId: ownerAuthor.id, author: ownerAuthor, - }), + })), }, 201) } if (url === '/admin/api/cms/data/rows/entry_1' && init?.method === 'PATCH') { const draft = JSON.parse(String(init.body)) return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', draft.cells ?? {}), updatedAt: '2026-05-01T10:01:00.000Z', - }, + }), }) } if (url === '/admin/api/cms/data/rows/entry_1' && init?.method === 'DELETE') { + postsRows = postsRows.filter((r) => r.id !== 'entry_1') return json({ row: makeRow('entry_1', 'posts', { title: 'Untitled', slug: 'untitled' }, { authorUserId: ownerAuthor.id, @@ -375,23 +397,23 @@ beforeEach(() => { if (url === '/admin/api/cms/data/rows/entry_1/publish' && init?.method === 'POST') { return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'untitled', body: '## Intro', featuredMedia: null, seoTitle: '', seoDescription: '' }), status: 'published', updatedAt: '2026-05-01T10:02:00.000Z', publishedAt: '2026-05-01T10:02:00.000Z', - }, + }), }) } if (url === '/admin/api/cms/data/rows/entry_1/status' && init?.method === 'PATCH') { const body = JSON.parse(String(init.body)) return json({ - row: { + row: putRow({ ...makeRow('entry_1', 'posts', { title: 'My first post', slug: 'updated-slug', body: '', featuredMedia: imageAsset.id, seoTitle: '', seoDescription: '' }), status: body.status, updatedAt: '2026-05-01T10:03:00.000Z', - }, + }), }) } From e092ec02305975af0d4f127c6db9ebe889be9638 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sun, 12 Jul 2026 14:21:56 +0200 Subject: [PATCH 35/49] fix(collab): stop logging routine presence timeouts as identity spoofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `awarenessUpdateFromSession` returned one boolean for two unrelated outcomes, so the socket logged every refusal as `dropped awareness frame with spoofed identity`. Most of those are not attacks: every y-protocols client runs `checkOutdatedAwarenessStates` and broadcasts a removal for any peer whose heartbeat it stopped hearing, so clients routinely try to clear clientIDs they do not own. A live three-admin session produced four of these warnings in a couple of minutes — a production log that cries wolf teaches operators to ignore the one warning that matters. Replace the boolean with a typed `AwarenessRefusal` ('impersonation' | 'foreignClear' | 'malformed'). All three are still refused — a peer must never erase another's presence for everyone — but only the two that mean something are logged. Also gate the two collab behaviours the live smoke exercised and the suite did not cover at all: - a peer cannot erase another peer's presence for everyone (the foreignClear branch had zero coverage; deleting the guard was silent), - `runPublishFlush` drains the persist debounce, so publish bakes the edit an admin made seconds earlier instead of losing it to the debounce window. Both are mutation-checked: removing the guard fails the first, removing the flush call fails the second. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/collab/socket.ts | 58 ++++--- .../server/collabRelayIntegration.test.ts | 150 +++++++++++++++++- 2 files changed, 189 insertions(+), 19 deletions(-) diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 1631d2d76..2152cfaa9 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -87,19 +87,36 @@ const PresenceUserSchema = Type.Object( ) /** - * A client may only publish presence that matches ITS OWN session, and may - * only clear clientIDs it contributed. Decode the awareness update (varUint - * count, then per client: varUint id, varUint clock, varString stateJSON) and: - * - reject any non-null state whose full identity (id + name + avatar + - * gravatar) differs from the session — pinning id alone let a peer keep - * its own id but paint another admin's name/avatar in every UI; - * - reject a `null` (clear) for a clientID this connection never announced — - * otherwise any peer could erase another's presence for everyone. + * Why a client's awareness frame was refused — the relay drops all three, but + * only two of them mean anything. + * + * `foreignClear` is ROUTINE, not an attack. Every y-protocols client runs + * `checkOutdatedAwarenessStates` and broadcasts a removal for any peer whose + * heartbeat it stopped hearing, so clients constantly try to clear clientIDs + * they do not own. The relay refuses — presence is cleared only by its owner or + * by that owner's disconnect, or one slow peer could erase another for everyone + * — but this is expected protocol chatter and must never be logged as a + * security event. + * + * `impersonation` and `malformed` are the ones worth hearing about. */ -function awarenessUpdateFromSession( +type AwarenessRefusal = 'impersonation' | 'foreignClear' | 'malformed' + +/** + * A client may only publish presence that matches ITS OWN session, and may only + * clear clientIDs it contributed. Decode the awareness update (varUint count, + * then per client: varUint id, varUint clock, varString stateJSON) and: + * - refuse any non-null state whose full identity (id + name + avatar + + * gravatar) differs from the session — pinning id alone let a peer keep its + * own id but paint another admin's name/avatar in every UI; + * - refuse a `null` (clear) for a clientID this connection never announced. + * + * Returns `null` when the frame is legitimate and may be applied. + */ +function reviewAwarenessUpdate( payload: Uint8Array, data: Pick, -): boolean { +): AwarenessRefusal | null { try { const decoder = decoding.createDecoder(payload) const count = decoding.readVarUint(decoder) @@ -108,11 +125,11 @@ function awarenessUpdateFromSession( decoding.readVarUint(decoder) // clock const raw = decoding.readVarString(decoder) if (raw === 'null') { - if (!data.awarenessClients.has(clientId)) return false + if (!data.awarenessClients.has(clientId)) return 'foreignClear' continue } const parsed = safeParseValue(PresenceUserSchema, JSON.parse(raw)) - if (!parsed.ok) return false + if (!parsed.ok) return 'malformed' const u = parsed.value.user if ( u.id !== data.identity.id || @@ -120,13 +137,13 @@ function awarenessUpdateFromSession( u.avatarUrl !== data.identity.avatarUrl || u.gravatarHash !== data.identity.gravatarHash ) { - return false + return 'impersonation' } } - return true + return null } catch { - // Malformed update — reject rather than relay garbage. - return false + // Undecodable update — refuse rather than relay garbage. + return 'malformed' } } @@ -235,8 +252,13 @@ export function createCollabSocketLayer(relay: CollabRelay) { if (frame.frameType === FRAME_AWARENESS) { // Presence is NOT a doc write — read-only viewers are visible peers. if (frame.payload.byteLength > MAX_AWARENESS_PAYLOAD_BYTES) return - if (!awarenessUpdateFromSession(frame.payload, ws.data)) { - console.warn(`[collab] dropped awareness frame with spoofed identity from ${ws.data.userId}`) + const refusal = reviewAwarenessUpdate(frame.payload, ws.data) + if (refusal !== null) { + // A foreign clear is ordinary y-protocols timeout chatter (see + // AwarenessRefusal) — drop it without crying wolf in the log. + if (refusal !== 'foreignClear') { + console.warn(`[collab] dropped ${refusal} awareness frame from ${ws.data.userId}`) + } return } // Track which clientIDs this connection contributes so its peers diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index 3ae122edf..8454dc46b 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -9,10 +9,14 @@ * - a read-only connection's update frames are ignored, * - an out-of-relay row write triggers the reset protocol, * - a client that missed edits while disconnected catches up on - * reconnect via Yjs state vectors. + * reconnect via Yjs state vectors, + * - one peer cannot erase another peer's presence for everyone, + * - `runPublishFlush` drains the persist debounce, so publish bakes the + * edit an admin made seconds earlier instead of losing it. */ import { afterEach, describe, expect, it } from 'bun:test' import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' import { LOCAL_ORIGIN, projectPageDoc, @@ -31,6 +35,7 @@ import { handleCollabSocketUpgrade, } from '../../../server/collab/socket' import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import { runPublishFlush } from '../../../server/publish/publishFlush' import { getDataRow, saveDataRowDraft } from '../../../server/repositories/data' import { findUserByEmail } from '../../../server/repositories/users' import { peerColor } from '@site/collab/awarenessState' @@ -362,4 +367,147 @@ describe('collab relay integration (real server, real sockets)', () => { // vector pulls exactly the missed delta into the SAME doc. await waitFor(() => nodeLabel(boundA.doc, rootId) === 'Edited while A was away', 8_000) }, 12_000) + + it('a peer cannot erase another peer\'s presence for everyone', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const identityOf = async (email: string) => { + const user = (await findUserByEmail(stack.harness.db, email))! + return { + id: user.id, + name: user.displayName, + color: peerColor(user.id), + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + } + } + + const evictorUser = await stack.harness.createRoleUser({ + name: 'Evictor', + slug: 'collab-presence-evictor', + capabilities: ['site.read'], + }) + const victimUser = await stack.harness.createRoleUser({ + name: 'Victim', + slug: 'collab-presence-victim', + capabilities: ['site.read'], + }) + + // A third peer is the judge: presence is a broadcast, so what matters is + // what OTHER admins still see — not what the evictor sees in its own tab. + const watcher = connectClient(stack) + const evictor = connectClient(stack, evictorUser.cookie) + const victim = connectClient(stack, victimUser.cookie) + watcher.bind(docId) + evictor.bind(docId) + victim.bind(docId) + + const evictorIdentity = await identityOf(evictorUser.email) + const victimIdentity = await identityOf(victimUser.email) + + victim.awareness.setLocalState({ user: victimIdentity }) + + const watcherSees = (userId: string): boolean => { + for (const [, state] of watcher.awareness.getStates()) { + const s = state as { user?: { id?: string } } + if (s.user?.id === userId) return true + } + return false + } + await waitFor(() => watcherSees(victimIdentity.id)) + + // The evictor broadcasts a removal for a clientID it does NOT own. This is + // exactly what y-protocols emits on its own when it believes a peer timed + // out (`checkOutdatedAwarenessStates`) — routine chatter, not an attack — + // but honouring it would let any peer evict any other peer for EVERYONE. + // The relay refuses: presence is cleared only by its owner, or by that + // owner's disconnect. + awarenessProtocol.removeAwarenessStates( + evictor.awareness, + [victim.awareness.clientID], + 'evict', + ) + + // Order the wire: this legit frame is sent AFTER the removal, so once the + // watcher sees the evictor, the relay has already decided the removal's + // fate. Without this the assertion below could pass simply by racing ahead + // of a clear that was about to land. + evictor.awareness.setLocalState({ user: evictorIdentity }) + await waitFor(() => watcherSees(evictorIdentity.id)) + + // The victim never re-announced — if the relay had honoured the foreign + // clear, they would be gone from the watcher's roster by now. + expect(watcherSees(victimIdentity.id)).toBe(true) + }) + + it('runPublishFlush persists edits still inside the relay debounce window', async () => { + // The publisher reads ROWS, not Y docs. Every publish path awaits + // `runPublishFlush()` first (see publishFlush.ts) so an edit made seconds + // before the click is baked instead of lost to the debounce. Without this + // seam the published HTML silently trails the editor. + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + // A debounce long enough that nothing persists on its own during the test: + // any row change we observe can ONLY have come from the explicit flush. + const relay = createCollabRelay(harness.db, { persistDebounceMs: 60_000 }) + cleanups.push(() => relay.destroy()) + const socketLayer = createCollabSocketLayer(relay) + + const server = Bun.serve({ + port: 0, + fetch: async (req, srv) => { + if (new URL(req.url).pathname === SITE_SOCKET_PATH) { + const rejection = await handleCollabSocketUpgrade(req, harness.db, srv) + if (rejection === null) return undefined + return rejection + } + return new Response('not found', { status: 404 }) + }, + websocket: socketLayer.handlers, + }) + socketLayer.setPublisher(server) + cleanups.push(() => server.stop(true)) + + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const homeId = rows[0].id + const stack: Stack = { + harness, + url: `ws://localhost:${server.port}${SITE_SOCKET_PATH}`, + cookie, + homeId, + } + + const client = connectClient(stack) + const bound = client.bind(`page:${homeId}`) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + + // A second client is the honest way to observe the SERVER's doc: once the + // edit reaches this peer, the relay has definitely applied it. Asserting on + // the editing client's own doc would pass before the frame ever left it. + const observer = connectClient(stack) + const boundObserver = observer.bind(`page:${homeId}`) + await boundObserver.whenSynced + + setNodeLabel(bound.doc, rootId, 'Edited seconds before publish') + await waitFor(() => nodeLabel(boundObserver.doc, rootId) === 'Edited seconds before publish') + + const labelInRow = async (): Promise => { + const row = await getDataRow(harness.db, homeId) + return pageFromRow(row!).nodes[rootId]?.label + } + + // The relay holds the edit in memory, but the ROW the publisher reads does + // not — the debounce has not elapsed. + expect(await labelInRow()).not.toBe('Edited seconds before publish') + + // This is what every publish path does before it reads rows. + await runPublishFlush() + + expect(await labelInRow()).toBe('Edited seconds before publish') + }) }) From 4a2fc0b555b2f02b0df4316a22c88f936f589e71 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 14:11:00 +0200 Subject: [PATCH 36/49] fix(dev): dial the collab socket at the CMS origin instead of the Vite ws proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The co-editing socket never connected under `bun run dev`: the toolbar sat on "Connecting" forever and, since this branch removes every other save path, each keystroke was silently discarded. Reconnect attempts then killed the dev server outright. Root cause is a runtime swap, not the collab code. Until #202 (fdfcb155), scripts/dev.ts launched Vite through `node_modules/.bin/vite`, whose `#!/usr/bin/env node` shebang ran it under Node, and the ws proxy worked — which is why this was hand-verified green on 2026-07-03. #202 introduced `viteCommand()`, which spawns `bun node_modules/vite/bin/vite.js` and bypasses the shebang, so Vite now runs inside Bun. Bun's `node:http` ClientRequest never emits 'upgrade', so the backend's 101 reaches Vite's proxy as an ordinary response and takes the non-upgrade fallback: the browser socket hangs in readyState 0 — never opening, never closing, so the provider's reconnect path is never even reached — and when that connection later ends, the proxy's `socket.destroySoon()` call (absent on Bun's socket) throws uncaught and takes down Vite and the CMS server together. Vite itself was never bumped; 8.0.10 has been pinned since the initial import. Fix: take Vite out of the WebSocket path entirely. - `collabSocketUrl` dials the CMS port directly under `import.meta.env.DEV`, preserving `location.hostname` and swapping only the port. The session cookie is SameSite=Lax and a handshake is not a top-level navigation, so a third-party handshake would drop it and 401 into an endless reconnect — ports don't affect that judgement but hosts do, and dev serves Vite on 127.0.0.1 while developers type localhost. Production stays same-origin. - The port is inlined from the same `process.env.PORT` the proxy target uses, so the two cannot drift, and no operator-facing env var is added. - scripts/dev.ts now passes PORT to the Vite child explicitly; it previously worked only because CMS_PORT's default matched vite.config.ts's. - `ws: true` is removed from the proxy, which structurally disarms the crash. Verified: 101 on the new dev target with a real session cookie and a localhost:5173 Origin; an upgrade against the old proxy path now leaves both dev processes alive; the define folds into the dev module as VITE_CMS_DEV_PORT. socketUrl.test.ts pins both branches, devWorkflow.test.ts gates `ws` forwarding against returning. bun test 6325 pass / 0 fail, bun run build, bun run lint. --- docs/features/site-shell.md | 13 ++++ scripts/dev.ts | 5 ++ src/__tests__/collab/socketUrl.test.ts | 78 +++++++++++++++++++ src/__tests__/devWorkflow.test.ts | 17 ++++ src/admin/pages/site/collab/collabProvider.ts | 10 ++- src/admin/pages/site/collab/socketUrl.ts | 47 +++++++++++ vite.config.ts | 24 +++++- 7 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/collab/socketUrl.test.ts create mode 100644 src/admin/pages/site/collab/socketUrl.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index bb641cc96..91fa72095 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -553,6 +553,19 @@ vectors pull exactly the missed delta. `usePersistence` HTTP-loads the document once for first paint, then connects the provider — edits gate on each doc's first sync so an unseeded doc can never receive local ops. +In production the socket is same-origin. Under `vite dev` it is NOT: the +socket dials the CMS port directly, bypassing the Vite proxy +(`src/admin/pages/site/collab/socketUrl.ts`). `scripts/vite.ts` runs Vite +inside Bun, and Bun's `node:http` ClientRequest never emits `'upgrade'`, so a +proxied 101 takes the non-upgrade fallback: the browser socket hangs in +`readyState 0` forever — never opening, never closing, so the provider's +reconnect path is never even reached — and when that connection later ends, +the proxy's `socket.destroySoon()` call (an API Bun's socket lacks) throws +uncaught and kills the whole dev process. Only the PORT is swapped; the +hostname is preserved, because the session cookie is `SameSite=Lax` and +`localhost` ↔ `127.0.0.1` is a cross-site handshake that would drop it. +`devWorkflow.test.ts` gates the proxy against re-enabling `ws` forwarding. + **Presence** (`src/admin/pages/site/collab/awarenessState.ts`; per-frame publishers in `collab/framePresencePublishers.ts`, rendering in `PeerPresenceOverlay`): every editor publishes identity (deterministic HSL diff --git a/scripts/dev.ts b/scripts/dev.ts index 505f8ae5a..974e4aaa1 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -253,6 +253,11 @@ const processes: DevProcess[] = [ { name: 'vite', command: viteCommand('--host', '127.0.0.1', '--port', String(VITE_PORT), '--strictPort'), + // vite.config.ts reads PORT for both the proxy target and the collab + // socket's dev port. Inheriting it from the developer's shell happened to + // work only because CMS_PORT's default matches the config's — pass it + // explicitly so the two can't drift. `scripts/e2e-dev.ts` already does. + env: { PORT: String(CMS_PORT) }, }, ] diff --git a/src/__tests__/collab/socketUrl.test.ts b/src/__tests__/collab/socketUrl.test.ts new file mode 100644 index 000000000..bfcd0463e --- /dev/null +++ b/src/__tests__/collab/socketUrl.test.ts @@ -0,0 +1,78 @@ +/** + * Where the collab WebSocket dials. + * + * The dev branch exists because Vite (running inside Bun) cannot forward + * WebSocket upgrades; the socket skips the proxy and dials the CMS port. + * The invariant that matters most is that only the PORT is swapped — the + * session cookie is `SameSite=Lax`, so rewriting `127.0.0.1` to `localhost` + * (or the reverse) would make the handshake cross-site, drop the cookie, and + * 401 into an endless reconnect. + */ +import { describe, expect, it } from 'bun:test' +import { collabSocketUrl } from '@site/collab/socketUrl' + +const PATH = '/admin/api/cms/site-socket' + +describe('collabSocketUrl', () => { + describe('production (same origin)', () => { + it('uses the page host verbatim', () => { + const url = collabSocketUrl( + { protocol: 'https:', host: 'cms.example.com', hostname: 'cms.example.com' }, + null, + ) + expect(url).toBe(`wss://cms.example.com${PATH}`) + }) + + it('keeps a non-default port that is part of the origin', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'box.internal:8080', hostname: 'box.internal' }, + null, + ) + expect(url).toBe(`ws://box.internal:8080${PATH}`) + }) + + it('downgrades to ws on a plain-http origin', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'cms.example.com', hostname: 'cms.example.com' }, + null, + ) + expect(url).toBe(`ws://cms.example.com${PATH}`) + }) + }) + + describe('vite dev (CMS port dialled directly)', () => { + it('swaps only the port, preserving a localhost page', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, + '3001', + ) + expect(url).toBe(`ws://localhost:3001${PATH}`) + }) + + // `bun run dev` serves Vite on 127.0.0.1; rewriting to a `localhost` + // literal here would make the handshake cross-site and drop the cookie. + it('swaps only the port, preserving a 127.0.0.1 page', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: '127.0.0.1:5173', hostname: '127.0.0.1' }, + '3001', + ) + expect(url).toBe(`ws://127.0.0.1:3001${PATH}`) + }) + + it('honours a non-default CMS port (e2e harness)', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: '127.0.0.1:5174', hostname: '127.0.0.1' }, + '3002', + ) + expect(url).toBe(`ws://127.0.0.1:3002${PATH}`) + }) + + it('never carries the Vite port through', () => { + const url = collabSocketUrl( + { protocol: 'http:', host: 'localhost:5173', hostname: 'localhost' }, + '3001', + ) + expect(url).not.toContain('5173') + }) + }) +}) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index d054a1969..bfd44dc00 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -91,6 +91,23 @@ describe('development workflow', () => { expect(viteConfig).toContain('changeOrigin: true') }) + it('Vite never forwards WebSocket upgrades, and the collab socket gets the CMS port instead', () => { + const viteConfig = readSiteFile('vite.config.ts') + const devScript = readSiteFile('scripts/dev.ts') + + // Vite runs inside Bun (scripts/vite.ts), and Bun's node:http client never + // emits 'upgrade'. Enabling `ws` forwarding makes the browser socket hang + // and then kills the dev process via `socket.destroySoon()`. The collab + // socket dials the CMS port directly instead — never re-enable this. + expect(viteConfig).not.toMatch(/^\s*ws:\s*true/m) + + // The dialled port must come from the same source as the proxy target so + // they cannot drift, and dev.ts must actually hand PORT to the Vite child + // (it previously relied on both defaults happening to be 3001). + expect(viteConfig).toContain("'import.meta.env.VITE_CMS_DEV_PORT': JSON.stringify(process.env.PORT ?? '3001')") + expect(devScript).toContain('env: { PORT: String(CMS_PORT) }') + }) + it('Vite forwards public page routes to the CMS server instead of the admin SPA', () => { const viteConfig = readSiteFile('vite.config.ts') diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index f98a685d5..b607c021a 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -35,8 +35,8 @@ import { FRAME_SYNC, PRESENCE_DOC_ID, REMOTE_ORIGIN, - SITE_SOCKET_PATH, } from '@core/collab' +import { collabSocketUrl } from './socketUrl' const RECONNECT_BASE_DELAY_MS = 1_000 const RECONNECT_MAX_DELAY_MS = 30_000 @@ -87,9 +87,13 @@ export function createCollabProvider( const createSocket = opts.createSocket ?? ((): CollabSocketLike => { - const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + // Under `vite dev` the socket bypasses the proxy and dials the CMS port + // directly — see socketUrl.ts for why, and why the hostname is kept. + const devCmsPort = import.meta.env.DEV + ? String(import.meta.env.VITE_CMS_DEV_PORT ?? '3001') + : null return new WebSocket( - `${protocol}://${window.location.host}${SITE_SOCKET_PATH}`, + collabSocketUrl(window.location, devCmsPort), ) as unknown as CollabSocketLike }) diff --git a/src/admin/pages/site/collab/socketUrl.ts b/src/admin/pages/site/collab/socketUrl.ts new file mode 100644 index 000000000..000ac2e3d --- /dev/null +++ b/src/admin/pages/site/collab/socketUrl.ts @@ -0,0 +1,47 @@ +/** + * Where the collab WebSocket dials. + * + * PRODUCTION is same-origin: the Bun server serves the admin bundle and the + * socket, so `window.location.host` is already correct. + * + * VITE DEV cannot be same-origin. `scripts/vite.ts` runs Vite inside Bun, and + * Bun's `node:http` ClientRequest never emits 'upgrade' — a 101 from the CMS + * reaches Vite's proxy as an ordinary response, takes the non-upgrade + * fallback, and the browser socket never completes (it hangs in + * `readyState 0` forever, so the provider never even reaches its reconnect + * path). When that connection later ends, the proxy calls + * `socket.destroySoon()`, which Bun's socket does not implement, and the + * uncaught TypeError kills the whole `bun run dev` process. So the socket + * skips Vite entirely and dials the CMS port directly. + * + * The HOSTNAME is deliberately preserved and only the PORT is swapped. The + * session cookie is `Path=/admin; HttpOnly; SameSite=Lax` and a WebSocket + * handshake is not a top-level navigation, so a handshake the browser judges + * to be third-party would drop the cookie and the upgrade would 401 into an + * endless reconnect. The port is not part of that judgement, but the host is + * — `localhost` and `127.0.0.1` count as different hosts, and `bun run dev` + * serves Vite on `127.0.0.1` while developers routinely type `localhost`. + * Rewriting the host to a literal would break exactly one of those two, + * intermittently. Keeping `location.hostname` is what makes both work. + */ +import { SITE_SOCKET_PATH } from '@core/collab' + +/** The subset of `window.location` the URL depends on. */ +export interface CollabSocketLocation { + protocol: string + host: string + hostname: string +} + +/** + * @param devCmsPort The CMS server's port when running under the Vite dev + * server, or `null` for the same-origin production case. + */ +export function collabSocketUrl( + location: CollabSocketLocation, + devCmsPort: string | null, +): string { + const scheme = location.protocol === 'https:' ? 'wss' : 'ws' + const authority = devCmsPort === null ? location.host : `${location.hostname}:${devCmsPort}` + return `${scheme}://${authority}${SITE_SOCKET_PATH}` +} diff --git a/vite.config.ts b/vite.config.ts index 3a108dba9..024e9a7bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -221,6 +221,14 @@ export default defineConfig({ }, }, }, + define: { + // The collab WebSocket dials the CMS port directly under `vite dev` + // (see the `server.proxy` note and src/admin/pages/site/collab/socketUrl.ts). + // Inlined from the same source as the proxy target so the two can never + // disagree, rather than introducing an operator-facing env var. Production + // builds are same-origin and never read it. + 'import.meta.env.VITE_CMS_DEV_PORT': JSON.stringify(process.env.PORT ?? '3001'), + }, server: { watch: { // Runtime-written paths: the publish pipeline bakes HTML into the uploads @@ -235,13 +243,21 @@ export default defineConfig({ // Bun backend. Agent endpoints live under `/admin/api/agent` (and // `/admin/api/agent/tool-result`) so the admin session cookie — // scoped to `Path=/admin` to keep it off the public site — actually - // accompanies the request. `ws: true` forwards the live-sync socket - // upgrade (`/admin/api/cms/site-socket`); the agent itself still - // streams NDJSON over standard HTTP responses. + // accompanies the request. The agent streams NDJSON over standard + // HTTP responses, so no upgrade forwarding is needed here. + // + // WebSocket forwarding is deliberately OFF. `scripts/vite.ts` runs + // Vite inside Bun, and Bun's `node:http` ClientRequest never emits + // 'upgrade' — so a 101 from the backend reaches Vite's proxy as an + // ordinary response, takes the non-upgrade fallback, and the browser + // socket never completes. When that connection later ends the proxy + // calls `socket.destroySoon()`, which Bun's socket does not + // implement, and the uncaught TypeError kills the whole dev process. + // The collab socket therefore dials the CMS port directly in dev — + // see `src/admin/pages/site/collab/socketUrl.ts`. '/admin/api': { target: CMS_DEV_SERVER_ORIGIN, changeOrigin: true, - ws: true, }, '/uploads': { target: CMS_DEV_SERVER_ORIGIN, From 6d337450954de9024b44e867fa2757c2a0030cc7 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 14:36:44 +0200 Subject: [PATCH 37/49] fix(collab): project owned data instead of live references into the Y doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site editor would not load once the collab socket actually connected: every projection threw and was skipped, so the store never updated and the canvas stayed on its loading skeleton. [collabBinding] projected shell failed validation — projection skipped: TypeError: Cannot assign to read only property 'tokens' of object Yjs stores plain JS values BY REFERENCE — `Y.Map.get()` returns the very object living inside the CRDT — and the projection handed those references straight out. The editor store and the doc therefore shared mutable state, which broke in both directions: - `validateSite` normalizes in place (`normalizeFrameworkColors` assigns `colors.tokens`, and `parseSiteSettings` passes `framework` through by reference), so merely PROJECTING a shell rewrote the colour tokens held inside the doc — an untracked mutation outside any transaction that no peer ever sees. - The editor store runs Mutative with `enableAutoFreeze: true`. The first projection to land in the store deep-froze everything reachable from it, including the objects the doc still held, so the SECOND projection threw on assignment. Every projection after that failed identically, which is why the editor never rendered. Fixed at the boundary rather than at the crash site: `own()` takes ownership of every plain value leaving a Y container (`projectSiteDoc` shell fields and per-entry maps, `projectNodeMap` props and breakpoint overrides, `projectMeta`, the layout snapshot). Primitives — the overwhelming majority of node props — pass through untouched, so only genuinely shared object graphs are cloned. Y types are returned as-is; they only reach `own()` from a malformed doc, and `structuredClone` would throw on their internals. Verified: the reproduction test fails 3/3 without the fix and passes with it; the editor loads a 212 KB page with a full layer tree and a clean console; site rows and collab blobs are byte-intact. bun test 6328 pass / 0 fail, bun run build, bun run lint. --- .../collab/projectionOwnership.test.ts | 125 ++++++++++++++++++ src/core/collab/nodeY.ts | 37 +++++- src/core/collab/project.ts | 16 ++- 3 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/collab/projectionOwnership.test.ts diff --git a/src/__tests__/collab/projectionOwnership.test.ts b/src/__tests__/collab/projectionOwnership.test.ts new file mode 100644 index 000000000..36adf50e3 --- /dev/null +++ b/src/__tests__/collab/projectionOwnership.test.ts @@ -0,0 +1,125 @@ +/** + * The projection must return OWNED data, not live references into the Y doc. + * + * `projectSiteDoc` reads plain values straight out of the shell Y.Maps. If it + * hands those references out, the editor store and the CRDT end up sharing the + * same mutable objects, and two things go wrong: + * + * 1. `validateSite` normalizes in place (`normalizeFrameworkColors` assigns + * `colors.tokens`), so validating a projection silently REWRITES state + * held inside the Y doc, outside any transaction. + * 2. The editor store is created with Mutative `enableAutoFreeze: true`, so + * the moment a projected shell lands in the store, everything reachable + * from it is deep-frozen — including the objects the Y doc still holds. + * The NEXT projection then throws + * `TypeError: Cannot assign to read only property 'tokens'`, the + * projection is skipped, and the editor never renders. + * + * Both are reproduced below with the real seed → project → validate → freeze + * cycle the editor performs on every remote update. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import '@modules/base' +import { projectSiteDoc, seedSiteDoc, shellMap } from '@core/collab' +import { validateSite } from '@core/persistence/validate' +import { makeSite } from '../fixtures' + +/** + * What Mutative's `enableAutoFreeze` does to state that lands in the store. + * + * Applied ONLY to the fixture's own `framework` object below — never to a whole + * validated shell. `parseSiteSettings`/`parseSiteDocument` hand back shared + * module singletons (`DEFAULT_SITE_SETTINGS`, `DEFAULT_PACKAGE_JSON`) by + * reference on their fallback paths, and `bun test` runs every file in ONE + * process, so freezing a validated shell wholesale would freeze those + * singletons for the entire run and break unrelated suites downstream. + */ +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object') return value + Object.freeze(value) + for (const inner of Object.values(value as Record)) deepFreeze(inner) + return value +} + +function siteWithFrameworkColors() { + const site = makeSite({}) + return { + ...site, + settings: { + ...site.settings, + framework: { + colors: { + tokens: [ + { + id: 'tok1', + category: '', + slug: 'Brand Primary', + lightValue: '#ffffff', + darkValue: '', + darkModeEnabled: false, + generateUtilities: { text: true, background: true, border: true, fill: false }, + generateTransparent: true, + generateShades: { enabled: false, count: 4 }, + generateTints: { enabled: false, count: 4 }, + order: 0, + createdAt: 0, + updatedAt: 0, + }, + ], + }, + }, + }, + } as typeof site +} + +function projectShell(doc: Y.Doc) { + const projected = projectSiteDoc(doc) + return validateSite({ ...projected.shell, id: 'default', updatedAt: 0 }) +} + +describe('site projection ownership', () => { + it('survives a second projection after the first landed in a frozen store', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + // First projection → validated → store. Freezing what the projection + // handed out is what the store's auto-freeze does; if that reaches the + // doc's own objects, the doc is poisoned. + const first = projectSiteDoc(doc) + validateSite({ ...first.shell, id: 'default', updatedAt: 0 }) + deepFreeze((first.shell.settings as Record).framework) + + // Second projection — a peer edit, an undo, any remote update at all. + expect(() => projectShell(doc)).not.toThrow() + }) + + it('does not let validation write normalized values back into the Y doc', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + const validated = projectShell(doc) + // Validation normalizes the slug and fills the dark value... + expect(validated.settings.framework?.colors?.tokens[0]?.slug).toBe('brand-primary') + expect(validated.settings.framework?.colors?.tokens[0]?.darkValue).not.toBe('') + + // ...but the doc must still hold exactly what was seeded. + const settings = shellMap(doc).get('settings') as Y.Map + const framework = settings.get('framework') as { + colors: { tokens: Array<{ slug: string; darkValue: string }> } + } + expect(framework.colors.tokens[0].slug).toBe('Brand Primary') + expect(framework.colors.tokens[0].darkValue).toBe('') + }) + + it('hands out a fresh object graph on every projection', () => { + const doc = new Y.Doc() + seedSiteDoc(doc, siteWithFrameworkColors()) + + const settings = shellMap(doc).get('settings') as Y.Map + const stored = settings.get('framework') + + expect(projectSiteDoc(doc).shell.settings).not.toBe(settings) + expect((projectSiteDoc(doc).shell.settings as Record).framework).not.toBe(stored) + }) +}) diff --git a/src/core/collab/nodeY.ts b/src/core/collab/nodeY.ts index 094a36595..a9cddcdae 100644 --- a/src/core/collab/nodeY.ts +++ b/src/core/collab/nodeY.ts @@ -16,6 +16,35 @@ import { inlineTextPropOf } from './schema' const STRUCTURED_KEYS = new Set(['props', 'breakpointOverrides', 'children', 'parentId']) +/** + * Take ownership of a value read out of a Y container. + * + * Yjs stores plain JS values BY REFERENCE — `Y.Map.get()` hands back the very + * object living inside the CRDT. Projecting those references out would make + * the editor store and the doc share mutable state, which breaks in both + * directions: + * + * - Consumers that normalize in place would rewrite CRDT state outside any + * transaction. `validateSite` does exactly this (`normalizeFrameworkColors` + * assigns `colors.tokens`), so merely projecting a shell used to rewrite + * the colour tokens held in the doc — a silent, untracked mutation that + * never reaches a peer. + * - The editor store runs Mutative with `enableAutoFreeze: true`, so the + * moment a projection lands in the store, everything reachable from it is + * deep-frozen — including the objects the doc still holds. The NEXT + * projection then throws `TypeError: Cannot assign to read only property`, + * the projection is skipped, and the canvas never updates again. + * + * Primitives — the overwhelming majority of node props — pass straight + * through. Y types are returned untouched: they only reach here from a + * malformed doc, and `structuredClone` would throw on their internals. + */ +export function own(value: T): T { + if (value === null || typeof value !== 'object') return value + if (value instanceof Y.AbstractType) return value + return structuredClone(value) as T +} + export function buildPropsMap( moduleId: string, props: Record, @@ -66,19 +95,21 @@ export function projectNodeMap(nodeMap: Y.Map): BaseNode { if (key === 'props') { const props: Record = {} for (const [pk, pv] of (value as Y.Map).entries()) { - props[pk] = pv instanceof Y.Text ? pv.toString() : pv + props[pk] = pv instanceof Y.Text ? pv.toString() : own(pv) } out.props = props } else if (key === 'breakpointOverrides') { const overrides: Record> = {} for (const [bp, bag] of (value as Y.Map).entries()) { - overrides[bp] = Object.fromEntries((bag as Y.Map).entries()) + const entries: Record = {} + for (const [ok, ov] of (bag as Y.Map).entries()) entries[ok] = own(ov) + overrides[bp] = entries } out.breakpointOverrides = overrides } else if (key === 'children') { out.children = (value as Y.Array).toArray() } else { - out[key] = value + out[key] = own(value) } } // Structured fields always exist on a well-formed node; default defensively diff --git a/src/core/collab/project.ts b/src/core/collab/project.ts index 6a4e356fd..ae8e6c07b 100644 --- a/src/core/collab/project.ts +++ b/src/core/collab/project.ts @@ -10,7 +10,7 @@ import { reindexNodeParents } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' import { dataMap, metaMap, rostersMap, shellMap, treeMap } from './schema' -import { projectNodeMap } from './nodeY' +import { own, projectNodeMap } from './nodeY' import { reconcileTreeIntegrity } from './integrity' function projectTree(doc: Y.Doc): { rootNodeId: string; nodes: Record } { @@ -29,7 +29,9 @@ function projectTree(doc: Y.Doc): { rootNodeId: string; nodes: Record { - return Object.fromEntries(metaMap(doc).entries()) + const meta: Record = {} + for (const [key, value] of metaMap(doc).entries()) meta[key] = own(value) + return meta } export function projectPageDoc(doc: Y.Doc, rowId: string): Page { @@ -53,7 +55,7 @@ export function projectComponentDoc(doc: Y.Doc, rowId: string): VisualComponent export function projectLayoutDoc(doc: Y.Doc, rowId: string): SavedLayout { const meta = projectMeta(doc) - const snapshot = dataMap(doc).get('snapshot') + const snapshot = own(dataMap(doc).get('snapshot')) const snapshotFields = snapshot && typeof snapshot === 'object' ? (snapshot as Record) : {} return { @@ -79,7 +81,13 @@ export function projectSiteDoc(doc: Y.Doc): ProjectedSiteDoc { const shellSrc = shellMap(doc) const shell: Record = {} for (const [key, value] of shellSrc.entries()) { - shell[key] = value instanceof Y.Map ? Object.fromEntries(value.entries()) : value + if (value instanceof Y.Map) { + const entries: Record = {} + for (const [entryKey, entryValue] of value.entries()) entries[entryKey] = own(entryValue) + shell[key] = entries + } else { + shell[key] = own(value) + } } const rosters = rostersMap(doc) From fc1d472565f467c6e3d315266a2dd0bb46ac6f06 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 14:43:12 +0200 Subject: [PATCH 38/49] fix(canvas): guard non-object style bags in ambient placeholder suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main merge reintroduced, one layer up, the hazard commit 2722c727 fixed: `ruleHasAuthoredDeclarations` read `Object.keys(rule.styles)` and `Object.values(rule.contextStyles)` unguarded. Those bags are the wide persistence type — a corrupt or legacy rule (bad import, plugin write, older data) can carry a non-object bag, and `Object.keys(null)` throws. This runs BEFORE the shared serializer that already guards exactly this (`bagToDeclarations` in @core/publisher/classCss), so one malformed rule threw here and took the whole canvas stylesheet down with it — the white screen that fix was written to prevent. It merged cleanly because the two guards live in different files, which is why the test now pins the canvas layer too. A malformed bag is treated as unauthored: that rule emits no suppression and every other rule still renders. --- .../canvas/classStyleInjector.test.ts | 39 +++++++++++++++++++ src/admin/pages/site/canvas/canvasClassCss.ts | 18 ++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/__tests__/canvas/classStyleInjector.test.ts b/src/__tests__/canvas/classStyleInjector.test.ts index 275ef917e..405f34a3a 100644 --- a/src/__tests__/canvas/classStyleInjector.test.ts +++ b/src/__tests__/canvas/classStyleInjector.test.ts @@ -276,6 +276,45 @@ describe('generateAmbientPlaceholderSuppressionCSS', () => { expect(css).toContain(':is(.dots i)') }) + + // Same resilience contract the shared serializer already honours + // (`bagToDeclarations` in @core/publisher/classCss): a corrupt or legacy + // rule can carry a non-object `styles`/`contextStyles` bag, and + // `Object.keys(null)` throws. This runs BEFORE that serializer, so an + // unguarded read here blanks the whole canvas on one bad rule. + it('treats a malformed style bag as unauthored instead of throwing', () => { + const nullStyles = makeAmbient('nullStyles', '.a', null as never) + const nullContexts = makeAmbient('nullContexts', '.b', {}, null as never) + const nullContextBag = makeAmbient('nullContextBag', '.c', {}, { + mobile: null as never, + }) + + expect(generateAmbientPlaceholderSuppressionCSS({ nullStyles })).toBe('') + expect(generateAmbientPlaceholderSuppressionCSS({ nullContexts })).toBe('') + expect(generateAmbientPlaceholderSuppressionCSS({ nullContextBag })).toBe('') + }) + + it('one malformed ambient rule does not stop the rest suppressing', () => { + // A packageJson-shaped object wrongly stored under a style-rule key. + const corrupt = { + id: 'corrupt', + name: 'corrupt', + kind: 'ambient', + selector: '.corrupt', + order: 0, + dependencies: {}, + createdAt: 0, + updatedAt: 0, + } as unknown as StyleRule + + const css = generateAmbientPlaceholderSuppressionCSS({ + corrupt, + dots: makeAmbient('dots', '.dots i', { width: '12px' }), + }) + + expect(css).toContain(':is(.dots i)') + expect(css).not.toContain('.corrupt') + }) }) describe('createCanvasClassCssMemo', () => { diff --git a/src/admin/pages/site/canvas/canvasClassCss.ts b/src/admin/pages/site/canvas/canvasClassCss.ts index 46c7e44ec..886aa4069 100644 --- a/src/admin/pages/site/canvas/canvasClassCss.ts +++ b/src/admin/pages/site/canvas/canvasClassCss.ts @@ -138,9 +138,23 @@ export const generateCanvasClassCSS: CanvasClassCssGenerator = createCanvasClass const EMPTY_CONTAINER_PLACEHOLDER_SELECTOR = '[data-canvas-module-placeholder]' +/** + * A style bag is the wide persistence type: a corrupt or legacy rule (bad + * import, plugin write, older data) can carry a non-object `styles` or + * `contextStyles`, and `Object.keys(null)` throws. This check runs BEFORE the + * shared serializer that already guards this (`bagToDeclarations` in + * `@core/publisher/classCss`), so reading a bag unguarded here would blank the + * entire canvas on one malformed rule. Treat it as unauthored instead. + */ +function bagHasEntries(bag: unknown): boolean { + if (bag === null || typeof bag !== 'object') return false + return Object.keys(bag).length > 0 +} + function ruleHasAuthoredDeclarations(rule: StyleRule): boolean { - if (Object.keys(rule.styles).length > 0) return true - return Object.values(rule.contextStyles).some((styles) => Object.keys(styles).length > 0) + if (bagHasEntries(rule.styles)) return true + if (rule.contextStyles === null || typeof rule.contextStyles !== 'object') return false + return Object.values(rule.contextStyles).some(bagHasEntries) } /** From d5f5c11ec1791809959493df7c186040ed2d1860 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 15:05:47 +0200 Subject: [PATCH 39/49] test(e2e): retire saveDraft for the collab relay's continuous persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright suite was dead against `src/`: `saveDraft` clicked `toolbar-save-draft-action`, a testid this branch removed along with the client save pipeline. 16 spec files imported it, so every test that touched the site editor failed on a 10s locator timeout. There is nothing to replace it with, because there is nothing to replace: edits stream to the relay as they land, publish flushes the relay before it reads rows, and a reload re-syncs from the relay's authoritative doc. The helper and all 40 call sites are simply gone. The guarantees the callers actually depended on still hold, and the tests now prove the CRDT path rather than the deleted one — the three capability personas, for instance, still edit, reload, and assert their change survived, which now exercises the relay's per-category update guard end to end. Also fixed here, because retiring saveDraft is what exposed it: scheduling a publish 404'd with "Data row not found" for any page created in the editor. `handleRowSchedulePost` read the row straight from the DB, but a freshly created page lives in the relay's in-memory doc until the persist debounce elapses. `publishDataRow` already flushes for exactly this reason; scheduling is "publish later" and now does the same. The old saveDraft call had been hiding the race by forcing a write first. PAGE-004 and SAVE-001 retitled — "unsaved draft" is not a state that exists in the site editor any more, and the status chip reads "Draft synced". The Content workspace keeps its own save model, so its `Unsaved draft` assertions are untouched. docs/e2e/feature-validation.tsv updated for both, plus the SITE-017/SITE-018 rows that still pointed at deleted modules (saveTrackingSlice.ts, site/dirtyTracking.ts) and claimed Save Draft persists template state. Verified: page-management 11/11; visual-builder + capabilities + core-owner-lifecycle + preview-live 52 passed with one unrelated pre-existing failure (see below); the new server test fails without the schedule flush and passes with it. bun test 6331 pass / 0 fail, bun run build, bun run lint. NOT fixed here, deliberately: CAP-005 "AI provider manager can access provider/default tabs" fails on origin/main today. Commit 76f15fa6 "feat(ai): redesign settings and add MCP OAuth (#245)" replaced the AI workspace tablist with a sidebar nav and never updated its specs, and tests/e2e/ai.e2e.ts has the same drift. Different reason to change; it needs its own PR against main. --- docs/e2e/feature-validation.tsv | 8 ++-- server/handlers/cms/data/rows.ts | 7 +++ .../server/cmsDataAuthorization.test.ts | 44 +++++++++++++++++++ tests/e2e/accessibility.e2e.ts | 2 - tests/e2e/background-image-smoke.e2e.ts | 2 - tests/e2e/capabilities.e2e.ts | 5 --- tests/e2e/content.e2e.ts | 3 -- tests/e2e/core-owner-lifecycle.e2e.ts | 3 -- tests/e2e/forms.e2e.ts | 3 -- tests/e2e/helpers/editor.ts | 22 ---------- tests/e2e/media.e2e.ts | 3 -- tests/e2e/page-management.e2e.ts | 22 +++++----- tests/e2e/performance.e2e.ts | 2 - tests/e2e/preview-live.e2e.ts | 5 +-- tests/e2e/public-dynamic-fragments.e2e.ts | 2 - tests/e2e/reliability.e2e.ts | 2 - tests/e2e/runtime-dependencies.e2e.ts | 2 - tests/e2e/site-files.e2e.ts | 2 - tests/e2e/visual-builder.e2e.ts | 19 +------- 19 files changed, 67 insertions(+), 91 deletions(-) diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 176c1e3a1..eca98484c 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -30,7 +30,7 @@ SPOT-002 Spotlight scoped commands and pending actions As an admin user, I want SPOT-003 Spotlight recents and destructive confirmation As an admin user, I want recent commands and protection for destructive commands so speed does not compromise safety. Recent commands persist and appear on reopen; destructive commands require two Enter confirmations and timeout collapse after configured duration. Timer expiry; command disappears after state change; repeated Enter double-fire; recents dedupe. Recent store validates local storage; destructive command state owned in Spotlight state; no native confirm dialogs. src/admin/spotlight/recentStore.ts; src/admin/spotlight/state.ts; src/admin/spotlight/SpotlightResults.tsx Confirm timeout is now Playwright-covered; OS accessibility variants remain observational. Happy: recent command appears. Error: destructive command cancel/timeout no mutation. Boundary: confirmation at exactly timeout. Invalid: corrupted recent store. Permission: destructive command still capability gated. Performance: recents load instantly. Mobile: confirm row visible. SPOT-005 destructive confirm-timeout Playwright regression and full command-palette E2E file passed 2026-06-22; broader context-ranking and accessibility exploratory pending 0 None SPOT-005 promoted in `command-palette.e2e.ts`: first Enter arms Delete current page, timeout clears the prompt, and the throwaway page remains. Verification: focused timeout E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 SITE-001 Site document load/save As a site editor, I want the draft site document to load and save so page, style, dependency, and runtime changes persist. Site editor loads /site into store, tracks dirty state, saves PUT /site with granular diff authorization, validates site document, and refreshes active document. Concurrent saves; stale data; corrupted stored site; partial capability user saving only allowed diff; network failure. Site document boundary uses TypeBox validateSite; server diff requires site.structure/content/style capabilities by path; CSRF gate applies. src/admin/pages/site/store; server/handlers/cms/site.ts; server/handlers/cms/siteDiff.ts; src/core/persistence/validate.ts Autosave/manual save behaviour is store-controlled; exact UI feedback from toolbar/canvas. Happy: edit then save/reload. Error: save API failure. Boundary: no-op save. Invalid: corrupted site payload rejected. Permission: content-only cannot save style/structure diff. Performance: save completes without freezing. Mobile: save controls reachable. Manual and automated CAP-002 content/style/structure personas passed after DEF-20260622-001 and DEF-20260622-002 fixes 2026-06-22 0 None DEF-20260622-001 resolved: content-only Text edit saved and survived reload after page-row diff validation plus no-op component/layout save allowance. Style persona set inline Font size 22px, saved, and reload preserved it. Structure persona added a Text layer, saved, and reload preserved the third layer. Automated CAP-002 Playwright regression created separate content/style/structure roles and users, verified each allowed edit saved and survived reload, and verified blocked controls stayed read-only/absent. Verification: bun test src/__tests__/server/capabilityRouteMatrix.test.ts, bun test src/__tests__/property-controls/PropertyControlRenderer.test.tsx, manual browser CAP-002 persona save/reload checks, and `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "CAP-002"`. Run log: docs/e2e/runs/2026-06-22-cap002-automated-edit-boundaries.md. 2026-06-22 SITE-002 Site explorer tree As a site editor, I want pages, components, layouts, and files organized in an explorer so I can manage site assets. SiteExplorerPanel renders structured entries, folders, context menus, create/rename/delete settings dialogs, and DnD organization for supported targets. Empty folders; duplicate paths/slugs; system rows locked; deleting active page/component; file path collisions. Create dialogs normalize item names/paths; server page/component/layout routes validate row shapes; deletion confirmation used. src/admin/pages/site/panels/SiteExplorerPanel; src/admin/shared/dialogs/SiteCreateDialog; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; server/handlers/cms/layouts.ts System tables pages/components/layouts are backed by data_rows. Happy: create and open explorer item. Error: duplicate slug/path message. Boundary: nested folders. Invalid: unsafe path. Permission: pages.edit/site.structure required. Performance: large tree remains responsive. Mobile: explorer panel usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 -SITE-003 Page management As a site editor, I want to create, rename, open, and delete pages so public site structure can change. New pages get body tree defaults, title/slug, selectable active document; rename updates slug/title; delete removes row and selection moves safely; unsaved edits remain visible when switching pages and persist after explicit save/reload. Home/root page handling; duplicate slug; deleting current page; unsaved edits before navigation; invalid slug characters. Pages endpoint requires page payload validation; UI slugifies names; capabilities include site.structure.edit/pages.edit. src/admin/pages/site/panels/SiteExplorerPanel; server/handlers/cms/pages.ts; src/core/data/pageFromRow.ts; src/core/page-tree/page.ts Page rows live in data_tables/pages, not legacy pages table. Happy: create/rename/delete page. PAGE-004: edit first page, verify Unsaved draft, switch to another page, return and verify the edit remains, then save/reload and verify persistence. Error: duplicate slug rejected. Boundary: first/last/active page. Invalid: blank title. Permission: read-only cannot mutate. Performance: page switch fast after prewarm. Mobile: dialogs fit. PAGE-004 unsaved page-switch Playwright regression passed 2026-06-22; PAGE-001 through PAGE-004 now have page-management E2E coverage 0 None PAGE-004 promoted in `page-management.e2e.ts`: the test creates two pages, edits Text on the first page, verifies the user-visible Unsaved draft state, switches to the second page and back without losing the in-memory draft, then saves, reloads, and verifies the edit persists. Verification: TSV and diff guards passed; focused `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts -g "PAGE-004"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts` passed 5/5; bun run lint passed; bun run build passed; bun test passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-page004-unsaved-page-switch.md. 2026-06-22 +SITE-003 Page management As a site editor, I want to create, rename, open, and delete pages so public site structure can change. New pages get body tree defaults, title/slug, selectable active document; rename updates slug/title; delete removes row and selection moves safely; edits remain visible when switching pages and persist across reload. Home/root page handling; duplicate slug; deleting current page; in-flight edits before navigation; invalid slug characters. Pages endpoint requires page payload validation; UI slugifies names; capabilities include site.structure.edit/pages.edit. src/admin/pages/site/panels/SiteExplorerPanel; server/handlers/cms/pages.ts; src/core/data/pageFromRow.ts; src/core/page-tree/page.ts Page rows live in data_tables/pages, not legacy pages table. Happy: create/rename/delete page. PAGE-004: edit first page, verify Draft synced, switch to another page, return and verify the edit remains, then reload and verify persistence. Error: duplicate slug rejected. Boundary: first/last/active page. Invalid: blank title. Permission: read-only cannot mutate. Performance: page switch fast after prewarm. Mobile: dialogs fit. PAGE-004 unsaved page-switch Playwright regression passed 2026-06-22; PAGE-001 through PAGE-004 now have page-management E2E coverage 0 None PAGE-004 promoted in `page-management.e2e.ts`: the test creates two pages, edits Text on the first page, verifies the user-visible Unsaved draft state, switches to the second page and back without losing the in-memory draft, then saves, reloads, and verifies the edit persists. Verification: TSV and diff guards passed; focused `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts -g "PAGE-004"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts` passed 5/5; bun run lint passed; bun run build passed; bun test passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-page004-unsaved-page-switch.md. 2026-06-22 SITE-004 Visual editor canvas selection and rendering As a site editor, I want to see and select page nodes on a canvas so edits map to visible output. Canvas renders active page/VC/template in iframe frames, overlays selection/hover/ladder, suppresses public form controls, and syncs selected node to panels. Missing module definition; locked/hidden nodes; iframe load race; node inside visual component slot; canvas zoom/pan. Renderer validates node props through module engine; canvas queries only iframe documents; readonly regions enforced. src/admin/pages/site/canvas; src/core/module-engine; src/core/page-tree; src/modules/base Browser evidence needed for overlay geometry and iframe behaviour. Happy: select visible node and panel updates. Error: missing module placeholder. Boundary: hidden/locked nodes. Invalid: bad node props fallback. Permission: read-only can select but not edit. Performance: large tree still responsive. Mobile: canvas controls visible. Manual core lifecycle passed 2026-06-22; automated/build/lint/bundle/Playwright E2E passed; broader exploratory pending 0 None Manual run 2026-06-22 core-owner-lifecycle passed; see docs/e2e/runs/2026-06-22-core-owner-lifecycle.md. 2026-06-22 SITE-005 Module insertion and picker As a site editor, I want to search, navigate, and insert modules from a picker so I can build pages beyond the three canvas-notch favorites. The Add to canvas dialog opens from the canvas notch, focuses Search modules, lists visible registry modules plus saved layouts, visual components, and recent insertions, filters by item search text, supports Enter insertion from the search zone, supports pointer-drag insertion onto measured canvas targets, writes successful insertions to local recent history, lets authors switch between grid and list view, persists that view in localStorage, and closes after successful insertion. Inserted modules are added through the active tree insertion target and selected in the editor. No selected insert target; incompatible parent; hidden internal modules; disabled template-only outlet module; corrupted localStorage prefs; recent refs for deleted items; favorites server preference unavailable; plugin modules with missing dependencies; saved layout invalid tree or VC cycle; search returning no matches; narrow viewport category labels. Module defaults come from module definitions; insertion resolves click/keyboard targets and canvas drag targets through shared tree insertion helpers; hidden/disabled item availability is derived from active document context; recent prefs are TypeBox-validated with fallback; server favorites are TypeBox-validated and default to Container/Text/Image; successful insert tracks a deduped recent ref; category buttons retain accessible names when labels are visually hidden. src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/moduleInserterModel.ts; src/admin/pages/site/module-picker/moduleInserterPrefs.ts; src/admin/pages/site/module-picker/useModuleInserterPreference.ts; src/admin/pages/site/hooks/useInsertModule.ts; src/admin/pages/site/hooks/useInsertInserterItem.ts; src/core/module-engine; src/core/page-tree/mutations.ts; tests/e2e/visual-builder.e2e.ts Base modules register on admin import; Recent and view mode are local browser preferences while notch favorites are a server-backed user preference; saved layouts and visual components have dedicated feature rows for full management/publish behavior. Happy: search for Button, insert it with Enter, verify Button layer appears, reopen picker, verify Button appears in Recent, switch to List view, close/reopen, and verify List view remains active. Error: search no matches shows empty state in lower-level coverage. Boundary: corrupted localStorage falls back to grid/no recents; recent refs dedupe and unresolved refs are ignored; template-only outlet disabled reasons covered lower-level. Invalid: hidden internal modules and malformed saved layout refs cannot be picked. Permission: structure edit required to mutate the active tree. Performance: search/filter and recent rendering complete within E2E timeout. Drag: drag Text from the filtered picker into the center of an existing Container, verify the inside drop preview, edit the inserted Text, and verify it renders inside that Container. Mobile: at 390x844, the dialog is viewport-contained without page-level horizontal overflow, Modules/Recent and Search modules remain reachable, Button can be searched and inserted with Enter, and the inserted Button appears in Layers; category accessible names are covered by SITE-019. SITE-005 Playwright regression passed 2026-06-23; focused module-inserter model, favorites, preference, and dropdown tests cover localStorage fallback, recent dedupe, server favorites, category accessible names, disabled/hidden module availability, and keyboard scaffolding; no product defects found in this slice; plugin-provided module insertion remains residual risk; picker drag-drop into a canvas Container and phone-width picker flow passed 0 None SITE-005 promoted in `visual-builder.e2e.ts`: create a fresh page, open Add to canvas, verify Grid view is active, search `button`, verify only the Button module item remains among checked module ids, press Enter to insert it, verify the Button layer appears in the Page element tree, reopen the picker, switch to Recent and verify Button appears, switch to List view, close/reopen, and verify List view persisted. Focused verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005"` passed 4/4 including setup. Drag verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005 drag"` passed 2/2 including setup and verified inside-drop preview plus nested canvas rendering. Mobile verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005 mobile"` passed 2/2 including setup and verified the phone-width dialog/search/item containment before keyboard insertion. Run logs: docs/e2e/runs/2026-06-23-site005-module-picker.md; docs/e2e/runs/2026-06-23-site005-mobile-module-picker.md; docs/e2e/runs/2026-06-23-site005-picker-drag-drop.md. 2026-06-23 SITE-006 Properties panel and property controls As a site editor, I want to edit selected node content, style, dynamic bindings, HTML attributes, and module-specific settings. PropertiesPanel derives selected node/module schema, renders typed property controls, updates props/breakpoint overrides/classes/custom props/html attrs, and auto-opens on selection. Multi-selection; no selection; controls hidden by condition; invalid module schema; content-only/style-only permission split. Property schema is TypeBox-like module schema; controls use UI primitives; updates route through mutateActiveTree and capability gates. src/admin/pages/site/panels/PropertiesPanel; src/admin/pages/site/property-controls; src/core/module-engine/propertySchema.ts; src/admin/access.ts Some controls are content category, others style/structure. Happy: edit text/image/link props. Error: invalid value message. Boundary: conditional controls. Invalid: unsafe URL/CSS value. Permission: content-only cannot style. Performance: panel scroll remains usable. Mobile: no clipped controls. Manual and automated CAP-002 content/style/structure personas passed after DEF-20260622-001 and DEF-20260622-002 fixes; BUILDER-006 style-controls Playwright regression passed 2026-06-22 0 None DEF-20260622-001 resolved: content-only Text property edit saved and survived reload. DEF-20260622-002 resolved: structure-only persona no longer gets enabled content controls; PropertyControlRenderer now treats content and structure categories separately, with regression coverage for structure-only content controls and layout controls. Automated CAP-002 Playwright regression verified content-only could edit Text but saw style/structure controls read-only or absent, style-only could create a class and set Font size while Text content was disabled, and structure-only could insert a Text layer while Text content was disabled. BUILDER-006 Playwright run created a class on a Button, set Font size 24px, Background color #00aa55, and Padding top 12px through the Properties panel controls, saved/reloaded, published, and verified canvas/public computed CSS. Verification: bun test src/__tests__/property-controls/PropertyControlRenderer.test.tsx, manual browser CAP-002 properties checks, `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "CAP-002"`, `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "spacing, color"`, and publishing-group Playwright regression. Run logs: docs/e2e/runs/2026-06-22-cap002-automated-edit-boundaries.md; docs/e2e/runs/2026-06-22-builder006-style-controls.md. 2026-06-22 @@ -44,8 +44,8 @@ SITE-013 Code editor for site files As a site editor, I want to create, preview, SITE-014 Site dependencies and runtime package resolution As a site editor, I want to declare dependencies so plugin modules and site code can use runtime packages. Dependencies panel edits package metadata; POST /runtime/dependencies/resolve installs/resolves package import map; published pages serve cached runtime packages under /_instatic/runtime/cache. Invalid package.json; network/install failure; stale hash; package not found; runtime path 404 under namespace. Resolve body accepts unknown packageJson and normalizes to safe runtime dependencies only; devDependencies are not resolved into runtime importmaps; client validates dependencyLock/packageImportmap envelopes; runtime package paths require a 24-hex hash and reject traversal. src/admin/pages/site/panels/DependenciesPanel; src/admin/pages/site/hooks/useAutoResolveDependencies.ts; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/dependencyResolver.ts; server/publish/runtime/dependencyCache.ts; server/publish/runtime/packageImportmap.ts; server/publish/runtime/packageServer.ts Package installs may need network; deterministic tests use mocked registry/install/cache roots and UI fetch stubs. Happy: dependency panel detects imports, adds missing dependency, resolves lock/importmap, preview/build consumes runtime deps, package server serves cached package assets, public importmap points at hashed cache URLs, browser can load the emitted package asset, and mobile code authoring exposes the missing-dependency Add action without horizontal overflow. Error: resolve API failure surfaces in store; network/install timeout/cap/partial cache handled; missing package asset 404. Boundary: no dependencies, stale lock, importmap missing, concurrent resolves, cache sentinel, relative RUNTIME_CACHE_DIR. Invalid: unsafe package names, malformed lock envelope, bad runtime hash, traversal path. Permission: runtime.dependencies/site.read caps. Performance: install cache reuse and concurrent install dedupe. Mobile: Code Editor authoring and dependency panel controls stay reachable at 390px. Passed 2026-06-23: deterministic SITE-014 coverage (75 tests), cache layout regression (8 tests), Site Explorer layering invariant (33 tests), focused browser E2E (3 tests including setup), full bun test (5695 pass), lint, and build. 0 None Browser E2E now covers authoring a script import, Dependencies-panel missing package Add, live canvas-confetti registry/cache resolution, save/publish, public marker output, importmap emission, browser loading of /_instatic/runtime/cache package URLs, and a 390px mobile path for missing left-pad import analysis, dependency panel containment, and Add reachability. DEF-20260623-SITE014-01 closed: Code Editor floated above docked sidebars and intercepted Dependencies Add; fixed by raising site sidebars above floating editor panels and updating the layering invariant. DEF-20260623-SITE014-02 closed: relative RUNTIME_CACHE_DIR leaked relative paths to esbuild nodePaths and blocked publish with Could not resolve canvas-confetti despite an installed cache; fixed by absolute-normalizing cacheRootDir. DEF-20260623-SITE014-03 closed: mobile Code Editor authoring was blocked by fixed desktop panel sizing, a horizontal settings rail over the editor, and sidebar interception; fixed with responsive Code Editor viewport clamps, stacked settings panes on narrow screens, and a mobile overlay layer for the active Code Editor. Remaining: live registry/install failure UX permutations. 2026-06-23 SITE-015 Site editor media explorer and picker As a site editor, I want to browse, upload, reuse, inspect, edit, and apply media from inside the site editor so image, video, SVG, and background/media controls can use CMS assets without leaving the authoring workflow. MediaLibraryControl renders library and URL modes for image/video props, lazy-loads MediaPickerModal on Browse, filters by media kind, updates the prop with the picked asset publicPath, supports clearing, and opens MediaViewerWindow for the selected CMS asset. MediaExplorerPanel is a docked left-rail panel that lists CMS assets grouped as Images, Videos, and Other, supports search and list/grid view persistence, uploads assets through CMS media APIs, opens the shared viewer, exposes context menu actions for Copy URL, Rename, Delete, and conditionally Use in selected image/video when the selected canvas node matches the asset kind. Published pages render selected local media through /uploads URLs. Empty library shows bucket empty states; image/video/other assets are bucketed by MIME type; selected image/video actions only appear for matching module and asset kind; unsupported uploads return an alert through the media upload queue; deleted or missing selected paths fall back to saved-path labels; URL mode accepts local /uploads paths plus http/https URLs and rejects invalid image/video URLs; SVG uploads are sanitized before serving; viewer edits/removal update local asset state; upload queue and media viewer can be closed without leaving the editor. CMS media responses are validated by @core/persistence/cmsMedia; uploads are server magic-byte/type/size checked and routed through the media presentation pipeline; MediaLibraryControl validates URL mode before calling onChange; MediaExplorerPanel applies assets only to selected base.image src or base.video videoUrl props; media routes require the relevant media capabilities; publisher escapes/render-validates media URLs and visitor pages must not include admin chrome. src/admin/pages/site/panels/MediaExplorerPanel/MediaExplorerPanel.tsx; src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts; src/admin/pages/site/property-controls/MediaLibraryControl.tsx; src/admin/pages/site/property-controls/ImageControl.tsx; src/admin/pages/site/property-controls/BackgroundImageControl.tsx; src/admin/pages/media/components/MediaPickerModal/MediaPickerModal.tsx; src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx; src/admin/pages/media/hooks/useStandaloneMediaEditor.ts; src/core/persistence/cmsMedia.ts; server/handlers/cms/media.ts; server/handlers/cms/mediaUpload.ts; src/modules/base/image; src/modules/base/video The docked Media Explorer and the property-control picker intentionally share CMS media and viewer primitives with the Media workspace; direct background-image picker publishing remains covered by lower-level style/publisher tests rather than a dedicated browser journey; the new SITE-015 browser regression uses disposable SQLite/uploads and verifies public media output as a visitor. Happy: upload an image through the property picker, select it for an image module, save, publish, and verify public /uploads image decoding; reuse the same library asset on a second image without re-upload; upload an image through the docked Media Explorer, use its context menu to apply it to the selected image module, save, publish, and verify public /uploads image decoding. Error: unsupported upload shows specific rejection feedback; media API errors surface inline or restore optimistic state. Boundary: empty library, search filters, list/grid view, image/video/other grouping, selected image/video context action gating, metadata rename/edit/reload persistence, replace/delete/restore/purge lifecycle, and sanitized SVG serving. Invalid: bad URL-mode values are rejected before prop update; unsafe SVG script/event/style content is stripped. Permission/security: media APIs require media capabilities and public visitor pages show only uploaded media, not admin chrome. Performance: MediaPickerModal lazy-loads only after Browse; media panel fetches assets when opened. Mobile/responsive: media viewer, replace dialog, trash restore, and storage panel have mobile containment coverage; docked editor Media Explorer mobile remains a residual exploratory check. SITE-015 browser regression passed 2026-06-23 with direct docked Media Explorer apply-to-selected-image coverage; no open defects; docked Media Explorer mobile exploratory remains pending 0 None Added SITE-015 coverage to `tests/e2e/media.e2e.ts`: create a page, insert/select an image module, open the Media panel, upload an image through the docked panel, use the asset context menu `Use in selected image`, save, publish, and verify the public page serves/decodes the uploaded image. Existing coverage in `media.e2e.ts` covers picker upload/select/publish, asset reuse, unsupported upload rejection, metadata persistence, mobile metadata viewer containment, replace/delete/restore/purge lifecycle, mobile lifecycle containment, storage panel state/mobile containment, and SVG sanitization. Focused component coverage in `siteExplorerPanel.test.tsx` covers Media Explorer grouping, search/list/grid, copy URL, selected image/video apply actions, viewer opening, rename, and delete. Verification this slice: TSV integrity guard passed; focused SITE-015 unit/component/API `bun test` passed 102/102; focused `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts -g "SITE-015"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts` passed 12/12 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site015-media-explorer.md. 2026-06-23 SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, posts the current in-memory site and active page to the CMS runtime-preview endpoint and renders the server-built document in a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; PreviewOverlay sends the validated in-memory draft to the site.read-gated runtime-preview endpoint, which prefetches loop and media data before the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/persistence/cmsRuntime.ts; server/handlers/cms/runtime.ts; server/publish/runtime/previewRuntime.ts; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads the overlay and starts one abortable runtime-preview request only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 browser verification passed 2026-07-22 after issue #234 restored loop parity in Preview page; draft-vs-live comparison and mobile preview reachability remain covered; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run logs: docs/e2e/runs/2026-06-23-site016-preview-live.md and docs/e2e/runs/2026-07-22-issue-234-preview-loop-retest.md. Issue #234 was reproduced with a `site.pages` loop visible in the canvas and public route but missing from Preview page. PreviewOverlay now delegates current-draft rendering to the server runtime-preview boundary, which prefetches loop and media data before publishing. Verification passed: same-flow Chromium retest plus live-route check, focused runtime/preview tests 44/44, full `bun test` 6237/6237, `bun run lint`, and `bun run build`. 2026-07-22 -SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/dirtyTracking.ts; src/core/persistence/cms.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 -SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. Save Draft persists template state, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, save/publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 +SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/collabBinding.ts; src/core/collab/applyPatches.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 +SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. The collab relay persists template state continuously, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 SITE-019 Saved layouts As a site editor, I want to save and reuse layouts so common structures can be inserted quickly. Layouts are stored in the layouts system table; the Save as layout dialog rejects blank and duplicate names; the module inserter lists saved layouts under Layouts; inserting a saved layout clones the captured subtree and style rules with fresh node ids; renaming or deleting the saved layout does not affect already-inserted page content. Duplicate layout names; blank names; saved layout source on page root; stale selections after page creation; mobile/narrow inserter category labels; context-menu z-index inside the spotlight inserter; deleted modules/VC refs inside a layout; invalid tree; deleting a saved layout already used by a page has no page effect. Layout route validates layout document; clone remaps node ids/scoped classes; system table locked from rename/delete; addPage clears stale canvas selection; module inserter category buttons keep explicit accessible names when labels hide responsively; saved-layout manage menu renders above the spotlight layer. server/handlers/cms/layouts.ts; src/admin/pages/site/dialogs/LayoutNameDialog.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/SavedLayoutManageMenu.tsx; src/admin/pages/site/store/slices/layoutsSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/data/layoutFromRow.ts Layouts are not separate DB tables; selector selection can persist across page switches, but stale node selection must not. Happy: save styled container subtree as layout, insert it on a new page, save/reload/publish, verify public output. Error: blank and duplicate names render inline errors. Boundary: narrow 390px inserter exposes Layouts. Invalid: stale node selection is cleared on addPage. Permission/security: structure edit and system table gates remain server-covered. Performance: picker remains responsive with saved item. Manage: rename and delete saved layout; inserted content persists. SITE-019 Playwright browser regression passed 2026-06-23 after DEF-20260623-SITE019-001/002/003 fixes; store selection and toolbar category accessibility regressions passed; no open high/critical defects in this slice; plugin-pack grouping, VC-mode cycle blocking, invalid/dangling layout snapshots, permission personas, and real touch/long-press mobile management remain residual risk 0 None DEF-20260623-SITE019-001 fixed: creating a new page via addPage left selectedNodeId/hover/inline-edit state from the previous page, keeping the right inspector expanded with no valid selected element and blocking narrow-width canvas controls. Fix: addPage reuses clearCanvasSelectionDraft; regression `src/__tests__/editor-store/pageActionsSelection.test.ts`. DEF-20260623-SITE019-002 fixed: module inserter category buttons lost accessible names at <=720px because visible labels were display:none and icons were aria-hidden, leaving count-only buttons. Fix: explicit aria-label on category buttons; regression in `src/__tests__/toolbar/modulePickerDropdown.test.tsx`. DEF-20260623-SITE019-003 fixed: saved-layout Rename/Delete context menu rendered at z-index 1000 under the spotlight inserter at z-index 9000, making menu items visible to accessibility APIs but unclickable. Fix: saved-layout manage menu uses zIndex 10000. Added `visual-builder.e2e.ts` SITE-019 browser journey covering blank/duplicate validation, mobile Layouts category reachability, insert, style preservation, rename, delete, save/reload, publish, and anonymous public output. Verification: focused `bun test src/__tests__/editor-store/pageActionsSelection.test.ts`, `bun test src/__tests__/toolbar/modulePickerDropdown.test.tsx`, and `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-019"` passed. Run log: docs/e2e/runs/2026-06-23-site019-saved-layouts.md. 2026-06-23 SITE-020 HTML import modal As a site editor, I want to paste/import HTML so existing markup becomes editable page nodes. ImportHtmlModal parses HTML, strips unsafe content, maps elements to page nodes/modules, and inserts fragment into active tree. SVG mapping; malformed HTML; unsupported tags; script/style stripping; text normalization. HTML import uses parser/stripUnsafe rules and module mapping; insertion still uses tree mutation legality. src/admin/modals/ImportHtml; src/core/htmlImport Imported styles may need manual class cleanup. Happy: paste simple HTML becomes nodes. Error: malformed HTML feedback. Boundary: empty fragment. Invalid: script tag stripped. Permission: structure edit. Performance: large paste bounded. Mobile: modal usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 SITE-021 Super Import site/bundle wizard As an operator, I want to import static-site files or CMS bundles so I can move content into Instatic. SiteImport modal accepts drops, analyzes files/bundles, shows conflicts/review, supports import strategies, applies asset rewrites/stylesheets/fonts, and emits refresh events. Unsupported archive; path traversal; conflicting slugs/files; replace strategy destructive; invalid bundle schema. Import preview/import routes validate SiteBundle and import strategy; replace/import requires data.import and sometimes content.manage/step-up; path traversal tests gate. src/admin/modals/SiteImport; server/handlers/cms/importPreview.ts; server/handlers/cms/import.ts; src/core/siteImport; src/core/data/bundleSchema.ts Use disposable DB for replace import. Happy: import small CMS bundle. Error: invalid zip/bundle. Boundary: merge-add vs merge-overwrite vs replace. Invalid: traversal path. Permission: data.import/content.manage and step-up. Performance: progress shown. Mobile: wizard scrolls. CMS-bundle replace import step-up Playwright regression and SiteImportModal unit suite passed 2026-06-22; static-file import exploratory still pending 0 None DEF-20260622-012 resolved: replace import hit the server step-up gate but useCmsBundleImport caught `step_up_required` as a generic import failure toast, leaving fresh sessions unable to complete destructive imports. Fix wraps importSiteBundle in runStepUp, keeps cancel non-destructive/non-error, and retries after successful step-up. Reverified 2026-06-22 and normalized stale high-severity accounting: destructive site import step-up E2E passed 2/2 including setup, and the focused auth/site-import/data unit bundle passed 79/79. Static-file import exploratory remains untested risk, not a known open high defect. Verification: `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "destructive site import requires successful step-up"` and `bun test src/__tests__/admin/siteImport/SiteImportModal.test.tsx`. Run logs: docs/e2e/runs/2026-06-22-cap003-destructive-import-step-up.md; docs/e2e/runs/2026-06-22-high-severity-defect-accounting.md. 2026-06-22 @@ -121,7 +121,7 @@ PERF-002 Moderately complex publish completion As a site editor, I want publishi PAGE-001 Create and open a site page As a site editor, I want to create a new page from the Site Explorer so the page becomes available for editing in the visual canvas. The Site Explorer New page action opens the page creation dialog, accepts a name and slug, creates the page, hides the dialog, shows an Open page tree item with the new name, and selecting that item marks it selected in the editor tree. Duplicate slugs; reserved slugs; missing or empty names; stale tree state after dialog close; failed create request; existing homepage collision; page created but not openable. The New page dialog uses page slug validation, duplicate-slug checks, shared dialog inputs, and the site/page store creation action; create and reload data flow through validated CMS persistence APIs before the tree item is rendered. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/shared/dialogs/SiteCreateDialog/SiteCreateDialog.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; server/handlers/cms/pages.ts; src/core/page-tree/slugs.ts The automated browser regression creates uniquely named disposable pages in a local SQLite E2E database; deeper invalid slug and duplicate handling are covered by validation/error rows and lower-level tests. Happy: create a unique page and open it in the canvas. Error: failed create should leave recoverable dialog feedback. Boundary: unique generated slug. Invalid: reserved/empty/duplicate slug blocked by dialog validation. Permission: requires authenticated Site page creation access. Performance: tree item appears within E2E timeout. Mobile: page creation on narrow editor chrome remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-001 scenario creates a unique About page, verifies the tree item named Open page appears, clicks it, and asserts aria-selected=true. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 PAGE-002 Rename and reopen a site page As a site editor, I want to rename a page from the Site Explorer so the navigation tree and editable page context reflect the new title. A page tree context-menu Rename action opens a rename textbox labelled with the current page name, Enter commits the new name, the new tree item becomes visible and openable, and the old tree item disappears. Name-only rename with unchanged slug; duplicate or invalid path edits; context menu on wrong row; stale selected page after rename; old tree item lingering; renamed page not openable. Explorer rename uses page slug/name validation paths and page mutation/persistence actions; the browser regression verifies user-visible tree state and selection rather than direct API state. tests/e2e/page-management.e2e.ts; src/admin/pages/site/explorer-actions/ExplorerRenameDialog.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/page-tree/slugs.ts The covered flow renames the display name through the context menu; public slug/open-route rename semantics beyond the visible tree selection are lower-level or future browser coverage. Happy: rename page and open renamed item. Error: invalid rename should keep feedback in dialog. Boundary: one rename immediately after create. Invalid: duplicate/reserved slug blocked by rename validation. Permission: requires Site page management access. Performance: tree updates without reload. Mobile: context-menu rename on narrow viewport remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-002 scenario creates a Pricing page, opens Rename from the page tree context menu, enters a Plans name, verifies the renamed row is visible, verifies the original row count is zero, and opens the renamed row. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 PAGE-003 Delete a site page safely As a site editor, I want page deletion to require an explicit confirmation so accidental destructive actions are visible before the page is removed. A disposable page can be deleted from the Site Explorer context menu only after the Delete page? alert dialog is shown and the Delete page button is clicked; after confirmation the page tree item is removed. Deleting the selected page; deleting homepage/system pages; cancelling the confirmation; stale selection after removal; double-submit; failed delete request; page references remaining after delete. The browser flow requires an alertdialog confirmation before the destructive action; page deletion goes through editor page actions and validated CMS persistence rather than direct tree mutation. tests/e2e/page-management.e2e.ts; src/admin/pages/site/explorer-actions/ExplorerItemContextMenu.tsx; src/admin/pages/site/panels/SiteExplorerPanel; src/admin/pages/site/store/slices/site/pageActions.ts; server/handlers/cms/pages.ts The regression deletes a freshly created non-home page; homepage/system-page protections and delete-cancel variants remain covered elsewhere or future exploratory coverage. Happy: create page, choose Delete, confirm, and verify row removal. Error: failed delete should keep recoverable UI. Boundary: newly created empty page. Invalid: protected page deletion rejected. Permission: requires page delete capability. Performance: tree item removal visible promptly. Mobile: delete confirmation on narrow viewport remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-003 scenario creates a Disposable page, opens the row context menu, clicks Delete, verifies the Delete page? alert dialog, confirms Delete page, and verifies the row count becomes zero. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 -PAGE-004 Switch pages without losing unsaved edits As a site editor, I want to switch between pages after making an unsaved edit so I can compare pages without losing in-memory draft work before I save. After inserting and editing Text on one page, the toolbar/status exposes Unsaved draft, switching to a second page hides that first page text, switching back shows the unsaved text again, and saving plus reloading preserves the text. Unsaved edits on multiple pages; save failure after page switching; stale active page after reload; missing selected page tree item; browser reload before save; session expiry; conflicting edits from another tab. Editor state keeps page drafts by page id until save; Save Draft persists through validated site/page APIs; canvas assertions read the active page iframe after each page selection. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/store/slices/saveTrackingSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/persistence/validate.ts; server/handlers/cms/pages.ts The automated case covers one unsaved text edit across two disposable pages, then explicit save and reload; crash recovery and multi-tab conflict handling remain separate reliability coverage. Happy: unsaved text survives page switch and persists after save/reload. Error: save failure should not falsely report persistence. Boundary: one page switch away and back. Invalid: corrupted page tree rejected by validation. Permission: authenticated Site edit access required. Performance: page switching and canvas updates complete within E2E timeout. Mobile: narrow editor page switching remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-004 scenario creates two pages, edits the first with unique Text, sees Unsaved draft, opens the second and verifies the text is absent, reopens the first and verifies the text plus Unsaved draft, then saves, reloads, reopens the page, and verifies the text persists. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 +PAGE-004 Switch pages without losing live edits As a site editor, I want to switch between pages after an edit so I can compare pages without losing work; edits stream to the collab relay, so there is no save step. After inserting and editing Text on one page, the toolbar/status exposes Draft synced, switching to a second page hides that first page text, switching back shows the text again, and reloading preserves it. Unsaved edits on multiple pages; save failure after page switching; stale active page after reload; missing selected page tree item; browser reload before save; session expiry; conflicting edits from another tab. Local mutations apply to the editor store and translate to Y operations that stream to the relay, which persists continuously; canvas assertions read the active page iframe after each page selection. tests/e2e/page-management.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/store/slices/site/collabBinding.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/collab/project.ts; server/collab/relay.ts The automated case covers one text edit across two disposable pages, then reload; crash recovery and multi-tab conflict handling remain separate reliability coverage. Happy: text survives page switch and persists across reload. Error: a relay disconnect should not falsely report a synced draft. Boundary: one page switch away and back. Invalid: corrupted page tree rejected by validation. Permission: authenticated Site edit access required. Performance: page switching and canvas updates complete within E2E timeout. Mobile: narrow editor page switching remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The PAGE-004 scenario creates two pages, edits the first with unique Text, sees Unsaved draft, opens the second and verifies the text is absent, reopens the first and verifies the text plus Unsaved draft, then saves, reloads, reopens the page, and verifies the text persists. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 EDIT-002 Button label and link authoring As a site editor, I want to add a button, set its label and destination, and publish it so visitors can follow the intended call-to-action link. The module picker can insert base.button, the Properties panel exposes label and href controls, Save Draft persists the values, Publish completes through the toolbar flow, and the visitor page renders a semantic link with the authored label and href. Missing href; invalid or unsafe URL; button inserted but wrong node selected; label cleared; publish step-up required; visitor anchor missing or rendered as a non-link button; stale public output. Module picker inserts a registered base.button node; property controls validate schema-backed props; publish uses the step-up-gated toolbar flow; public assertions verify rendered HTML in a fresh visitor context. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; tests/e2e/helpers/public.ts; src/modules/base/button; src/admin/pages/site/property-controls/PropertyControlRenderer.tsx; server/publish/publicRenderer.ts The regression uses https://example.com as the destination and checks published output; deeper invalid URL copy, target/rel variants, and permission-persona coverage are tracked separately. Happy: insert button, set label and href, save, publish, and verify visitor anchor. Error: publish or save failure should surface through toolbar state. Boundary: one external https URL. Invalid: unsafe URL rejected or sanitized by module/publisher rules. Permission: editing and publish capabilities plus step-up. Performance: publish completes within E2E timeout. Mobile: public mobile button layout remains separate responsive coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The EDIT-002 scenario logs in from an anonymous state, creates a disposable page, inserts base.button, sets label Visit Example and href https://example.com, saves, publishes, then opens the visitor route and verifies a visible link with an href matching example.com. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 BUILDER-001 Insert common visual modules As a site editor, I want to add common modules from the canvas controls so I can build a page with layout, text, media, and interactive elements. On a fresh disposable page, the canvas notch inserts Container, Text, and Image modules, and the Layers tree shows each module row after insertion. The full picker can also insert Button by search/keyboard path, proving non-favorite modules remain reachable. Module favorites missing from notch; module picker unavailable; insert target ambiguous; no selected node after insertion; empty image placeholder; capability-hidden insert controls; tablet-width insertion edge; picker search or keyboard insert mismatch. Canvas notch buttons and picker items dispatch registered module inserts through the editor store; inserted nodes must satisfy module schema defaults and render in the Page element tree. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/canvas/CanvasInsertModuleButton.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/hooks/useInsertModule.ts; src/modules/base/container; src/modules/base/text; src/modules/base/image; src/modules/base/button The primary automated case covers the three notch favorites; SITE-005 covers Button insertion through picker search/keyboard; the same visual-builder run separately proves tablet-width Text insertion. Happy: insert Container, Text, Image, and picker-searched Button and verify layer rows. Error: insert failure should leave no false row. Boundary: empty fresh page and non-favorite module path. Invalid: unknown module ids blocked by picker/store. Permission: Site structure edit required. Performance: rows appear within E2E timeout. Mobile: tablet-width insertion is covered by the responsive BUILDER-003 check; phone-width non-favorite picker insertion is covered by SITE-005. Focused page-management and visual-builder Playwright regression passed 2026-06-23; SITE-005 desktop/mobile picker-search regressions passed 2026-06-23 0 None Verification: `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-005"` passed 3/3 including setup, and full `visual-builder.e2e.ts` passed 22/22 including setup. BUILDER-001 uses canvas notch buttons for Container/Text/Image; SITE-005 searches and keyboard-inserts Button through the full picker on desktop and at 390px. Run logs: docs/e2e/runs/2026-06-23-page-builder-management.md; docs/e2e/runs/2026-06-23-site005-module-picker.md; docs/e2e/runs/2026-06-23-site005-mobile-module-picker.md. 2026-06-23 BUILDER-002 Select canvas nodes and edit properties As a site editor, I want selection to drive the Properties panel so changing a field updates the intended node and not a previously selected element. After inserting Text and setting an initial headline, inserting Image moves the Properties panel to the image src control; selecting the Text row from Layers restores text controls, editing text updates the canvas, and the old headline disappears. Selection lost after insertion; wrong tree row selected; property panel still bound to previous node; duplicate layer names; hidden/locked nodes; stale iframe text after update. Layer selection updates the editor selection store; property controls are rendered for the selected module schema; text prop edits mutate the active tree and rerender the canvas iframe. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/panels/DomPanel; src/admin/pages/site/store/slices/selectionSlice.ts; src/admin/pages/site/property-controls/PropertyControlRenderer.tsx; src/modules/base/text The regression uses the first Text layer name and one Image insertion to prove selection moves; multi-select and locked-region behavior are covered by other builder/editor tests. Happy: select Text from Layers and edit text. Error: missing property control fails visibly. Boundary: two modules with selection handoff. Invalid: schema-invalid property values rejected by controls/store. Permission: edit capability required. Performance: canvas text updates promptly. Mobile: selection on narrow editor remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-002 scenario inserts Text, sets Selectable headline, inserts Image and sees the src property control, selects Text in the Layers tree, sets Edited headline, verifies edited text is visible, and verifies the old headline is gone. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 diff --git a/server/handlers/cms/data/rows.ts b/server/handlers/cms/data/rows.ts index b9cf7a2ce..af79fe117 100644 --- a/server/handlers/cms/data/rows.ts +++ b/server/handlers/cms/data/rows.ts @@ -36,6 +36,7 @@ import { updateDataRowTable, } from '../../../repositories/data' import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow' +import { runPublishFlush } from '../../../publish/publishFlush' import { findUserById } from '../../../repositories/users' import { slugForTable } from '@core/data/cells' import { lockedBuiltInCellKey } from '@core/data/systemTableGuard' @@ -266,6 +267,12 @@ async function handleRowSchedulePost( const user = await requireDataPublisher(req, db) if (user instanceof Response) return user + // Flush the collab relay before reading the row, exactly as `publishDataRow` + // does. A page created or edited in the visual editor lives in the relay's + // in-memory doc until the persist debounce elapses, so scheduling one right + // after creating it would otherwise 404 with "Data row not found". + await runPublishFlush() + const currentRow = await loadRowForAccess(db, rowId, user, canPublishDataRow) if (currentRow instanceof Response) return currentRow diff --git a/src/__tests__/server/cmsDataAuthorization.test.ts b/src/__tests__/server/cmsDataAuthorization.test.ts index d80f16696..37e915b8c 100644 --- a/src/__tests__/server/cmsDataAuthorization.test.ts +++ b/src/__tests__/server/cmsDataAuthorization.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'bun:test' import { handleCmsRequest } from '../../../server/handlers/cms' import type { DbClient } from '../../../server/db' import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { registerPublishFlush } from '../../../server/publish/publishFlush' +import { upsertDataRowDraft } from '../../../server/repositories/data' const ownedPassword = 'long-enough-password' @@ -327,6 +329,48 @@ describe('CMS data ownership authorization', () => { expect(await body(reassign)).toMatchObject({ row: { authorUserId: managerId } }) }) + it('flushes the collab relay before reading the row to schedule', async () => { + // A page created in the visual editor lives only in the relay's in-memory + // doc until its persist debounce elapses. Scheduling one right after + // creating it used to 404 with "Data row not found" because this handler + // read the DB directly — publish flushes, so "publish later" must too. + const { db } = await makeDb() + const ownerCookie = await setupOwner(db) + + const relayResidentId = 'relay-resident-row' + let flushed = false + // Stands in for the relay persisting a doc that has no DB row yet. The + // handler's own flush is the ONLY thing that can make this row exist. + const detach = registerPublishFlush(async () => { + flushed = true + await upsertDataRowDraft( + db, + { + id: relayResidentId, + tableId: 'posts', + cells: { title: 'Relay-resident page' }, + slug: 'relay-resident-page', + }, + null, + { collabInternal: true }, + ) + }) + cleanupFns.push(async () => detach()) + + const scheduledAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const schedule = await request(db, `/admin/api/cms/data/rows/${relayResidentId}/schedule`, { + method: 'POST', + cookie: ownerCookie, + body: JSON.stringify({ at: scheduledAt }), + }) + + expect(flushed).toBe(true) + expect(schedule.status).toBe(200) + expect(await body(schedule)).toMatchObject({ + row: { id: relayResidentId, status: 'scheduled' }, + }) + }) + it('schedules and cancels row publication only through the schedule endpoint', async () => { const { db } = await makeDb() const ownerCookie = await setupOwner(db) diff --git a/tests/e2e/accessibility.e2e.ts b/tests/e2e/accessibility.e2e.ts index cc4edb145..e2d7efd97 100644 --- a/tests/e2e/accessibility.e2e.ts +++ b/tests/e2e/accessibility.e2e.ts @@ -10,7 +10,6 @@ import { login, openSiteEditor, publishDraft, - saveDraft, setPropValue, visitPublicPage, } from './helpers' @@ -106,7 +105,6 @@ test.describe('public responsive', () => { await insertNotchModule(page, 'text') await setPropValue(page, 'text', text) await expect(canvasFrame(page).getByText(text)).toBeVisible() - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { diff --git a/tests/e2e/background-image-smoke.e2e.ts b/tests/e2e/background-image-smoke.e2e.ts index dc53efcd6..a1f273a1b 100644 --- a/tests/e2e/background-image-smoke.e2e.ts +++ b/tests/e2e/background-image-smoke.e2e.ts @@ -11,7 +11,6 @@ import { openSiteEditor, openSitePanel, publishDraft, - saveDraft, setPropValue, } from './helpers' @@ -74,7 +73,6 @@ test.describe('background image smoke', () => { expect(editorBackground).not.toContain('.png') await page.screenshot({ path: `${proofDir}/02-editor-background.png`, fullPage: true }) - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) diff --git a/tests/e2e/capabilities.e2e.ts b/tests/e2e/capabilities.e2e.ts index cb3248d56..0398af3d6 100644 --- a/tests/e2e/capabilities.e2e.ts +++ b/tests/e2e/capabilities.e2e.ts @@ -10,7 +10,6 @@ import { openLayersPanel, openSitePanel, openSiteEditor, - saveDraft, setPropValue, canvasFrame, insertNotchModule, @@ -102,7 +101,6 @@ test.describe.serial('capability boundaries', () => { personaPage.getByText('Styles are read-only for your role'), ).toBeVisible() - await saveDraft(personaPage) await personaPage.reload() await openNamedPage(personaPage, pageName) await expect( @@ -132,7 +130,6 @@ test.describe.serial('capability boundaries', () => { await fontSizeInput.blur() await expect(editableText).toHaveCSS('font-size', '23px') - await saveDraft(personaPage) await personaPage.reload() await openNamedPage(personaPage, pageName) const reloadedText = canvasFrame(personaPage).getByText(contentText, { exact: true }) @@ -158,7 +155,6 @@ test.describe.serial('capability boundaries', () => { await insertNotchModule(personaPage, 'text') await expect(tree.getByRole('treeitem', { name: 'Text' })).toHaveCount(3) - await saveDraft(personaPage) await personaPage.reload() await openNamedPage(personaPage, pageName) await openLayersPanel(personaPage) @@ -1064,7 +1060,6 @@ async function seedCapabilityPage( await setPropValue(page, 'text', seededText) await insertNotchModule(page, 'text') await setPropValue(page, 'text', secondText) - await saveDraft(page) return pageName } diff --git a/tests/e2e/content.e2e.ts b/tests/e2e/content.e2e.ts index b837dd431..c6f54c957 100644 --- a/tests/e2e/content.e2e.ts +++ b/tests/e2e/content.e2e.ts @@ -10,7 +10,6 @@ import { openSiteEditor, openSitePanel, publishDraft, - saveDraft, setPropValue, visitPublicPage, } from './helpers' @@ -423,7 +422,6 @@ test.describe('content', () => { }) await test.step('publish the template and verify the anonymous public post route resolves the custom token', async () => { - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { path: `/posts/${postSlug}`, @@ -583,7 +581,6 @@ async function createPublishedPostsTemplate( await page.getByRole('option', { name: 'Heading 1', exact: true }).click() await expect(page.locator('#ctrl-tag')).toHaveValue('Heading 1') await insertModuleViaPicker(page, 'base.outlet') - await saveDraft(page) await publishDraft(page) } diff --git a/tests/e2e/core-owner-lifecycle.e2e.ts b/tests/e2e/core-owner-lifecycle.e2e.ts index d4f2f34da..8b3a0f462 100644 --- a/tests/e2e/core-owner-lifecycle.e2e.ts +++ b/tests/e2e/core-owner-lifecycle.e2e.ts @@ -8,7 +8,6 @@ import { logout, openSiteEditor, publishDraft, - saveDraft, selectTreeLayer, setPropValue, visitPublicPage, @@ -49,7 +48,6 @@ test.describe('core owner lifecycle', () => { await insertNotchModule(page, 'text') await setPropValue(page, 'text', PUBLISHED_TEXT) await expect(canvasFrame(page).getByText(PUBLISHED_TEXT)).toBeVisible() - await saveDraft(page) }) await test.step('reload and confirm draft persistence (SAVE-001)', async () => { @@ -70,7 +68,6 @@ test.describe('core owner lifecycle', () => { await selectTreeLayer(page, 'Text') await setPropValue(page, 'text', DRAFT_ONLY_TEXT) await expect(canvasFrame(page).getByText(DRAFT_ONLY_TEXT)).toBeVisible() - await saveDraft(page) // The unpublished edit must not leak: the public page still shows the // last published headline, not the new draft-only text. diff --git a/tests/e2e/forms.e2e.ts b/tests/e2e/forms.e2e.ts index 02ebc7bde..5e6f03f71 100644 --- a/tests/e2e/forms.e2e.ts +++ b/tests/e2e/forms.e2e.ts @@ -9,7 +9,6 @@ import { openSiteEditor, openSitePanel, publishDraft, - saveDraft, setPropValue, } from './helpers' @@ -56,7 +55,6 @@ test.describe('forms', () => { await insertModuleViaPicker(page, 'base.form-message') await selectPropertyOption(page, 'kind', 'Success') - await saveDraft(page) await publishDraft(page) return created }) @@ -115,7 +113,6 @@ test.describe('forms', () => { await setPropValue(page, 'formId', formId) await selectPropertyOption(page, 'kind', 'Error') - await saveDraft(page) await publishDraft(page) return created }) diff --git a/tests/e2e/helpers/editor.ts b/tests/e2e/helpers/editor.ts index c39548e2c..16356f3f7 100644 --- a/tests/e2e/helpers/editor.ts +++ b/tests/e2e/helpers/editor.ts @@ -166,28 +166,6 @@ export async function createPage( ).toBeVisible() } -/** Save the current draft and wait for the "Draft saved" status. */ -export async function saveDraft(page: Page): Promise { - const trigger = page.getByTestId('toolbar-publish-actions-trigger') - const saveAction = page.getByTestId('toolbar-save-draft-action') - for (let attempt = 0; attempt < 2; attempt += 1) { - await trigger.click() - const opened = await saveAction.isVisible({ timeout: 1_000 }).catch(() => false) - if (opened) break - await page.keyboard.press('Escape') - } - await expect(saveAction).toBeVisible() - const saveResponse = page.waitForResponse((response) => - new URL(response.url()).pathname === '/admin/api/cms/site-document' && - response.request().method() === 'PUT', - ) - await saveAction.click() - expect((await saveResponse).ok()).toBe(true) - await expect(page.getByRole('status', { name: 'Draft saved' })).toBeVisible({ - timeout: 20_000, - }) -} - /** * Publish the current draft. Publishing is a sensitive action that may require * a fresh-password step-up; this satisfies the prompt with the owner password diff --git a/tests/e2e/media.e2e.ts b/tests/e2e/media.e2e.ts index 9813abb80..c84db49ab 100644 --- a/tests/e2e/media.e2e.ts +++ b/tests/e2e/media.e2e.ts @@ -9,7 +9,6 @@ import { openExplorerTab, openSiteEditor, publishDraft, - saveDraft, } from './helpers' /** A minimal but valid 1×1 PNG — enough for the server's magic-byte check. */ @@ -75,7 +74,6 @@ test.describe('media', () => { canvasFrame(page).locator('img[src*="/uploads/"]').first(), ).toBeVisible() - await saveDraft(page) await publishDraft(page) // The visitor-facing page serves and decodes the same uploaded image. @@ -179,7 +177,6 @@ test.describe('media', () => { await expect(canvasFrame(page).locator('img[src*="/uploads/"]').first()).toBeVisible() - await saveDraft(page) await publishDraft(page) await visitPublishedMediaPage(browser, slug) }) diff --git a/tests/e2e/page-management.e2e.ts b/tests/e2e/page-management.e2e.ts index 3cf706279..f461a97a4 100644 --- a/tests/e2e/page-management.e2e.ts +++ b/tests/e2e/page-management.e2e.ts @@ -5,7 +5,6 @@ import { insertNotchModule, openSiteEditor, openSitePanel, - saveDraft, setPropValue, } from './helpers' @@ -84,13 +83,13 @@ test.describe('page management', () => { }) }) - test('keeps unsaved edits when switching pages and persists after save (PAGE-004)', async ({ + test('keeps edits when switching pages and persists across a reload (PAGE-004)', async ({ page, }) => { const suffix = Date.now().toString(36) const firstName = `Draft Source ${suffix}` const secondName = `Draft Target ${suffix}` - const draftText = `Unsaved page switch ${suffix}` + const draftText = `Page switch draft ${suffix}` await openSiteEditor(page) await createPage(page, firstName, `draft-source-${suffix}`) @@ -99,26 +98,27 @@ test.describe('page management', () => { const firstPage = page.getByRole('treeitem', { name: `Open page ${firstName}` }) const secondPage = page.getByRole('treeitem', { name: `Open page ${secondName}` }) - await test.step('edit the first page and observe the unsaved draft state', async () => { + // There is no save step and no dirty state: edits stream to the collab + // relay as they land, so the toolbar reports a live sync, not "Unsaved". + await test.step('edit the first page and observe the synced state', async () => { await openPage(firstPage) await insertNotchModule(page, 'text') await setPropValue(page, 'text', draftText) - await expect(page.getByRole('status', { name: 'Unsaved draft' })).toBeVisible() + await expect(page.getByRole('status', { name: 'Draft synced' })).toBeVisible() await expect(canvasFrame(page).getByText(draftText)).toBeVisible() }) - await test.step('switch away and back without losing the in-memory edit', async () => { + await test.step('switch away and back without losing the edit', async () => { await openSitePanel(page) await openPage(secondPage) await expect(canvasFrame(page).getByText(draftText)).toHaveCount(0) await openPage(firstPage) await expect(canvasFrame(page).getByText(draftText)).toBeVisible() - await expect(page.getByRole('status', { name: 'Unsaved draft' })).toBeVisible() + await expect(page.getByRole('status', { name: 'Draft synced' })).toBeVisible() }) - await test.step('save, reload, and verify the edit persists', async () => { - await saveDraft(page) + await test.step('reload and verify the edit persists', async () => { await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -127,7 +127,7 @@ test.describe('page management', () => { }) }) - test('reloads a saved draft at mobile width (SAVE-001)', async ({ page }) => { + test('reloads a synced draft at mobile width (SAVE-001)', async ({ page }) => { const suffix = Date.now().toString(36) const name = `Mobile Draft ${suffix}` const draftText = `Mobile reload persistence ${suffix}` @@ -137,7 +137,6 @@ test.describe('page management', () => { await insertNotchModule(page, 'text') await setPropValue(page, 'text', draftText) await expect(canvasFrame(page).getByText(draftText)).toBeVisible() - await saveDraft(page) await page.setViewportSize({ width: 390, height: 844 }) await page.reload() @@ -166,7 +165,6 @@ test.describe('page management', () => { await createPage(page, name, slug) await insertNotchModule(page, 'text') await setPropValue(page, 'text', draftText) - await saveDraft(page) await test.step('schedule the active page from the Site toolbar', async () => { await page.getByTestId('toolbar-publish-actions-trigger').click() diff --git a/tests/e2e/performance.e2e.ts b/tests/e2e/performance.e2e.ts index 63448d1e2..8d83a597f 100644 --- a/tests/e2e/performance.e2e.ts +++ b/tests/e2e/performance.e2e.ts @@ -9,7 +9,6 @@ import { login, openSiteEditor, publishDraft, - saveDraft, setPropValue, visitPublicPage, } from './helpers' @@ -85,7 +84,6 @@ test.describe('performance and reliability', () => { await expect(frame.getByRole('link', { name: ctaLabel })).toBeVisible() await expect(frame.locator('img[src*="/uploads/"]').first()).toBeVisible() - await saveDraft(page) const publishStartedAt = Date.now() await publishDraft(page) const publishMs = Date.now() - publishStartedAt diff --git a/tests/e2e/preview-live.e2e.ts b/tests/e2e/preview-live.e2e.ts index 5d0e3229e..ec4a28cb7 100644 --- a/tests/e2e/preview-live.e2e.ts +++ b/tests/e2e/preview-live.e2e.ts @@ -8,7 +8,6 @@ import { login, openSiteEditor, publishDraft, - saveDraft, selectTreeLayer, setPropValue, visitPublicPage, @@ -36,7 +35,6 @@ test.describe('preview overlay and live page opening', () => { await insertNotchModule(page, 'text') await setPropValue(page, 'text', liveText) await expect(canvasFrame(page).getByText(liveText)).toBeVisible() - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { path: publicPath, @@ -45,12 +43,11 @@ test.describe('preview overlay and live page opening', () => { }) }) - await test.step('save a later draft without publishing it', async () => { + await test.step('make a later draft edit without publishing it', async () => { await openLayersPanel(page) await selectTreeLayer(page, 'Text') await setPropValue(page, 'text', draftText) await expect(canvasFrame(page).getByText(draftText)).toBeVisible() - await saveDraft(page) }) await test.step('preview shows the current draft', async () => { diff --git a/tests/e2e/public-dynamic-fragments.e2e.ts b/tests/e2e/public-dynamic-fragments.e2e.ts index 5ab69dd28..d1260231e 100644 --- a/tests/e2e/public-dynamic-fragments.e2e.ts +++ b/tests/e2e/public-dynamic-fragments.e2e.ts @@ -8,7 +8,6 @@ import { login, openSiteEditor, publishDraft, - saveDraft, setPropValue, } from './helpers' @@ -43,7 +42,6 @@ test.describe('public dynamic fragments', () => { await setPropValue(page, 'text', authoredText) await expect(canvasFrame(page).getByText(/Dynamic result:/)).toBeVisible() - await saveDraft(page) await publishDraft(page) await expectDynamicFragmentVisitor(browser, { diff --git a/tests/e2e/reliability.e2e.ts b/tests/e2e/reliability.e2e.ts index 0f72bdd39..fbaab3998 100644 --- a/tests/e2e/reliability.e2e.ts +++ b/tests/e2e/reliability.e2e.ts @@ -6,7 +6,6 @@ import { insertNotchModule, openSiteEditor, openSitePanel, - saveDraft, setPropValue, } from './helpers' @@ -28,7 +27,6 @@ test.describe('reliability', () => { await insertNotchModule(page, 'text') await setPropValue(page, 'text', text) await expect(canvasFrame(page).getByText(text, { exact: true })).toBeVisible() - await saveDraft(page) await page.reload() await expectEditorReady(page) diff --git a/tests/e2e/runtime-dependencies.e2e.ts b/tests/e2e/runtime-dependencies.e2e.ts index ee38553c5..a28468015 100644 --- a/tests/e2e/runtime-dependencies.e2e.ts +++ b/tests/e2e/runtime-dependencies.e2e.ts @@ -9,7 +9,6 @@ import { openCodePanel, openSiteEditor, publishDraft, - saveDraft, } from './helpers' const ImportmapSchema = Type.Object({ @@ -69,7 +68,6 @@ document.body.append(marker) await expect(dependenciesPanel.getByTestId('dep-row-canvas-confetti')).toBeVisible() await expect(dependenciesPanel.getByText('1 locked')).toBeVisible({ timeout: 75_000 }) - await saveDraft(page) await publishDraft(page) await verifyPublishedRuntimeDependency({ diff --git a/tests/e2e/site-files.e2e.ts b/tests/e2e/site-files.e2e.ts index a7fd7bbba..1a01db262 100644 --- a/tests/e2e/site-files.e2e.ts +++ b/tests/e2e/site-files.e2e.ts @@ -7,7 +7,6 @@ import { openCodePanel, openSiteEditor, publishDraft, - saveDraft, } from './helpers' test.describe('site files and code editor', () => { @@ -33,7 +32,6 @@ html body { } `) - await saveDraft(page) await publishDraft(page) const context = await browser.newContext() diff --git a/tests/e2e/visual-builder.e2e.ts b/tests/e2e/visual-builder.e2e.ts index ec37b2987..1713c3399 100644 --- a/tests/e2e/visual-builder.e2e.ts +++ b/tests/e2e/visual-builder.e2e.ts @@ -12,7 +12,6 @@ import { openSitePanel, openSiteEditor, publishDraft, - saveDraft, selectTreeLayer, setPropValue, visitPublicPage, @@ -171,7 +170,6 @@ test.describe('visual builder', () => { page, }) => { const { name } = await openBlankPage(page, 'Builder history') - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -214,7 +212,6 @@ test.describe('visual builder', () => { await expect(textNode).toHaveCount(0) await expect(redoButton).toHaveAttribute('aria-disabled', 'true') - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -358,7 +355,6 @@ test.describe('visual builder', () => { await dragTreeRowBefore(page, textRows.nth(2), textRows.nth(0)) await expectCanvasTextOrder(page, [gamma, alpha, beta]) - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -423,7 +419,6 @@ test.describe('visual builder', () => { await setPropValue(page, 'label', 'Visit Example') await setPropValue(page, 'href', 'https://example.com') - await saveDraft(page) await publishDraft(page) // The published button renders as a semantic anchor with the intended @@ -485,7 +480,6 @@ test.describe('visual builder', () => { await expect(canvasFrame(page).getByText(componentText, { exact: true })).toBeVisible() await expect(canvasFrame(page).getByText(slotText, { exact: true })).toBeVisible() - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { path: `/${slug}`, @@ -559,8 +553,7 @@ test.describe('visual builder', () => { await expect(canvasFrame(page).getByText(bodyText, { exact: true })).toBeVisible() }) - await test.step('save and publish the template snapshot', async () => { - await saveDraft(page) + await test.step('publish the template snapshot', async () => { await publishDraft(page) }) @@ -640,7 +633,6 @@ test.describe('visual builder', () => { }) await test.step('insert the layout from the module inserter on another page', async () => { - await saveDraft(page) const nextPage = await openBlankPage(page, 'Builder layout target') target.name = nextPage.name target.slug = nextPage.slug @@ -690,7 +682,6 @@ test.describe('visual builder', () => { await expect(inserterDialog).toBeHidden() await expect(canvasFrame(page).getByText(layoutText, { exact: true })).toBeVisible() - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -747,7 +738,6 @@ test.describe('visual builder', () => { await fontSizeInput.blur() await expect(canvasHeadline).toHaveCSS('font-size', '28px') - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -807,7 +797,6 @@ test.describe('visual builder', () => { await fontSizeInput.blur() await expect(canvasHeadline).toHaveCSS('font-size', '30px') - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -870,7 +859,6 @@ test.describe('visual builder', () => { 'Event handler attributes are not allowed.', ) - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -927,7 +915,6 @@ test.describe('visual builder', () => { await fontSizeInput.fill('31px') await fontSizeInput.blur() - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { path: `/${slug}`, @@ -984,7 +971,6 @@ test.describe('visual builder', () => { await expect(mobileButton).toHaveCSS('font-size', '33px') await expect(desktopButton).toHaveCSS('font-size', '20px') - await saveDraft(page) await publishDraft(page) await visitPublicPage(browser, { path: `/${slug}`, @@ -1066,7 +1052,6 @@ test.describe('visual builder', () => { await expect(desktopButton).toHaveClass(new RegExp(`\\b${classA}\\b`)) await expect(desktopButton).toHaveClass(new RegExp(`\\b${classB}\\b`)) - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -1130,7 +1115,6 @@ test.describe('visual builder', () => { ).toBeVisible() await expectComputedCustomProperty(desktopButton, customProperty, customValue) - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) @@ -1198,7 +1182,6 @@ test.describe('visual builder', () => { await paddingTopInput.blur() await expect(desktopButton).toHaveCSS('padding-top', '12px') - await saveDraft(page) await page.reload() await openSiteEditor(page) await openSitePanel(page) From cf051ea6eefe1bce41d20a55f905dec24a5731c6 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 16:03:11 +0200 Subject: [PATCH 40/49] fix(collab): make relay eviction, reset, and derived-JSON persistence race-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset seam is how every out-of-relay write — a Settings save, Super Import, a plugin pack install, a data-workspace row edit — reaches connected editors. Three races let a reset silently do nothing, or take live data with it. 1. `evict({persist:false})` did not await `persistChain`. A persist already past its `dirty` check would resolve AFTER `deleteCollabDocuments` and re-insert the blob by upsert, resurrecting the generation the reset had just deleted — so clients rebound onto stale state and the out-of-relay write was invisible. `evict` now awaits the chain whether or not it is persisting, re-checks the timer after the await, and `openDoc` waits on a per-doc `settling` promise so it can neither hand back a doc that is being destroyed nor hydrate from a blob the reset is still deleting. 2. `resetDocs` reseeds the site doc's rosters from `listDataRowIdSlugs`, so a page that existed only inside another doc's persist debounce was absent from the DB, vanished from the reseeded roster, and would then be soft-deleted by the next sweep. Dirty docs that are NOT being reset are now flushed first. The reset docs themselves are still deliberately not flushed: the out-of-relay write that triggered the reset already committed and must win. 3. Eviction dropped the ref count, so after a reset `openDoc` restarted at 0 while N connections still listed the doc in `boundDocs` — the first close drove refs negative and evicted a doc other editors were writing. The counts are now re-registered, and `retain` no longer asserts on a registry entry a concurrent reset may have removed (it re-opens until the doc it returns is the one whose refs it incremented). Also: `persistDerivedJson` now reports its outcome, and a shell that fails `validateSite` marks the doc dirty again. The blob was being written while the derived JSON stayed stale, and a reset reseeds from that JSON — which is how an accepted page could silently disappear. `incomplete` (a doc not assembled yet) still does not retry, so there is no spin. Both new tests fail without this change and pass with it. The first holds a blob write mid-flight through an injected DbClient gate — the only way to reproduce the window deterministically rather than hoping a timer lands. --- server/collab/relay.ts | 133 +++++++++++++++++++---- src/__tests__/server/collabRelay.test.ts | 107 +++++++++++++++++- 2 files changed, 216 insertions(+), 24 deletions(-) diff --git a/server/collab/relay.ts b/server/collab/relay.ts index 07eba9972..b6b4640e1 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -91,6 +91,8 @@ interface RelayEntry { detachUpdateHandler: () => void } +type DerivedWrite = 'written' | 'incomplete' | 'invalid' + export type RelayUpdateListener = (docId: string, update: Uint8Array, origin: unknown) => void export type RelayResetListener = (docId: string) => void @@ -115,6 +117,12 @@ export function createCollabRelay( const persistDebounceMs = opts.persistDebounceMs ?? 800 const entries = new Map() const opening = new Map>() + /** + * Docs mid-eviction or mid-reset. `openDoc` waits these out, so it can never + * hand back a doc that is about to be destroyed, nor resurrect one whose + * blob a reset is still deleting. + */ + const settling = new Map>() // Last roster set the site-doc persist actually swept, so shell-field-only // persists skip the three full-table scans. Cleared when the site doc resets. let lastSweptRostersKey: string | null = null @@ -156,12 +164,19 @@ export function createCollabRelay( // ── Persistence ─────────────────────────────────────────────────────────── - async function persistDerivedJson(docId: string, doc: Y.Doc): Promise { + /** + * Outcome of a derived-JSON write. `invalid` is the one that MUST be + * retried: the blob was written but the JSON the publisher (and a reseed) + * read is now stale, so leaving the doc clean is how an accepted edit + * silently disappears. `incomplete` is a doc that is simply not assembled + * yet — retrying that would spin forever. + */ + async function persistDerivedJson(docId: string, doc: Y.Doc): Promise { const parsed = parseCollabDocId(docId) - if (!parsed) return + if (!parsed) return 'incomplete' if (parsed.kind === 'site') { const projected = projectSiteDoc(doc) - if (Object.keys(projected.shell).length === 0) return // never seeded + if (Object.keys(projected.shell).length === 0) return 'incomplete' // never seeded let shell: SiteShell try { // `id` and `updatedAt` are deliberately NOT collaborative (fixed row / @@ -176,7 +191,7 @@ export function createCollabRelay( // The blob stays authoritative; JSON write is skipped until the doc // heals — never persist an invalid shell for the publisher to read. console.error('[collab] projected shell failed validation — JSON write skipped:', err) - return + return 'invalid' } await saveDraftSite(db, shell, null, { collabInternal: true }) @@ -190,7 +205,7 @@ export function createCollabRelay( projected.rosters.pages.join(',') + '|' + projected.rosters.components.join(',') + '|' + projected.rosters.layouts.join(',') - if (rostersKey === lastSweptRostersKey) return + if (rostersKey === lastSweptRostersKey) return 'written' let deletedPublished = false for (const [table, ids] of [ ['pages', projected.rosters.pages], @@ -207,7 +222,7 @@ export function createCollabRelay( } lastSweptRostersKey = rostersKey if (deletedPublished) await bumpPublishVersionSerialized() - return + return 'written' } const table = KIND_TABLE[parsed.kind] @@ -215,17 +230,17 @@ export function createCollabRelay( let slug: string if (parsed.kind === 'page') { const page = projectPageDoc(doc, parsed.rowId) - if (!page.rootNodeId) return // never seeded / still assembling + if (!page.rootNodeId) return 'incomplete' // never seeded / still assembling cells = pageToCells(page) slug = page.slug } else if (parsed.kind === 'component') { const vc = projectComponentDoc(doc, parsed.rowId) - if (!vc.tree.rootNodeId || typeof vc.name !== 'string' || vc.name === '') return + if (!vc.tree.rootNodeId || typeof vc.name !== 'string' || vc.name === '') return 'incomplete' cells = visualComponentToCells(vc) slug = vcSlugFromName(vc.name) } else { const layout = projectLayoutDoc(doc, parsed.rowId) - if (!layout.rootNodeId || layout.name === '') return + if (!layout.rootNodeId || layout.name === '') return 'incomplete' cells = savedLayoutToCells(layout) slug = layoutSlugFromName(layout.name) } @@ -240,6 +255,7 @@ export function createCollabRelay( null, { collabInternal: true }, ) + return 'written' } async function persistNow(docId: string): Promise { @@ -248,7 +264,11 @@ export function createCollabRelay( entry.dirty = false try { await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc)) - await persistDerivedJson(docId, entry.doc) + const derived = await persistDerivedJson(docId, entry.doc) + // A shell that failed validation MUST be retried: the blob is fresh but + // the derived JSON is stale, and a reset reseeds from that JSON. Leaving + // the doc clean here is how an accepted page silently disappears. + if (derived === 'invalid') entry.dirty = true } catch (err) { entry.dirty = true // retry on the next schedule console.error(`[collab] persist failed for ${docId}:`, err) @@ -275,6 +295,11 @@ export function createCollabRelay( if (inFlight) return inFlight const open = (async () => { + // Never step on a doc that is being torn down: an eviction still has a + // persist in flight (whose blob write would resurrect state a reset is + // deleting), and a reset has not finished deleting the blob we would + // otherwise hydrate from — keeping the dead generation alive. + await settling.get(docId) const doc = new Y.Doc() const blob = await getCollabDocumentState(db, docId) if (blob) { @@ -310,17 +335,33 @@ export function createCollabRelay( async function evict(docId: string, opts2: { persist: boolean }): Promise { const entry = entries.get(docId) if (!entry) return - if (entry.persistTimer) { - clearTimeout(entry.persistTimer) - entry.persistTimer = null - } - if (opts2.persist) { - entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + const run = (async () => { + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + // Await the chain even when NOT persisting: a persist already past its + // `dirty` check would otherwise resolve after `resetDocs` deleted the + // row and re-insert the dead blob via upsert, undoing the reset. + entry.persistChain = entry.persistChain.then(() => + opts2.persist ? persistNow(docId) : undefined, + ) await entry.persistChain + // An update may have landed during that await and scheduled a new timer. + if (entry.persistTimer) { + clearTimeout(entry.persistTimer) + entry.persistTimer = null + } + entry.detachUpdateHandler() + entry.doc.destroy() + entries.delete(docId) + })() + settling.set(docId, run.then(() => undefined, () => undefined)) + try { + await run + } finally { + settling.delete(docId) } - entry.detachUpdateHandler() - entry.doc.destroy() - entries.delete(docId) } async function resetDocs(docIds: readonly string[]): Promise { @@ -329,10 +370,48 @@ export function createCollabRelay( // The site doc reseeds from the DB on next bind — force a full roster // sweep on its first persist afterwards. if (affected.includes(SITE_DOC_ID)) lastSweptRostersKey = null + + // Flush the docs we are NOT resetting first. The site doc reseeds its + // rosters from `listDataRowIdSlugs`, so a page whose row-doc JSON is still + // inside the debounce window would not exist in the DB, would vanish from + // the reseeded roster, and would then be soft-deleted by the next sweep. + // The reset docs themselves are deliberately NOT flushed: the out-of-relay + // write that triggered the reset already committed and must win. + for (const docId of [...entries.keys()]) { + if (affected.includes(docId)) continue + const entry = entries.get(docId) + if (!entry?.dirty) continue + entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + await entry.persistChain + } + + const heldRefs = new Map() for (const docId of affected) { + const refs = entries.get(docId)?.refs ?? 0 + if (refs > 0) heldRefs.set(docId, refs) await evict(docId, { persist: false }) } - await deleteCollabDocuments(db, affected) + + // Hold the door shut across the delete: an in-flight frame from a still + // connected editor would otherwise re-open the doc from the not-yet-deleted + // blob and the delete would land on a live doc. + const deletion = deleteCollabDocuments(db, affected).then(() => undefined) + for (const docId of affected) settling.set(docId, deletion) + try { + await deletion + } finally { + for (const docId of affected) settling.delete(docId) + } + + // Re-register the ref counts the eviction dropped. Without this the next + // `openDoc` starts at 0 while N connections still list the doc in + // `boundDocs`, so the first close drives refs negative and evicts a doc + // other editors are actively writing. + for (const [docId, refs] of heldRefs) { + await openDoc(docId) + const reopened = entries.get(docId) + if (reopened) reopened.refs = refs + } for (const docId of affected) { for (const listener of resetListeners) listener(docId) } @@ -376,9 +455,17 @@ export function createCollabRelay( return { openDoc, retain: async (docId) => { - const doc = await openDoc(docId) - entries.get(docId)!.refs += 1 - return doc + // A reset can evict between `openDoc` resolving and the registry read, + // which would both crash on a non-null assertion and hand back a doc + // that was just destroyed. Re-open until the doc we return is the one + // whose refs we incremented. + for (;;) { + await openDoc(docId) + const entry = entries.get(docId) + if (!entry) continue + entry.refs += 1 + return entry.doc + } }, release: (docId) => { const entry = entries.get(docId) diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts index 329a7bc93..a2998840b 100644 --- a/src/__tests__/server/collabRelay.test.ts +++ b/src/__tests__/server/collabRelay.test.ts @@ -14,6 +14,7 @@ import { treeMap, } from '@core/collab' import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import type { DbClient } from '../../../server/db' import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' import { saveDataRowDraft } from '../../../server/repositories/data' import { @@ -40,6 +41,41 @@ async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabR return { harness, relay, homeId: rows[0].id } } +/** + * Wrap a DbClient so the FIRST collab blob write blocks until released. That + * is the only way to hold a persist mid-flight deterministically, which is + * exactly the window `resetDocs` has to survive. + */ +function gateCollabBlobWrites(db: DbClient): { + db: DbClient + blocked: Promise + release: () => void +} { + let announceBlocked!: () => void + const blocked = new Promise((resolve) => { + announceBlocked = resolve + }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let gated = false + + const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + if (!gated && strings.join('?').includes('insert into collab_documents')) { + gated = true + announceBlocked() + await gate + } + return db(strings, ...values) + }) as DbClient + Object.defineProperty(wrapped, 'dialect', { get: () => db.dialect }) + wrapped.unsafe = ((sql: string, params?: unknown[]) => db.unsafe(sql, params)) as DbClient['unsafe'] + wrapped.transaction = ((fn: Parameters[0]) => + db.transaction(fn)) as DbClient['transaction'] + return { db: wrapped, blocked, release } +} + function editTitleUpdate(doc: Y.Doc, nodeText: string): void { doc.transact(() => { const nodes = treeMap(doc).get('nodes') as Y.Map @@ -152,4 +188,73 @@ describe('collab relay', () => { ` expect(rows[0]?.slug).toBe('fresh') }) -}) + + // ── Lifecycle races ─────────────────────────────────────────────────────── + // The reset seam is how every out-of-relay write (Settings save, Super + // Import, plugin install, data-workspace edit) reaches connected editors. + // Both cases below FAIL without the eviction/flush ordering in relay.ts. + + it('a reset is not undone by a persist that was already in flight', async () => { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const gated = gateCollabBlobWrites(harness.db) + const relay = createCollabRelay(gated.db, { persistDebounceMs: 5 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const docId = `page:${rows[0].id}` + + const doc = await relay.openDoc(docId) + editTitleUpdate(doc, 'About to be reset') + + // Block the blob write mid-flight, then reset underneath it. + await gated.blocked + const reset = relay.resetDocs([docId]) + gated.release() + await reset + + // Without the await in `evict`, the released insert lands AFTER + // deleteCollabDocuments and resurrects the dead generation. + expect(await getCollabDocumentState(harness.db, docId)).toBeNull() + }) + + it('a relay-only page survives a site-doc reset instead of vanishing from the roster', async () => { + const { harness, relay } = await setup() + const rowId = 'relay-only-page' + const pageDoc = await relay.openDoc(`page:${rowId}`) + await relay.openDoc(SITE_DOC_ID) + + pageDoc.transact(() => { + const tree = treeMap(pageDoc) + tree.set('rootNodeId', 'root') + const nodes = new Y.Map() + const root = new Y.Map() + root.set('id', 'root') + root.set('moduleId', 'base.body') + root.set('props', new Y.Map()) + root.set('breakpointOverrides', new Y.Map()) + root.set('children', new Y.Array()) + nodes.set('root', root) + tree.set('nodes', nodes) + const meta = pageDoc.getMap('meta') + meta.set('title', 'Relay only') + meta.set('slug', 'relay-only') + }, LOCAL_ORIGIN) + + // Reset the SITE doc while the new page exists ONLY in the relay. Its + // derived JSON must be flushed first, or the reseed — which builds the + // roster from listDataRowIdSlugs — cannot see the row at all. + await relay.resetDocs([SITE_DOC_ID]) + + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where id = ${rowId} and deleted_at is null + ` + expect(rows).toHaveLength(1) + + const reseeded = await relay.openDoc(SITE_DOC_ID) + const pages = rostersMap(reseeded).get('pages') as Y.Map + expect([...pages.keys()]).toContain(rowId) + }) +}) \ No newline at end of file From 8879fb8f61944b72ac999e418fda3c0dfecd213e Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 17:12:36 +0200 Subject: [PATCH 41/49] feat(collab): stamp a per-doc CRDT generation on every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reset deletes the doc's blob and the relay reseeds it at the FIXED `SEED_CLIENT_ID`, so generation N+1 occupies the identical `(client 1, clock 0..K)` coordinates as generation N. FRAME_RESET is a topic broadcast, so a client that was offline never hears about it — and on plain reconnect its `syncStep1` makes the server answer with `encodeStateAsUpdate(doc, staleSV)`, which omits exactly the structs the stale client "already has" at those colliding coordinates and ships the full delete set. Both ends then diverge silently, and the divergence persists into published content. This is reachable today with no new code; it is why this lands before any resync work. The frame envelope gains the doc's lineage id: frame := varString docId | varString generation | varUint frameType | payload The server refuses a cross-lineage frame BEFORE `readSyncMessage`, before the write-policy guard, and before any `applyUpdate` — answering a stale step1 is itself enough to corrupt. `''` means "I hold no server state for this doc": always fine for a step1, and fine for a write only while the server's doc is still empty, which is precisely the client-created-row flow that populates a doc at bind time before any inbound frame. Presence and reset frames carry `''`. Generations are minted with `nanoid()` on seed and persisted immediately rather than through the debounce — a doc that hydrated cleanly is not dirty, so a mint riding the debounce would never reach the DB and the next open would mint a different id, resetting every bound client for a byte-identical lineage. Migration 023 adds the column additively; rows written by 022 carry `''` and get one minted on their next open. FRAME_RESET now carries a reason, because the client cannot otherwise tell a routine reseed from losing its work. `rewritten` (another admin saved a setting, a plugin installed, the data workspace edited a row) stays silent; `stale`, `refused` and `oversize` raise a toast naming what happened. A reset also prunes that doc from the undo routing — its UndoManager is rebuilt empty, so a stale routing entry made Cmd+Z a silent no-op that consumed a step. Oversize sync frames stop being dropped with a bare `return`. Silence there diverges the client permanently: it holds structs the server will never receive, every later update queues behind them as pending, and the screen shows edits that can never publish. It now answers `FRAME_RESET('oversize')`. Also removes `CollabRelay.applyUpdate`, which had no production caller. Verified: the forged-dead-lineage frame is rejected (test fails when the check is disabled); codec round-trips generation and reason, degrading an unknown reason to the routine one rather than throwing on wire data. bun test 6338 pass / 0 fail, bun run build, bun run lint. --- server/collab/relay.ts | 67 ++++++++++----- server/collab/socket.ts | 84 +++++++++++++++---- server/db/migrations-pg.ts | 11 +++ server/db/migrations-sqlite.ts | 11 +++ server/repositories/collabDocuments.ts | 31 +++++-- src/__tests__/collab/merge.test.ts | 28 ++++++- src/__tests__/collab/provider.test.ts | 8 +- src/__tests__/server/collabDocuments.test.ts | 12 +-- src/__tests__/server/collabRelay.test.ts | 36 ++++---- .../server/collabRelayIntegration.test.ts | 71 +++++++++++++++- src/admin/pages/site/collab/collabProvider.ts | 26 +++++- .../site/store/slices/site/collabBinding.ts | 36 +++++++- src/core/collab/index.ts | 3 + src/core/collab/protocol.ts | 62 ++++++++++++-- 14 files changed, 401 insertions(+), 85 deletions(-) diff --git a/server/collab/relay.ts b/server/collab/relay.ts index b6b4640e1..e0c445259 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -8,7 +8,9 @@ * clients can never build divergent initial histories). A doc with * neither blob nor row starts empty: that is the client-created-row * flow, whose content arrives as ordinary updates. - * - `applyUpdate` merges a client update and schedules a debounced persist. + * - every doc carries a `generation` — its CRDT lineage id, minted on seed + * and returned with the doc so the socket can refuse frames from a dead + * lineage (see @core/collab/protocol). * - every local doc update fans out to `subscribeUpdates` listeners (the * socket layer broadcasts to the other connections). * - persistence writes BOTH the CRDT blob (source of truth for editing) @@ -28,6 +30,7 @@ * scope, documented in docs/features/site-shell.md). */ import * as Y from 'yjs' +import { nanoid } from 'nanoid' import { encodeCollabDocId, parseCollabDocId, @@ -83,6 +86,8 @@ const TABLE_KIND: Record> = { interface RelayEntry { doc: Y.Doc + /** This doc's CRDT lineage id — see @core/collab/protocol. */ + generation: string refs: number dirty: boolean persistTimer: ReturnType | null @@ -93,14 +98,24 @@ interface RelayEntry { type DerivedWrite = 'written' | 'incomplete' | 'invalid' -export type RelayUpdateListener = (docId: string, update: Uint8Array, origin: unknown) => void +export type RelayUpdateListener = ( + docId: string, + update: Uint8Array, + origin: unknown, + generation: string, +) => void export type RelayResetListener = (docId: string) => void +/** A doc plus the CRDT lineage the caller must stamp on its frames. */ +export interface RelayDoc { + doc: Y.Doc + generation: string +} + export interface CollabRelay { - openDoc(docId: string): Promise - retain(docId: string): Promise + openDoc(docId: string): Promise + retain(docId: string): Promise release(docId: string): void - applyUpdate(docId: string, update: Uint8Array, origin: unknown): Promise subscribeUpdates(listener: RelayUpdateListener): () => void onReset(listener: RelayResetListener): () => void resetDocs(docIds: readonly string[]): Promise @@ -116,7 +131,7 @@ export function createCollabRelay( ): CollabRelay { const persistDebounceMs = opts.persistDebounceMs ?? 800 const entries = new Map() - const opening = new Map>() + const opening = new Map>() /** * Docs mid-eviction or mid-reset. `openDoc` waits these out, so it can never * hand back a doc that is about to be destroyed, nor resurrect one whose @@ -263,7 +278,7 @@ export function createCollabRelay( if (!entry || !entry.dirty) return entry.dirty = false try { - await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc)) + await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc), entry.generation) const derived = await persistDerivedJson(docId, entry.doc) // A shell that failed validation MUST be retried: the blob is fresh but // the derived JSON is stale, and a reset reseeds from that JSON. Leaving @@ -288,9 +303,9 @@ export function createCollabRelay( // ── Registry ────────────────────────────────────────────────────────────── - async function openDoc(docId: string): Promise { + async function openDoc(docId: string): Promise { const existing = entries.get(docId) - if (existing) return existing.doc + if (existing) return { doc: existing.doc, generation: existing.generation } const inFlight = opening.get(docId) if (inFlight) return inFlight @@ -301,27 +316,41 @@ export function createCollabRelay( // otherwise hydrate from — keeping the dead generation alive. await settling.get(docId) const doc = new Y.Doc() - const blob = await getCollabDocumentState(db, docId) - if (blob) { - Y.applyUpdate(doc, blob, 'hydrate') + const stored = await getCollabDocumentState(db, docId) + let generation: string + let minted: boolean + if (stored) { + Y.applyUpdate(doc, stored.state, 'hydrate') + // Rows written before migration 023 carry ''. + minted = stored.generation === '' + generation = minted ? nanoid() : stored.generation } else { await seedFromJson(docId, doc) + generation = nanoid() + minted = true } const updateHandler = (update: Uint8Array, origin: unknown) => { - for (const listener of updateListeners) listener(docId, update, origin) + for (const listener of updateListeners) listener(docId, update, origin, generation) schedulePersist(docId) } doc.on('update', updateHandler) entries.set(docId, { doc, + generation, refs: 0, - dirty: !blob, // freshly seeded state should persist its blob + dirty: false, persistTimer: null, persistChain: Promise.resolve(), detachUpdateHandler: () => doc.off('update', updateHandler), }) - if (!blob) schedulePersist(docId) - return doc + if (minted) { + // Persist the mint IMMEDIATELY rather than through the debounce. A doc + // that hydrated cleanly is not dirty, so a mint riding the debounce + // would never reach the DB — the next open would mint a DIFFERENT id + // and reset every bound client for a byte-identical lineage. + await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(doc), generation) + } + return { doc, generation } })() opening.set(docId, open) @@ -464,7 +493,7 @@ export function createCollabRelay( const entry = entries.get(docId) if (!entry) continue entry.refs += 1 - return entry.doc + return { doc: entry.doc, generation: entry.generation } } }, release: (docId) => { @@ -477,10 +506,6 @@ export function createCollabRelay( }) } }, - applyUpdate: async (docId, update, origin) => { - const doc = await openDoc(docId) - Y.applyUpdate(doc, update, origin) - }, subscribeUpdates: (listener) => { updateListeners.add(listener) return () => updateListeners.delete(listener) diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 2152cfaa9..27241b3ec 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -34,6 +34,7 @@ import * as awarenessProtocol from 'y-protocols/awareness' import { decodeCollabFrame, encodeCollabFrame, + encodeResetPayload, FRAME_AWARENESS, FRAME_RESET, FRAME_SYNC, @@ -41,6 +42,7 @@ import { PRESENCE_DOC_ID, SITE_SOCKET_PATH, type CollabFrame, + type ResetReason, } from '@core/collab' import { requireCapability, userHasCapability } from '../auth/authz' import { safeParseValue, Type } from '@core/utils/typeboxHelpers' @@ -49,7 +51,7 @@ import { validateGuardedUpdate } from './updateGuard' import { originAllowed } from '../auth/security' import type { DbClient } from '../db/client' import { jsonResponse } from '../http' -import type { CollabRelay } from './relay' +import type { CollabRelay, RelayDoc } from './relay' export { SITE_SOCKET_PATH } @@ -230,16 +232,32 @@ export function createCollabSocketLayer(relay: CollabRelay) { // The server never contributes its own presence state. awareness.setLocalState(null) - relay.subscribeUpdates((docId, update) => { + relay.subscribeUpdates((docId, update, _origin, generation) => { if (!publisher) return const encoder = encoding.createEncoder() syncProtocol.writeUpdate(encoder, update) - publisher.publish(docTopic(docId), encodeCollabFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder))) + publisher.publish( + docTopic(docId), + encodeCollabFrame(docId, generation, FRAME_SYNC, encoding.toUint8Array(encoder)), + ) }) relay.onReset((docId) => { - publisher?.publish(docTopic(docId), encodeCollabFrame(docId, FRAME_RESET, new Uint8Array())) + // The lineage this frame refers to is already gone, so it carries none. + publisher?.publish( + docTopic(docId), + encodeCollabFrame(docId, '', FRAME_RESET, encodeResetPayload('rewritten')), + ) }) + /** Tell one connection its doc was dropped, and why. */ + function sendReset( + ws: ServerWebSocket, + docId: string, + reason: ResetReason, + ): void { + ws.send(encodeCollabFrame(docId, '', FRAME_RESET, encodeResetPayload(reason))) + } + /** * Dispatch one decoded frame. Extracted so `message` can wrap it in a * single try/catch — a malformed frame or a projection crash inside the @@ -270,25 +288,35 @@ export function createCollabSocketLayer(relay: CollabRelay) { } publisher?.publish( docTopic(PRESENCE_DOC_ID), - encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, frame.payload), + encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, frame.payload), ) // publish() excludes nobody server-side; the sender's own state is // already local — awareness re-application is idempotent. - ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, frame.payload)) + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, frame.payload)) return } if (frame.frameType !== FRAME_SYNC) return if (!parseCollabDocId(frame.docId)) return - if (frame.payload.byteLength > MAX_SYNC_PAYLOAD_BYTES) return + if (frame.payload.byteLength > MAX_SYNC_PAYLOAD_BYTES) { + console.warn( + `[collab] oversize ${frame.docId} frame (${frame.payload.byteLength}B) from ${ws.data.userId}`, + ) + // Silently dropping this diverges the client permanently: it holds + // structs the server will never receive, every later update queues + // behind them as pending, and the screen shows edits that can never + // publish. A visible revert is strictly better. + sendReset(ws, frame.docId, 'oversize') + return + } // Read-only connections may REQUEST state (step1) but never write. const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) if (messageType !== SYNC_STEP_1 && !ws.data.canWrite) return - let doc: Y.Doc + let bound: RelayDoc if (ws.data.boundDocs.has(frame.docId)) { - doc = await relay.openDoc(frame.docId) + bound = await relay.openDoc(frame.docId) } else { // Claim the doc SYNCHRONOUSLY before awaiting retain — otherwise two // concurrent frames for the same not-yet-bound doc both take this @@ -297,13 +325,39 @@ export function createCollabSocketLayer(relay: CollabRelay) { ws.data.boundDocs.add(frame.docId) ws.subscribe(docTopic(frame.docId)) try { - doc = await relay.retain(frame.docId) + bound = await relay.retain(frame.docId) } catch (err) { ws.data.boundDocs.delete(frame.docId) ws.unsubscribe(docTopic(frame.docId)) throw err } } + const { doc, generation } = bound + + // LINEAGE CHECK — before readSyncMessage, before the guard, before any + // applyUpdate. A reset reseeds at the fixed SEED_CLIENT_ID, so a client + // that missed the reset (it was offline; FRAME_RESET is a broadcast) + // holds structs at coordinates the live lineage now occupies. Answering + // its step1 is enough to corrupt: encodeStateAsUpdate(doc, staleSV) + // omits exactly the structs it "already has" and ships the full delete + // set, so both ends silently diverge. + if (frame.generation === '') { + // '' means "I hold no server state for this doc". Always fine for a + // read. For a WRITE it is only safe when there is nothing to collide + // with — the client-created-row flow populates a doc at bind time, + // before any inbound frame has taught it a generation. + const serverIsEmpty = Y.encodeStateVector(doc).byteLength === 1 + if (messageType !== SYNC_STEP_1 && !serverIsEmpty) { + sendReset(ws, frame.docId, 'stale') + return + } + } else if (frame.generation !== generation) { + console.warn( + `[collab] stale generation for ${frame.docId} from ${ws.data.userId}`, + ) + sendReset(ws, frame.docId, 'stale') + return + } // Partial writers (canWrite but not every site capability) pass each // update through the category guard BEFORE it touches the @@ -321,7 +375,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { // The sender's local doc holds the forbidden change — a TARGETED // reset makes their client rebind and reseed from the server, // reverting it everywhere (including their own screen). - ws.send(encodeCollabFrame(frame.docId, FRAME_RESET, new Uint8Array())) + sendReset(ws, frame.docId, 'refused') return } Y.applyUpdate(doc, update, ws) @@ -332,7 +386,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { const encoder = encoding.createEncoder() syncProtocol.readSyncMessage(decoder, encoder, doc, ws) if (encoding.length(encoder) > 0) { - ws.send(encodeCollabFrame(frame.docId, FRAME_SYNC, encoding.toUint8Array(encoder))) + ws.send(encodeCollabFrame(frame.docId, generation, FRAME_SYNC, encoding.toUint8Array(encoder))) } } @@ -347,7 +401,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { const known = [...awareness.getStates().keys()] if (known.length > 0) { const update = awarenessProtocol.encodeAwarenessUpdate(awareness, known) - ws.send(encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update)) + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, update)) } }, @@ -364,7 +418,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { // rebinds and reseeds. Awareness/malformed frames just get dropped. if (frame && frame.frameType === FRAME_SYNC && parseCollabDocId(frame.docId)) { try { - ws.send(encodeCollabFrame(frame.docId, FRAME_RESET, new Uint8Array())) + sendReset(ws, frame.docId, 'refused') } catch (_sendErr) { // Socket already closing — nothing to recover. } @@ -380,7 +434,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { const update = awarenessProtocol.encodeAwarenessUpdate(awareness, [...ws.data.awarenessClients]) publisher?.publish( docTopic(PRESENCE_DOC_ID), - encodeCollabFrame(PRESENCE_DOC_ID, FRAME_AWARENESS, update), + encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_AWARENESS, update), ) } }, diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index e21aab668..e6ca8ee65 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1136,4 +1136,15 @@ export const pgMigrations: Migration[] = [ ); `, }, + { + // Per-doc CRDT lineage id. A reset deletes the blob and the doc reseeds at + // the fixed SEED_CLIENT_ID, so the new lineage reuses the old one's struct + // coordinates and a client that missed the reset would hand back structs + // from a dead lineage at live coordinates. Rows written by 022 carry '' and + // have a generation minted on their next open (see relay.openDoc). + id: '023_collab_document_generation', + sql: ` + alter table collab_documents add column generation text not null default ''; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 4ca1f2862..734a53fb3 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1200,4 +1200,15 @@ export const sqliteMigrations: Migration[] = [ ); `, }, + { + // Per-doc CRDT lineage id. A reset deletes the blob and the doc reseeds at + // the fixed SEED_CLIENT_ID, so the new lineage reuses the old one's struct + // coordinates and a client that missed the reset would hand back structs + // from a dead lineage at live coordinates. Rows written by 022 carry '' and + // have a generation minted on their next open (see relay.openDoc). + id: '023_collab_document_generation', + sql: ` + alter table collab_documents add column generation text not null default ''; + `, + }, ] diff --git a/server/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts index cadd5764a..804d97e99 100644 --- a/server/repositories/collabDocuments.ts +++ b/server/repositories/collabDocuments.ts @@ -4,34 +4,49 @@ * of truth; the relay (server/collab) derives JSON into `data_rows` / `site` * on every persist so the publisher and non-editor reads never touch CRDT * state. `seq` counts persists (diagnostics + future delta APIs). + * + * `generation` is the doc's CRDT LINEAGE id (see @core/collab/protocol). A + * reset deletes the row, so the next open mints a fresh one — which is exactly + * what lets both ends refuse a frame from a dead lineage. */ import { placeholder, type DbClient } from '../db/client' +export interface StoredCollabDocument { + state: Uint8Array + /** '' for rows written before migration 023 — the relay mints one on open. */ + generation: string +} + export async function getCollabDocumentState( db: DbClient, docId: string, -): Promise { - const { rows } = await db<{ state_blob: Uint8Array }>` - select state_blob from collab_documents +): Promise { + const { rows } = await db<{ state_blob: Uint8Array; generation: string }>` + select state_blob, generation from collab_documents where doc_id = ${docId} limit 1 ` - const blob = rows[0]?.state_blob - if (!blob) return null - return blob instanceof Uint8Array ? blob : new Uint8Array(blob) + const row = rows[0] + if (!row?.state_blob) return null + return { + state: row.state_blob instanceof Uint8Array ? row.state_blob : new Uint8Array(row.state_blob), + generation: typeof row.generation === 'string' ? row.generation : '', + } } export async function putCollabDocumentState( db: DbClient, docId: string, state: Uint8Array, + generation: string, ): Promise { await db` - insert into collab_documents (doc_id, state_blob, seq) - values (${docId}, ${state}, 1) + insert into collab_documents (doc_id, state_blob, seq, generation) + values (${docId}, ${state}, 1, ${generation}) on conflict (doc_id) do update set state_blob = excluded.state_blob, seq = collab_documents.seq + 1, + generation = excluded.generation, updated_at = current_timestamp ` } diff --git a/src/__tests__/collab/merge.test.ts b/src/__tests__/collab/merge.test.ts index 2cc73e30b..d6bb29ecc 100644 --- a/src/__tests__/collab/merge.test.ts +++ b/src/__tests__/collab/merge.test.ts @@ -145,7 +145,7 @@ describe('applyTextDiff', () => { describe('wire frame codec', () => { it('round-trips docId + frameType + payload', async () => { const { encodeCollabFrame, decodeCollabFrame, FRAME_SYNC } = await import('@core/collab') - const frame = encodeCollabFrame('page:p1', FRAME_SYNC, new Uint8Array([1, 2, 3])) + const frame = encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, new Uint8Array([1, 2, 3])) const decoded = decodeCollabFrame(frame) expect([decoded.docId, decoded.frameType, [...decoded.payload]]).toEqual([ 'page:p1', @@ -156,9 +156,33 @@ describe('wire frame codec', () => { it('round-trips an empty payload (reset frames)', async () => { const { encodeCollabFrame, decodeCollabFrame, FRAME_RESET } = await import('@core/collab') - const decoded = decodeCollabFrame(encodeCollabFrame('site:default', FRAME_RESET, new Uint8Array())) + const decoded = decodeCollabFrame(encodeCollabFrame('site:default', 'gen-1', FRAME_RESET, new Uint8Array())) expect(decoded.docId).toBe('site:default') expect(decoded.frameType).toBe(FRAME_RESET) expect(decoded.payload.length).toBe(0) }) + it('round-trips the generation and refuses to lose it', async () => { + const { encodeCollabFrame, decodeCollabFrame } = await import('@core/collab') + const frame = decodeCollabFrame(encodeCollabFrame('page:p1', 'gen-abc', 0, new Uint8Array([7]))) + expect(frame.docId).toBe('page:p1') + expect(frame.generation).toBe('gen-abc') + expect([...frame.payload]).toEqual([7]) + }) + + it("treats '' as a generation, not a missing field", async () => { + const { encodeCollabFrame, decodeCollabFrame } = await import('@core/collab') + const frame = decodeCollabFrame(encodeCollabFrame('presence', '', 1, new Uint8Array())) + expect(frame.generation).toBe('') + expect(frame.frameType).toBe(1) + }) + + it('round-trips a reset reason and degrades an unknown one to the routine reseed', async () => { + const { encodeResetPayload, decodeResetReason } = await import('@core/collab') + for (const reason of ['rewritten', 'stale', 'refused', 'oversize'] as const) { + expect(decodeResetReason(encodeResetPayload(reason))).toBe(reason) + } + expect(decodeResetReason(new Uint8Array())).toBe('rewritten') + expect(decodeResetReason(new Uint8Array([9, 9, 9]))).toBe('rewritten') + }) + }) diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index 822cede13..867f47138 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -67,7 +67,7 @@ describe('collab provider', () => { serverDoc.getMap('tree').set('rootNodeId', 'root') const encoder = encoding.createEncoder() syncProtocol.writeSyncStep2(encoder, serverDoc, Y.encodeStateVector(new Y.Doc())) - socket.emit(encodeCollabFrame('page:p1', FRAME_SYNC, encoding.toUint8Array(encoder))) + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, encoding.toUint8Array(encoder))) await binding.whenSynced expect(binding.synced).toBe(true) @@ -94,7 +94,7 @@ describe('collab provider', () => { const encoder = encoding.createEncoder() syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(remote)) const countBeforeRemote = socket.sent.length - socket.emit(encodeCollabFrame('page:p1', FRAME_SYNC, encoding.toUint8Array(encoder))) + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, encoding.toUint8Array(encoder))) expect(doc.getMap('meta').get('title')).toBe('From remote') expect(socket.sent.length).toBe(countBeforeRemote) provider.destroy() @@ -108,7 +108,7 @@ describe('collab provider', () => { const resets: string[] = [] provider.onReset((docId) => resets.push(docId)) - socket.emit(encodeCollabFrame('page:p1', FRAME_RESET, new Uint8Array())) + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) expect(resets).toEqual(['page:p1']) // A rebind gets a FRESH doc (the old one was destroyed). const rebound = provider.bind('page:p1') @@ -128,7 +128,7 @@ describe('collab provider', () => { }) // Reset (or any unbind) before the first sync must resolve the promise, // not leave every chained continuation pending forever. - socket.emit(encodeCollabFrame('page:p1', FRAME_RESET, new Uint8Array())) + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) await binding.whenSynced // resolves instead of hanging the test await Promise.resolve() expect(settled).toBe(true) diff --git a/src/__tests__/server/collabDocuments.test.ts b/src/__tests__/server/collabDocuments.test.ts index af7987695..b48e9d85f 100644 --- a/src/__tests__/server/collabDocuments.test.ts +++ b/src/__tests__/server/collabDocuments.test.ts @@ -17,12 +17,12 @@ describe('collab_documents repository', () => { expect(await getCollabDocumentState(db, 'page:x')).toBeNull() const first = new Uint8Array([1, 2, 3, 250]) - await putCollabDocumentState(db, 'page:x', first) - expect([...(await getCollabDocumentState(db, 'page:x'))!]).toEqual([1, 2, 3, 250]) + await putCollabDocumentState(db, 'page:x', first, 'gen-1') + expect([...(await getCollabDocumentState(db, 'page:x'))!.state]).toEqual([1, 2, 3, 250]) const second = new Uint8Array([9, 9]) - await putCollabDocumentState(db, 'page:x', second) - expect([...(await getCollabDocumentState(db, 'page:x'))!]).toEqual([9, 9]) + await putCollabDocumentState(db, 'page:x', second, 'gen-1') + expect([...(await getCollabDocumentState(db, 'page:x'))!.state]).toEqual([9, 9]) const { rows } = await db<{ seq: number }>` select seq from collab_documents where doc_id = ${'page:x'} @@ -36,8 +36,8 @@ describe('collab_documents repository', () => { it('deleteCollabDocuments removes exactly the named docs', async () => { const { db, cleanup } = await createTestDb() try { - await putCollabDocumentState(db, 'page:a', new Uint8Array([1])) - await putCollabDocumentState(db, 'page:b', new Uint8Array([2])) + await putCollabDocumentState(db, 'page:a', new Uint8Array([1]), 'gen-1') + await putCollabDocumentState(db, 'page:b', new Uint8Array([2]), 'gen-1') await deleteCollabDocuments(db, ['page:a', 'page:missing']) expect(await getCollabDocumentState(db, 'page:a')).toBeNull() expect(await getCollabDocumentState(db, 'page:b')).not.toBeNull() diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts index a2998840b..033a7e7dc 100644 --- a/src/__tests__/server/collabRelay.test.ts +++ b/src/__tests__/server/collabRelay.test.ts @@ -42,12 +42,14 @@ async function setup(): Promise<{ harness: CapabilityTestHarness; relay: CollabR } /** - * Wrap a DbClient so the FIRST collab blob write blocks until released. That - * is the only way to hold a persist mid-flight deterministically, which is - * exactly the window `resetDocs` has to survive. + * Wrap a DbClient so the first collab blob write AFTER `arm()` blocks until + * released. Arming is explicit because `openDoc` persists the freshly minted + * generation immediately — the write we want to hold is the later debounced + * one, not that mint. */ function gateCollabBlobWrites(db: DbClient): { db: DbClient + arm: () => void blocked: Promise release: () => void } { @@ -59,10 +61,11 @@ function gateCollabBlobWrites(db: DbClient): { const gate = new Promise((resolve) => { release = resolve }) + let armed = false let gated = false const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { - if (!gated && strings.join('?').includes('insert into collab_documents')) { + if (armed && !gated && strings.join('?').includes('insert into collab_documents')) { gated = true announceBlocked() await gate @@ -73,7 +76,7 @@ function gateCollabBlobWrites(db: DbClient): { wrapped.unsafe = ((sql: string, params?: unknown[]) => db.unsafe(sql, params)) as DbClient['unsafe'] wrapped.transaction = ((fn: Parameters[0]) => db.transaction(fn)) as DbClient['transaction'] - return { db: wrapped, blocked, release } + return { db: wrapped, arm: () => { armed = true }, blocked, release } } function editTitleUpdate(doc: Y.Doc, nodeText: string): void { @@ -88,7 +91,7 @@ function editTitleUpdate(doc: Y.Doc, nodeText: string): void { describe('collab relay', () => { it('seeds a page doc deterministically from the stored row (identical state on repeat)', async () => { const { harness, relay, homeId } = await setup() - const doc = await relay.openDoc(`page:${homeId}`) + const { doc: doc } = await relay.openDoc(`page:${homeId}`) const projected = projectPageDoc(doc, homeId) expect(projected.slug).toBe('index') expect(projected.rootNodeId).not.toBe('') @@ -97,20 +100,20 @@ describe('collab relay', () => { // deterministic seeding must produce an identical state vector. const relay2 = createCollabRelay(harness.db, { persistDebounceMs: 10 }) cleanups.push(() => relay2.destroy()) - const doc2 = await relay2.openDoc(`page:${homeId}`) + const { doc: doc2 } = await relay2.openDoc(`page:${homeId}`) expect(Y.encodeStateVector(doc2)).toEqual(Y.encodeStateVector(doc)) }) it('persists the blob AND the derived JSON after an update', async () => { const { harness, relay, homeId } = await setup() const docId = `page:${homeId}` - const doc = await relay.openDoc(docId) + const { doc: doc } = await relay.openDoc(docId) editTitleUpdate(doc, 'Hero section') await new Promise((resolve) => setTimeout(resolve, 30)) await relay.flushAll() - expect(await getCollabDocumentState(harness.db, docId)).not.toBeNull() + expect((await getCollabDocumentState(harness.db, docId))?.state).toBeDefined() const { rows } = await harness.db<{ cells_json: Record }>` select cells_json from data_rows where id = ${homeId} ` @@ -120,7 +123,7 @@ describe('collab relay', () => { it('roster removal soft-deletes the row on site-doc persist', async () => { const { harness, relay, homeId } = await setup() - const siteDoc = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) siteDoc.transact(() => { const rosters = rostersMap(siteDoc) ;(rosters.get('pages') as Y.Map).delete(homeId) @@ -140,7 +143,7 @@ describe('collab relay', () => { const docId = `page:${homeId}` await relay.openDoc(docId) await relay.flushAll() - expect(await getCollabDocumentState(harness.db, docId)).not.toBeNull() + expect((await getCollabDocumentState(harness.db, docId))?.state).toBeDefined() const resets: string[] = [] relay.onReset((id) => resets.push(id)) @@ -159,7 +162,7 @@ describe('collab relay', () => { it('a doc with neither blob nor row starts empty (client-created-row flow) and persists a new row', async () => { const { harness, relay } = await setup() const docId = 'page:fresh-row-id' - const doc = await relay.openDoc(docId) + const { doc: doc } = await relay.openDoc(docId) expect(treeMap(doc).get('rootNodeId')).toBeUndefined() // Client update arrives with full content (translator-populated shape). @@ -206,7 +209,10 @@ describe('collab relay', () => { ` const docId = `page:${rows[0].id}` - const doc = await relay.openDoc(docId) + const { doc } = await relay.openDoc(docId) + // The generation-mint write has landed; arm the gate so the DEBOUNCED + // persist is the one held mid-flight. + gated.arm() editTitleUpdate(doc, 'About to be reset') // Block the blob write mid-flight, then reset underneath it. @@ -223,7 +229,7 @@ describe('collab relay', () => { it('a relay-only page survives a site-doc reset instead of vanishing from the roster', async () => { const { harness, relay } = await setup() const rowId = 'relay-only-page' - const pageDoc = await relay.openDoc(`page:${rowId}`) + const { doc: pageDoc } = await relay.openDoc(`page:${rowId}`) await relay.openDoc(SITE_DOC_ID) pageDoc.transact(() => { @@ -253,7 +259,7 @@ describe('collab relay', () => { ` expect(rows).toHaveLength(1) - const reseeded = await relay.openDoc(SITE_DOC_ID) + const { doc: reseeded } = await relay.openDoc(SITE_DOC_ID) const pages = rostersMap(reseeded).get('pages') as Y.Map expect([...pages.keys()]).toContain(rowId) }) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index 8454dc46b..b204f850c 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -17,7 +17,11 @@ import { afterEach, describe, expect, it } from 'bun:test' import * as Y from 'yjs' import * as awarenessProtocol from 'y-protocols/awareness' +import * as encoding from 'lib0/encoding' +import * as syncProtocol from 'y-protocols/sync' import { + encodeCollabFrame, + FRAME_SYNC, LOCAL_ORIGIN, projectPageDoc, SITE_SOCKET_PATH, @@ -29,7 +33,7 @@ import { type CollabProvider, type CollabSocketLike, } from '@site/collab/collabProvider' -import { createCollabRelay } from '../../../server/collab/relay' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' import { createCollabSocketLayer, handleCollabSocketUpgrade, @@ -68,6 +72,7 @@ async function waitFor( interface Stack { harness: CapabilityTestHarness + relay: CollabRelay url: string cookie: string homeId: string @@ -101,6 +106,7 @@ async function startStack(): Promise { ` return { harness, + relay, url: `ws://localhost:${server.port}${SITE_SOCKET_PATH}`, cookie, homeId: rows[0].id, @@ -186,7 +192,7 @@ describe('collab relay integration (real server, real sockets)', () => { const stored = await getCollabDocumentState(stack.harness.db, docId) if (!stored) return false const restored = new Y.Doc() - Y.applyUpdate(restored, stored) + Y.applyUpdate(restored, stored.state) return nodeLabel(restored, rootId) === 'Renamed by A' }) await waitFor(async () => { @@ -510,4 +516,65 @@ describe('collab relay integration (real server, real sockets)', () => { expect(await labelInRow()).toBe('Edited seconds before publish') }) + + // ── CRDT lineage ────────────────────────────────────────────────────────── + + it('refuses a stale lineage instead of merging a dead generation', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + 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, 'Before the reset') + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Before the reset') + }) + + const resets: string[] = [] + client.onReset((id, reason) => resets.push(`${id}:${reason}`)) + + // Reset the doc the way an out-of-relay write does. The client is still + // bound and still holds generation N, whose structs sit at the very + // coordinates the reseeded generation N+1 now occupies. + await stack.relay.resetDocs([docId]) + + // The client's own frames must not be merged into the new lineage. + await waitFor(() => resets.length > 0) + expect(resets[0]).toBe(`${docId}:rewritten`) + + // And the authoritative doc must project the reseeded content — no ghost + // nodes carried over from the dead lineage. + const { doc: authoritative } = await stack.relay.openDoc(docId) + const nodes = treeMap(authoritative).get('nodes') as Y.Map + expect(nodes.has(rootId)).toBe(true) + }) + + it('a frame stamped with a dead generation is answered with a reset, not applied', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + + const { generation: live } = await stack.relay.openDoc(docId) + const rootId = treeMap(bound.doc).get('rootNodeId') as string + const before = nodeLabel(bound.doc, rootId) + + const socket = client.lastSocket()! + const encoder = encoding.createEncoder() + const forged = new Y.Doc() + Y.applyUpdate(forged, Y.encodeStateAsUpdate(bound.doc)) + setNodeLabel(forged, rootId, 'From a dead lineage') + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(forged)) + socket.send( + encodeCollabFrame(docId, `${live}-dead`, FRAME_SYNC, encoding.toUint8Array(encoder)), + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + const { doc: authoritative } = await stack.relay.openDoc(docId) + expect(nodeLabel(authoritative, rootId)).toBe(before) + }) }) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index b607c021a..d30cc172f 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -35,6 +35,8 @@ import { FRAME_SYNC, PRESENCE_DOC_ID, REMOTE_ORIGIN, + decodeResetReason, + type ResetReason, } from '@core/collab' import { collabSocketUrl } from './socketUrl' @@ -63,18 +65,27 @@ export interface BoundCollabDoc { whenSynced: Promise } +export type CollabResetListener = (docId: string, reason: ResetReason) => void + export interface CollabProvider { bind(docId: string): BoundCollabDoc unbind(docId: string): void awareness: awarenessProtocol.Awareness status(): CollabStatus onStatus(listener: (status: CollabStatus) => void): () => void - onReset(listener: (docId: string) => void): () => void + onReset(listener: CollabResetListener): () => void destroy(): void } interface BoundEntry { doc: Y.Doc + /** + * The doc's CRDT lineage, learned from the first inbound frame. `''` means + * "I hold no server state yet" — the server accepts that for a step1 + * always, and for a write only while its own doc is still empty (the + * client-created-row flow). See @core/collab/protocol. + */ + generation: string synced: boolean whenSynced: Promise resolveSynced: () => void @@ -99,7 +110,7 @@ export function createCollabProvider( const bound = new Map() const statusListeners = new Set<(status: CollabStatus) => void>() - const resetListeners = new Set<(docId: string) => void>() + const resetListeners = new Set() let socket: CollabSocketLike | null = null let status: CollabStatus = 'connecting' let destroyed = false @@ -117,7 +128,7 @@ export function createCollabProvider( function sendFrame(docId: string, frameType: number, payload: Uint8Array): void { if (!socket || socket.readyState !== 1 /* OPEN */) return - socket.send(encodeCollabFrame(docId, frameType, payload)) + socket.send(encodeCollabFrame(docId, bound.get(docId)?.generation ?? '', frameType, payload)) } function sendSyncStep1(docId: string, doc: Y.Doc): void { @@ -133,6 +144,7 @@ export function createCollabProvider( }) const entry: BoundEntry = { doc, + generation: '', synced: false, whenSynced, resolveSynced, @@ -174,10 +186,16 @@ export function createCollabProvider( if (!entry) return if (frame.frameType === FRAME_RESET) { + const reason = decodeResetReason(frame.payload) unbind(frame.docId) - for (const listener of resetListeners) listener(frame.docId) + for (const listener of resetListeners) listener(frame.docId, reason) return } + // Adopt the server's lineage from the first frame that names one, so every + // subsequent outbound frame is refusable if this doc is later reseeded. + if (entry.generation === '' && frame.generation !== '') { + entry.generation = frame.generation + } if (frame.frameType !== FRAME_SYNC) return const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 55b7c50d0..5d55c890e 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -64,6 +64,19 @@ import type { EditorStoreApi } from '@site/store/types' import { pruneCanvasSelectionDraft } from '../selectionSlice' import type { Awareness } from 'y-protocols/awareness' import type { CollabProvider } from '@site/collab/collabProvider' +import { pushToast } from '@ui/components/Toast' +import type { ResetReason } from '@core/collab' + +/** + * What the user is told when the server discards a doc they were editing. + * `rewritten` never reaches here — it is the routine out-of-relay reseed. + */ +const RESET_REASON_COPY: Record = { + rewritten: '', + stale: 'This document was rebuilt while you were disconnected, so unsent changes were discarded. The latest version is now loaded.', + refused: 'Your role does not allow that change, so it was undone.', + oversize: 'That change was too large to sync in one step and was undone. Try making it in smaller pieces.', +} interface ManagedDoc { doc: Y.Doc @@ -617,9 +630,26 @@ function bindThroughProvider(docIds: readonly string[]): void { export function connectCollabProvider(next: CollabProvider): void { provider = next detachProviderReset?.() - detachProviderReset = next.onReset((docId) => { - // The server dropped this doc (out-of-relay JSON rewrite): rebind and - // let the fresh server seed re-project into the store. + detachProviderReset = next.onReset((docId, reason) => { + // 'rewritten' is the routine out-of-relay reseed — another admin saved a + // setting, a plugin installed, the data workspace edited a row. Nothing of + // this user's was lost, so saying anything would be noise. The other three + // mean work of theirs was discarded, and they must be told. + if (reason !== 'rewritten') { + pushToast({ + kind: 'error', + title: 'A change was reverted', + body: RESET_REASON_COPY[reason], + }) + } + // The doc's undo history belongs to the lineage that just died: its + // UndoManager is rebuilt empty below, so any routing entry still naming + // this doc would make Cmd+Z a silent no-op that consumes a step. + undoRoute = undoRoute.map((group) => group.filter((id) => id !== docId)).filter((g) => g.length > 0) + redoRoute = redoRoute.map((group) => group.filter((id) => id !== docId)).filter((g) => g.length > 0) + syncUndoFlags() + // 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) gate.synced = rebound.synced diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 6ae75f2a5..460b2022a 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -29,13 +29,16 @@ export { reconcileTreeIntegrity } from './integrity' export { applyTextDiff } from './textDiff' export { decodeCollabFrame, + decodeResetReason, encodeCollabFrame, + encodeResetPayload, FRAME_AWARENESS, FRAME_RESET, FRAME_SYNC, PRESENCE_DOC_ID, SITE_SOCKET_PATH, type CollabFrame, + type ResetReason, } from './protocol' export { createCollabDocSet, type CollabDocSet } from './docSet' export { applySitePatchesToDocs } from './applyPatches' diff --git a/src/core/collab/protocol.ts b/src/core/collab/protocol.ts index 96743bd6f..543aef966 100644 --- a/src/core/collab/protocol.ts +++ b/src/core/collab/protocol.ts @@ -3,15 +3,30 @@ * WebSocket (`SITE_SOCKET_PATH`). Shared verbatim by the server socket layer * and the client provider. * - * frame := varString docId | varUint frameType | rest payload + * frame := varString docId | varString generation | varUint frameType | rest payload * * FRAME_SYNC payload = a y-protocols/sync message (step1/step2/update) * FRAME_AWARENESS payload = a y-protocols/awareness update * (docId is the fixed PRESENCE_DOC_ID — awareness is * site-wide; clients filter peers by their active doc) - * FRAME_RESET no payload — the server dropped this doc's CRDT state - * (its backing JSON was rewritten out-of-relay); clients - * rebind and the doc reseeds server-side + * FRAME_RESET payload = varString ResetReason — the server dropped this + * doc's CRDT state; clients rebind and the doc reseeds + * server-side + * + * `generation` identifies the doc's CRDT LINEAGE, and is the reason this + * envelope exists. A reset deletes the blob and the server reseeds at the + * FIXED `SEED_CLIENT_ID`, so generation N+1 occupies the identical + * `(client 1, clock 0..K)` coordinates as generation N. A client that missed + * the reset — it was offline, and FRAME_RESET is a topic broadcast — would + * otherwise hand the server structs from a dead lineage at live coordinates, + * which Yjs cannot detect and which corrupts published content. Stamping the + * lineage in the ENVELOPE lets both sides refuse a cross-lineage frame before + * any doc is touched. + * + * `''` means "I hold no server state for this doc yet": always safe for a + * sync step1, and safe for a write only when the server's doc is still empty + * (the client-created-row flow, which populates a doc at bind time before any + * inbound frame). Presence and reset frames always use `''`. */ import * as encoding from 'lib0/encoding' import * as decoding from 'lib0/decoding' @@ -25,19 +40,37 @@ export const FRAME_SYNC = 0 export const FRAME_AWARENESS = 1 export const FRAME_RESET = 2 +/** + * Why the server dropped a doc. + * + * rewritten — routine: the backing JSON was rewritten out-of-relay (a + * Settings save, a data-workspace edit, a plugin install). + * Nothing of the user's was lost; do not alarm them. + * stale — this client's frame belonged to a dead lineage; whatever it + * held for that doc has been discarded. + * refused — the write-policy guard rejected the update. + * oversize — the frame exceeded the sync payload ceiling. + */ +export type ResetReason = 'rewritten' | 'stale' | 'refused' | 'oversize' + +const RESET_REASONS: readonly ResetReason[] = ['rewritten', 'stale', 'refused', 'oversize'] + export interface CollabFrame { docId: string + generation: string frameType: number payload: Uint8Array } export function encodeCollabFrame( docId: string, + generation: string, frameType: number, payload: Uint8Array, ): Uint8Array { const encoder = encoding.createEncoder() encoding.writeVarString(encoder, docId) + encoding.writeVarString(encoder, generation) encoding.writeVarUint(encoder, frameType) encoding.writeUint8Array(encoder, payload) return encoding.toUint8Array(encoder) @@ -46,7 +79,26 @@ export function encodeCollabFrame( export function decodeCollabFrame(data: Uint8Array): CollabFrame { const decoder = decoding.createDecoder(data) const docId = decoding.readVarString(decoder) + const generation = decoding.readVarString(decoder) const frameType = decoding.readVarUint(decoder) const payload = decoding.readTailAsUint8Array(decoder) - return { docId, frameType, payload } + return { docId, generation, frameType, payload } +} + +export function encodeResetPayload(reason: ResetReason): Uint8Array { + const encoder = encoding.createEncoder() + encoding.writeVarString(encoder, reason) + return encoding.toUint8Array(encoder) +} + +/** Wire data — an unknown reason degrades to the routine one, never throws. */ +export function decodeResetReason(payload: Uint8Array): ResetReason { + if (payload.byteLength === 0) return 'rewritten' + try { + const value = decoding.readVarString(decoding.createDecoder(payload)) + return RESET_REASONS.includes(value as ResetReason) ? (value as ResetReason) : 'rewritten' + } catch { + // Undecodable payload — treat as the routine reseed rather than alarming. + return 'rewritten' + } } From 0ebd7e0e7e018e7abd857212471646d6627467a9 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 17:24:25 +0200 Subject: [PATCH 42/49] feat(collab): application-level liveness so "connected" means "reachable" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor is about to refuse edits it cannot deliver, which makes the definition of "connected" load-bearing. `readyState` cannot carry that weight: on a black-holed path — VPN drop, captive portal, sleep/wake, mobile handover — the browser keeps the socket OPEN until the TCP retransmission timeout, which is minutes. Throughout that window every `send()` succeeds into a buffer nobody drains, the toolbar reads "Draft synced", and the work is already lost. Bun's protocol-level pings cannot help: the browser answers them without telling JavaScript, so they prove nothing to the side that has to decide. A FRAME_PING / FRAME_PONG round trip on a 10s interval bounds that window to ~2.5 intervals. Missing it publishes `offline` SYNCHRONOUSLY rather than waiting for a close that may never arrive — the gate must not keep accepting edits while the chip still claims everything is fine. Pings are answered before the frame reaches `parseCollabDocId` or the relay, and are deliberately ungated: a read-only viewer needs to know its socket is alive exactly as much as a writer does. `canSend()` becomes the single "can this edit reach the relay" predicate, shared by `sendFrame` and (next commit) the write gate, because it is one fact rather than two. It also refuses once `bufferedAmount` exceeds 512 KB, which catches the slower failure the heartbeat misses: the socket is genuinely open but the bytes are not moving. `reconnectNow()` escapes the backoff, which reaches 30s. When the user tells us the network is back, making them watch a timer is the wrong answer. Two honesty fixes that came out of this: - the provider's docstring claimed "no flush window to lose on unload". That is false — `send()` only enqueues, and the browser discards `bufferedAmount` on unload. The comment now says so, and `beforeunload` calls `preventDefault()` while bytes are queued. A large paste is one multi-hundred-KB frame and there is no client-side save path behind it. - the ping interval is injectable, because a test that proves the timeout works should not spend 31 seconds doing it. The suite version runs in 255ms. bun test 6342 pass / 0 fail (twice), bun run build, bun run lint. Unrelated: `contentAdmin.test.tsx` "does not reopen the settings preference when the last selected entry is cleared" failed once during this work and passed in isolation, in both pairwise combinations, and in two consecutive full runs. It is an intermittent timing flake in the Content workspace, not a regression from this change. --- server/collab/socket.ts | 10 ++ src/__tests__/collab/provider.test.ts | 62 +++++++++++++ .../server/collabRelayIntegration.test.ts | 27 ++++++ src/admin/pages/site/collab/collabProvider.ts | 93 ++++++++++++++++++- src/core/collab/index.ts | 2 + src/core/collab/protocol.ts | 11 +++ 6 files changed, 200 insertions(+), 5 deletions(-) diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 27241b3ec..3e8737138 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -36,6 +36,8 @@ import { encodeCollabFrame, encodeResetPayload, FRAME_AWARENESS, + FRAME_PING, + FRAME_PONG, FRAME_RESET, FRAME_SYNC, parseCollabDocId, @@ -267,6 +269,14 @@ export function createCollabSocketLayer(relay: CollabRelay) { ws: ServerWebSocket, frame: CollabFrame, ): Promise { + // Liveness first: a ping carries no doc and must never reach + // parseCollabDocId or the relay. Deliberately ungated — a read-only + // viewer needs to know its socket is alive exactly as much as a writer. + if (frame.frameType === FRAME_PING) { + ws.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_PONG, new Uint8Array())) + return + } + if (frame.frameType === FRAME_AWARENESS) { // Presence is NOT a doc write — read-only viewers are visible peers. if (frame.payload.byteLength > MAX_AWARENESS_PAYLOAD_BYTES) return diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index 867f47138..8d88ad447 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -12,6 +12,7 @@ import * as syncProtocol from 'y-protocols/sync' import { decodeCollabFrame, encodeCollabFrame, + FRAME_PING, FRAME_RESET, FRAME_SYNC, LOCAL_ORIGIN, @@ -24,6 +25,7 @@ import { class FakeSocket implements CollabSocketLike { binaryType = 'arraybuffer' readyState = 1 + bufferedAmount = 0 sent: Uint8Array[] = [] onopen: (() => void) | null = null onmessage: ((event: { data: unknown }) => void) | null = null @@ -134,4 +136,64 @@ describe('collab provider', () => { expect(settled).toBe(true) provider.destroy() }) + + // ── Liveness ────────────────────────────────────────────────────────────── + // `readyState` cannot distinguish a live socket from a black-holed one, and + // the write gate refuses edits it cannot deliver — so "connected" has to + // mean "answered a ping recently", not "the browser has not noticed yet". + + it('pings on an interval and goes offline when the socket stops answering', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket, pingIntervalMs: 20 }) + const seen: string[] = [] + provider.onStatus((status) => seen.push(status)) + socket.open() + expect(provider.canSend()).toBe(true) + + // A ping goes out on the interval... + await Bun.sleep(35) + const pings = socket.sent.filter((f) => decodeCollabFrame(f).frameType === FRAME_PING) + expect(pings.length).toBeGreaterThan(0) + + // ...and with no inbound traffic at all, the provider stops claiming to be + // connected rather than waiting for a close that may never come. + await Bun.sleep(80) + expect(provider.status()).toBe('offline') + expect(provider.canSend()).toBe(false) + expect(seen).toContain('offline') + provider.destroy() + }) + + it('refuses to send while the socket backlog is not draining', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket, pingIntervalMs: 60_000 }) + socket.open() + expect(provider.canSend()).toBe(true) + + // Open, but the bytes are not moving — an edit now would only join a + // buffer the browser discards on unload. + socket.bufferedAmount = 1024 * 1024 + expect(provider.canSend()).toBe(false) + provider.destroy() + }) + + it('reconnectNow escapes the backoff and is a no-op while connected', () => { + let created = 0 + const socket = new FakeSocket() + const provider = createCollabProvider({ + createSocket: () => { + created += 1 + return socket + }, + }) + socket.open() + provider.reconnectNow() + expect(created).toBe(1) // already connected — nothing to do + + socket.readyState = 3 + socket.onclose?.() + provider.reconnectNow() + expect(created).toBe(2) + provider.destroy() + }) }) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index b204f850c..e90ba728e 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -20,8 +20,12 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as encoding from 'lib0/encoding' import * as syncProtocol from 'y-protocols/sync' import { + decodeCollabFrame, encodeCollabFrame, + FRAME_PING, + FRAME_PONG, FRAME_SYNC, + PRESENCE_DOC_ID, LOCAL_ORIGIN, projectPageDoc, SITE_SOCKET_PATH, @@ -577,4 +581,27 @@ describe('collab relay integration (real server, real sockets)', () => { const { doc: authoritative } = await stack.relay.openDoc(docId) expect(nodeLabel(authoritative, rootId)).toBe(before) }) + + it('answers a ping on a read-only connection without touching the relay', async () => { + const stack = await startStack() + const viewer = await stack.harness.createRoleUser({ + name: 'Ping Viewer', + slug: 'collab-ping-viewer', + capabilities: ['site.read'], + }) + const client = connectClient(stack, viewer.cookie) + // Give the socket a moment to open before probing liveness. + await waitFor(() => client.status() === 'connected') + + const socket = client.lastSocket()! + const pongs: number[] = [] + socket.addEventListener('message', (event: MessageEvent) => { + const data = new Uint8Array(event.data as ArrayBuffer) + pongs.push(decodeCollabFrame(data).frameType) + }) + socket.send(encodeCollabFrame(PRESENCE_DOC_ID, '', FRAME_PING, new Uint8Array())) + + await waitFor(() => pongs.includes(FRAME_PONG)) + expect(client.status()).toBe('connected') + }) }) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index d30cc172f..8aa8cfb74 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -7,9 +7,15 @@ * resolves after the server's initial sync (docs are NEVER seeded * client-side — the server is the only seeder, so two clients can't * build divergent initial histories; the store gates edits on synced). - * - every local transaction (origin ≠ REMOTE_ORIGIN) sends an update - * frame immediately — Yjs 'update' events fire synchronously inside the - * transaction commit, so there is no flush window to lose on unload. + * - every local transaction (origin ≠ REMOTE_ORIGIN) sends an update frame + * immediately — Yjs 'update' events fire synchronously inside the + * transaction commit. `send()` still only ENQUEUES, though: bytes sit in + * `bufferedAmount` and the browser discards that buffer on unload, so a + * large in-flight frame can still be lost by closing the tab. That window + * is bounded and flagged via `beforeunload`, not zero. + * - an application-level ping/pong bounds how long a black-holed socket can + * masquerade as connected, because the write gate refuses edits it cannot + * deliver and must not be fooled by `readyState`. * - reconnect uses exponential backoff; on every (re)connect each bound * doc re-runs syncStep1, and Yjs state vectors make the catch-up delta * exact — the transport is self-healing by construction. @@ -31,6 +37,8 @@ import { decodeCollabFrame, encodeCollabFrame, FRAME_AWARENESS, + FRAME_PING, + FRAME_PONG, FRAME_RESET, FRAME_SYNC, PRESENCE_DOC_ID, @@ -42,6 +50,13 @@ import { collabSocketUrl } from './socketUrl' const RECONNECT_BASE_DELAY_MS = 1_000 const RECONNECT_MAX_DELAY_MS = 30_000 +const PING_INTERVAL_MS = 10_000 +/** + * Bytes queued in the socket's send buffer. Above this the transport is not + * draining (or is black-holed) and further edits would only join bytes the + * browser throws away on unload. + */ +const MAX_BACKLOG_BYTES = 512 * 1024 /** y-protocols/sync message types (payload's first varUint). */ const SYNC_STEP_2 = 1 @@ -51,6 +66,7 @@ export type CollabStatus = 'connecting' | 'connected' | 'offline' export interface CollabSocketLike { binaryType: string readyState: number + bufferedAmount: number send(data: Uint8Array): void close(): void onopen: (() => void) | null @@ -72,6 +88,13 @@ export interface CollabProvider { unbind(docId: string): void awareness: awarenessProtocol.Awareness status(): CollabStatus + /** + * Whether an edit made right now can actually reach the relay. The write + * gate and `sendFrame` share this because it is ONE fact. + */ + canSend(): boolean + /** Escape the backoff (which reaches 30s) when the user says the network is back. */ + reconnectNow(): void onStatus(listener: (status: CollabStatus) => void): () => void onReset(listener: CollabResetListener): () => void destroy(): void @@ -93,8 +116,14 @@ interface BoundEntry { } export function createCollabProvider( - opts: { createSocket?: () => CollabSocketLike } = {}, + opts: { + createSocket?: () => CollabSocketLike + /** Heartbeat period. Overridden only by tests, which cannot wait 10s. */ + pingIntervalMs?: number + } = {}, ): CollabProvider { + const pingIntervalMs = opts.pingIntervalMs ?? PING_INTERVAL_MS + const livenessTimeoutMs = pingIntervalMs * 2.5 const createSocket = opts.createSocket ?? ((): CollabSocketLike => { @@ -116,6 +145,8 @@ export function createCollabProvider( let destroyed = false let attempts = 0 let reconnectTimer: ReturnType | undefined + let pingTimer: ReturnType | undefined + let lastInboundAt = 0 const presenceDoc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(presenceDoc) @@ -126,8 +157,27 @@ export function createCollabProvider( for (const listener of statusListeners) listener(next) } + /** + * `readyState` alone is not enough. On a black-holed path the browser keeps + * the socket OPEN until the TCP retransmission timeout — minutes — during + * which every `send()` silently succeeds into a buffer nobody drains. The + * heartbeat bounds that; `bufferedAmount` catches the slower case where the + * socket is genuinely open but the bytes are not moving. + */ + function canSend(): boolean { + return ( + status === 'connected' && + socket !== null && + socket.readyState === 1 /* OPEN */ && + socket.bufferedAmount <= MAX_BACKLOG_BYTES + ) + } + function sendFrame(docId: string, frameType: number, payload: Uint8Array): void { + // Pings must go out even while the connection is still being judged; + // everything else respects the gate. if (!socket || socket.readyState !== 1 /* OPEN */) return + if (frameType !== FRAME_PING && !canSend()) return socket.send(encodeCollabFrame(docId, bound.get(docId)?.generation ?? '', frameType, payload)) } @@ -177,6 +227,10 @@ export function createCollabProvider( function handleFrame(data: Uint8Array): void { const frame = decodeCollabFrame(data) + // The inbound timestamp is already taken in onmessage — a pong carries no + // other meaning. + if (frame.frameType === FRAME_PONG) return + if (frame.docId === PRESENCE_DOC_ID && frame.frameType === FRAME_AWARENESS) { awarenessProtocol.applyAwarenessUpdate(awareness, frame.payload, REMOTE_ORIGIN) return @@ -220,6 +274,19 @@ export function createCollabProvider( socket.onopen = () => { attempts = 0 setStatus('connected') + lastInboundAt = Date.now() + clearInterval(pingTimer) + pingTimer = setInterval(() => { + if (Date.now() - lastInboundAt > livenessTimeoutMs) { + // Publish OFFLINE synchronously: `onclose` is not prompt on a black + // hole, and the write gate must not keep accepting edits while the + // toolbar still reads "Draft synced". + setStatus('offline') + socket?.close() + return + } + sendFrame(PRESENCE_DOC_ID, FRAME_PING, new Uint8Array()) + }, pingIntervalMs) for (const [docId, entry] of bound) sendSyncStep1(docId, entry.doc) // Re-announce local presence after a reconnect. const localState = awareness.getLocalState() @@ -233,6 +300,7 @@ export function createCollabProvider( } socket.onmessage = (event) => { + lastInboundAt = Date.now() const { data } = event if (data instanceof ArrayBuffer) handleFrame(new Uint8Array(data)) else if (data instanceof Uint8Array) handleFrame(data) @@ -243,6 +311,8 @@ export function createCollabProvider( socket.onclose = () => { socket = null + clearInterval(pingTimer) + pingTimer = undefined if (destroyed) return setStatus('offline') const delay = @@ -268,8 +338,13 @@ export function createCollabProvider( bound.delete(docId) } - function beforeUnload(): void { + function beforeUnload(event: BeforeUnloadEvent): void { awarenessProtocol.removeAwarenessStates(awareness, [awareness.clientID], 'unload') + // `send()` only enqueues, and the browser discards `bufferedAmount` on + // unload. A large paste is one multi-hundred-KB frame, and there is no + // client-side save path to fall back on — so warn rather than lose it + // silently. Browsers may ignore this without prior interaction. + if (socket && socket.bufferedAmount > 0) event.preventDefault() } if (typeof window !== 'undefined') { window.addEventListener('beforeunload', beforeUnload) @@ -289,6 +364,13 @@ export function createCollabProvider( unbind, awareness, status: () => status, + canSend, + reconnectNow: () => { + if (destroyed || status === 'connected' || socket) return + clearTimeout(reconnectTimer) + attempts = 0 + connect() + }, onStatus: (listener) => { statusListeners.add(listener) return () => statusListeners.delete(listener) @@ -300,6 +382,7 @@ export function createCollabProvider( destroy: () => { destroyed = true clearTimeout(reconnectTimer) + clearInterval(pingTimer) if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnload) awareness.off('update', awarenessUpdateHandler) awarenessProtocol.removeAwarenessStates(awareness, [awareness.clientID], 'destroy') diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 460b2022a..75bebf79d 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -33,6 +33,8 @@ export { encodeCollabFrame, encodeResetPayload, FRAME_AWARENESS, + FRAME_PING, + FRAME_PONG, FRAME_RESET, FRAME_SYNC, PRESENCE_DOC_ID, diff --git a/src/core/collab/protocol.ts b/src/core/collab/protocol.ts index 543aef966..2e4e753db 100644 --- a/src/core/collab/protocol.ts +++ b/src/core/collab/protocol.ts @@ -12,6 +12,8 @@ * FRAME_RESET payload = varString ResetReason — the server dropped this * doc's CRDT state; clients rebind and the doc reseeds * server-side + * FRAME_PING / no payload — application-level liveness, under + * FRAME_PONG PRESENCE_DOC_ID and never gated on capabilities * * `generation` identifies the doc's CRDT LINEAGE, and is the reason this * envelope exists. A reset deletes the blob and the server reseeds at the @@ -39,6 +41,15 @@ export const PRESENCE_DOC_ID = 'presence' export const FRAME_SYNC = 0 export const FRAME_AWARENESS = 1 export const FRAME_RESET = 2 +/** + * Liveness. `readyState` alone cannot tell a live socket from a black-holed + * one — on a VPN drop, captive portal, sleep/wake or mobile handover the + * browser keeps the socket OPEN until the TCP retransmission timeout, which is + * minutes. Since the editor refuses edits it cannot deliver, "connected" has + * to mean "reachable", and only an application-level round trip can say so. + */ +export const FRAME_PING = 3 +export const FRAME_PONG = 4 /** * Why the server dropped a doc. From a1afb5179f436cdadbc3e43e2229eff3c3c45e71 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 25 Jul 2026 17:28:20 +0200 Subject: [PATCH 43/49] feat(collab): server-initiated syncStep1 so a reconnect recovers unsent edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client's step1 only ever pulled the SERVER's delta. Nothing pulled the client's, and the server never originated a step1 of its own — so any edit committed locally while the socket was down never reached the relay, even after a clean reconnect. Worse, later edits depend on those structs, so Yjs held them as pending server-side too: the session diverged permanently while the toolbar showed everything synced. The fix is one frame. y-protocols already answers a step1 with exactly the missing delta, and the provider already ships whatever its encoder produced, so originating a step1 from the server needs ZERO client changes. Sending our own state vector on a connection's first sync frame per doc is the whole mechanism. Gated on `fullSiteWriter`, deliberately and unhappily. A partial writer's reply is a single update carrying its entire missing state, and `validateGuardedUpdate` accepts or rejects that as ONE unit — a single forbidden op inside the batch would discard every legitimate edit alongside it. Losing a content-only editor's recovery window is bad; silently destroying the rest of their session while trying to save it is worse. Closing that properly needs a splittable guarded update, which is a follow-up. `probedDocs` is per-connection rather than per-doc, because a reconnect brings a fresh socket and that is precisely when the probe has to run again. This lands AFTER the generation stamp for a reason: asking a client for its state is exactly what makes a missed reset dangerous, and the lineage check is what makes the answer safe to merge. The new test fails without the probe and passes with it — it kills the transport, edits, asserts the relay has NOT seen the change, reconnects, and then asserts the edit reaches both the authoritative doc and `data_rows`. It is the mirror of the existing "a reconnecting client catches up on edits it missed", which only ever covered server → client. bun test 6343 pass / 0 fail, bun run build, bun run lint. --- server/collab/socket.ts | 29 +++++++++++++++ .../server/collabRelayIntegration.test.ts | 37 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 3e8737138..d8e1ef818 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -167,6 +167,12 @@ export interface CollabSocketData { capabilities: readonly CoreCapability[] /** Doc ids this connection retained in the relay (released on close). */ boundDocs: Set + /** + * Docs this connection has already been asked to hand over its state for. + * Per-connection by design: a reconnect brings a fresh socket, which is + * exactly when the recovery probe must run again. + */ + probedDocs: Set /** Awareness clientIDs contributed by this connection. */ awarenessClients: Set } @@ -207,6 +213,7 @@ export async function handleCollabSocketUpgrade( fullSiteWriter, capabilities: user.capabilities, boundDocs: new Set(), + probedDocs: new Set(), awarenessClients: new Set(), }, }) @@ -392,6 +399,28 @@ export function createCollabSocketLayer(relay: CollabRelay) { return } + // RECOVERY — ask a full writer what IT holds that we do not. + // + // The client's step1 only pulls the server's delta; nothing ever pulls + // the client's. So anything committed locally while the socket was down + // (or black-holed, before liveness noticed) never reached the relay, + // even after reconnect. y-protocols already answers a step1 with exactly + // the missing delta, so originating one here needs no client change at + // all. + // + // Gated on `fullSiteWriter` deliberately. A partial writer's reply is a + // single update carrying its whole missing state, and + // `validateGuardedUpdate` accepts or rejects that as ONE unit — a single + // forbidden op inside it would discard every legitimate edit alongside. + // Losing their recovery window is bad; silently destroying the rest of + // their session to attempt it is worse. + if (messageType === SYNC_STEP_1 && ws.data.fullSiteWriter && !ws.data.probedDocs.has(frame.docId)) { + ws.data.probedDocs.add(frame.docId) + const probe = encoding.createEncoder() + syncProtocol.writeSyncStep1(probe, doc) + ws.send(encodeCollabFrame(frame.docId, generation, FRAME_SYNC, encoding.toUint8Array(probe))) + } + const decoder = decoding.createDecoder(frame.payload) const encoder = encoding.createEncoder() syncProtocol.readSyncMessage(decoder, encoder, doc, ws) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index e90ba728e..aa68421fd 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -604,4 +604,41 @@ describe('collab relay integration (real server, real sockets)', () => { await waitFor(() => pongs.includes(FRAME_PONG)) expect(client.status()).toBe('connected') }) + + // The mirror of "a reconnecting client catches up on edits it missed": that + // test proves server → client. This proves client → server, which is the + // direction that was silently dropping work. + it('recovers edits authored while the socket was down, on reconnect', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + + // Kill the transport, then edit. The frame is dropped on the floor — + // `sendFrame` cannot reach a closed socket. + client.lastSocket()!.close() + await waitFor(() => client.status() !== 'connected') + setNodeLabel(bound.doc, rootId, 'Written while offline') + + // The relay has NOT seen it. + const { doc: beforeReconnect } = await stack.relay.openDoc(docId) + expect(nodeLabel(beforeReconnect, rootId)).not.toBe('Written while offline') + + // Reconnect. The server asks what this client holds, and the client's + // existing readSyncMessage answers with exactly the missing delta. + client.reconnectNow() + await waitFor(async () => { + const { doc: authoritative } = await stack.relay.openDoc(docId) + return nodeLabel(authoritative, rootId) === 'Written while offline' + }) + + // And it reaches storage, not just memory. + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Written while offline') + }) + }) }) From 933ddd9865721f9898e07bf553cbbffd2bcab73e Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Mon, 27 Jul 2026 17:44:54 +0200 Subject: [PATCH 44/49] fix(collab): reset read-only viewers on a refused write so their screen reverts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stress test (five concurrent role-scoped editors) surfaced a read-only viewer whose local doc permanently diverged while the toolbar still read "Draft synced". Every other peer had the correct converged state; only the viewer showed its own rejected edits, merged into a text node by the Y.Text CRDT and never healed. Cause: the socket dispatch special-cased read-only connections. A non-step1 frame from a connection lacking every site-write capability was dropped at a bespoke `canWrite` gate (`... && !ws.data.canWrite) return`) with NO reset — so the sender kept its optimistic local change forever. Partial writers never had this problem: their forbidden writes go through the category guard, which sends a targeted reset that reverts the change on their own screen too. A read-only viewer is just a partial writer with zero write capabilities. Remove the special case (and the now-dead `canWrite` field) and let read-only connections ride the same guard path: the guard refuses any real change (→ reset → the viewer's doc reseeds from the server and reverts), while a viewer's empty handshake step2 diffs to nothing and passes as a no-op. One enforcement path, no divergent screens. The existing "drops update frames from a read-only connection" test only checked that other peers never saw the edit — it never pinned the viewer's own divergence, which is why this shipped. Strengthened to assert the viewer receives a reset and its rebound doc no longer holds the edit; mutation-checked (restoring the silent drop fails it). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/collab/socket.ts | 29 ++++++++++--------- .../server/collabRelayIntegration.test.ts | 22 ++++++++++---- src/__tests__/server/collabSocket.test.ts | 24 +++++++++++++-- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/server/collab/socket.ts b/server/collab/socket.ts index d8e1ef818..7e0dd53e0 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -155,12 +155,14 @@ export interface CollabSocketData { userId: string /** The session identity this connection may publish over presence. */ identity: CollabPresenceIdentity - canWrite: boolean /** * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common - * case, which skips the per-update capability guard entirely. Partial - * writers (e.g. content-only editors) pay a fork+diff validation per - * update frame instead (see ./updateGuard.ts). + * case, which skips the per-update capability guard entirely. Every other + * connection — partial writers (e.g. content-only editors) AND read-only + * viewers (zero write capabilities) — pays a fork+diff validation per + * update frame instead (see ./updateGuard.ts). A viewer's edit has no + * matching capability for any category, so the guard refuses it and resets + * the sender, exactly like a partial writer straying out of its lane. */ fullSiteWriter: boolean /** The user's granted capabilities — the guard's validation input. */ @@ -198,7 +200,6 @@ export async function handleCollabSocketUpgrade( } const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - const canWrite = SITE_WRITE_CAPABILITIES.some((cap) => userHasCapability(user, cap)) const fullSiteWriter = SITE_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) const upgraded = server.upgrade(req, { data: { @@ -209,7 +210,6 @@ export async function handleCollabSocketUpgrade( avatarUrl: user.avatarUrl, gravatarHash: user.gravatarHash, }, - canWrite, fullSiteWriter, capabilities: user.capabilities, boundDocs: new Set(), @@ -327,9 +327,7 @@ export function createCollabSocketLayer(relay: CollabRelay) { return } - // Read-only connections may REQUEST state (step1) but never write. const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) - if (messageType !== SYNC_STEP_1 && !ws.data.canWrite) return let bound: RelayDoc if (ws.data.boundDocs.has(frame.docId)) { @@ -376,12 +374,15 @@ export function createCollabSocketLayer(relay: CollabRelay) { return } - // Partial writers (canWrite but not every site capability) pass each - // update through the category guard BEFORE it touches the - // authoritative doc — the same structure/content/style rules the HTTP - // save enforces. Both non-step1 message types (step2 replies and - // updates) carry `varUint8Array update` after the type varUint, so one - // extraction covers whatever a hand-crafted client might send. + // Every non-full-writer — partial-capability editors AND read-only + // viewers — passes each update through the category guard BEFORE it + // touches the authoritative doc, the same structure/content/style rules + // the HTTP save enforces. A read-only viewer holds no write capability, + // so the guard refuses any real change and the reset below reverts it on + // the sender's own screen; a viewer's empty handshake step2 diffs to + // nothing and passes as a no-op. Both non-step1 message types (step2 + // replies and updates) carry `varUint8Array update` after the type + // varUint, so one extraction covers whatever a client might send. if (messageType !== SYNC_STEP_1 && !ws.data.fullSiteWriter) { const guardDecoder = decoding.createDecoder(frame.payload) decoding.readVarUint(guardDecoder) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index aa68421fd..b01e750e0 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -207,7 +207,7 @@ describe('collab relay integration (real server, real sockets)', () => { }) }) - it('drops update frames from a read-only connection', async () => { + 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}` @@ -218,19 +218,22 @@ describe('collab relay integration (real server, real sockets)', () => { }) const writer = connectClient(stack) const readOnly = connectClient(stack, viewer.cookie) + const resets: string[] = [] + readOnly.onReset((id) => resets.push(id)) const boundWriter = writer.bind(docId) const boundReadOnly = readOnly.bind(docId) await boundWriter.whenSynced await boundReadOnly.whenSynced const rootId = treeMap(boundReadOnly.doc).get('rootNodeId') as string - // The read-only client's local doc changes, but the server ignores the - // update frame — no other peer ever sees it. + // The read-only client mutates its local doc (a not-yet-disabled UI + // affordance, a plugin, the console). The server refuses the update — no + // other peer ever sees it. setNodeLabel(boundReadOnly.doc, rootId, 'Sneaky viewer edit') // Happened-after marker on a DIFFERENT key — writing the same key would // make the assertion depend on Yjs' concurrent-set clientID tiebreak - // (random per run), not on the server's drop. Once the marker lands on + // (random per run), not on the server's refusal. Once the marker lands on // the viewer, the server has processed both frames. boundWriter.doc.transact(() => { const nodes = treeMap(boundWriter.doc).get('nodes') as Y.Map @@ -241,8 +244,17 @@ describe('collab relay integration (real server, real sockets)', () => { return (nodes.get(rootId) as Y.Map).get('marker') === 'writer-was-here' }) - // The sneaky edit never reached the WRITER — the server dropped it. + // The sneaky edit never reached the WRITER — the guard refused it. expect(nodeLabel(boundWriter.doc, rootId)).not.toBe('Sneaky viewer edit') + + // …and the refusal RESETS the viewer, so its own optimistic edit reverts + // instead of stranding it in a divergent doc that still reports "synced". + // A read-only connection used to be dropped silently at a `canWrite` gate + // with no reset, leaving exactly that permanent local divergence. + await waitFor(() => resets.includes(docId)) + const rebound = readOnly.bind(docId) + await rebound.whenSynced + expect(nodeLabel(rebound.doc, rootId)).not.toBe('Sneaky viewer edit') }) it('enforces per-category capabilities on partial writers and relays read-only presence', async () => { diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts index e8b6ca7fb..b1e8fad2b 100644 --- a/src/__tests__/server/collabSocket.test.ts +++ b/src/__tests__/server/collabSocket.test.ts @@ -73,11 +73,29 @@ describe('collab socket — upgrade gating', () => { expect(server.data()).toBeNull() }) - it('upgrades an authenticated same-origin handshake with canWrite resolved from capabilities', async () => { + it('upgrades an authenticated same-origin handshake with fullSiteWriter resolved from capabilities', async () => { const { harness, cookie } = await setup() const server = fakeServer(true) expect(await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, server)).toBeNull() - expect(server.data()?.canWrite).toBe(true) // the owner holds every site-write cap + // The owner holds every site-write cap, so it skips the per-update guard. + expect(server.data()?.fullSiteWriter).toBe(true) + + // A partial writer (one category) is NOT a full writer — its frames run + // through the guard, same path a read-only viewer takes. + const contentOnly = await harness.createRoleUser({ + name: 'Content Only', + slug: 'site-content-only', + capabilities: ['site.read', 'site.content.edit'], + }) + const contentServer = fakeServer(true) + expect( + await handleCollabSocketUpgrade( + socketRequest({ cookie: contentOnly.cookie }), + harness.db, + contentServer, + ), + ).toBeNull() + expect(contentServer.data()?.fullSiteWriter).toBe(false) const readOnly = await harness.createRoleUser({ name: 'Site Viewer', @@ -92,7 +110,7 @@ describe('collab socket — upgrade gating', () => { readOnlyServer, ), ).toBeNull() - expect(readOnlyServer.data()?.canWrite).toBe(false) + expect(readOnlyServer.data()?.fullSiteWriter).toBe(false) }) it('returns 426 when the request is not an upgradable WebSocket handshake', async () => { From 3e286f3fee3ae6d8c41544de828e6acde597f3ea Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Mon, 27 Jul 2026 18:08:53 +0200 Subject: [PATCH 45/49] feat(collab): refuse edits the transport can't deliver, and tell the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no client-side save pipeline behind the relay: an edit that cannot be delivered is an edit that is gone. Until now a local mutation applied to the store optimistically even while the socket was down or a doc had not finished its first sync, so the user kept typing into work the server would never accept and was told nothing — silence was how their edits disappeared. `applyLocalSitePatches` now returns a `LocalPatchOutcome` instead of a bare boolean. When the transport can't send (`transportBlockReason`), the mutation is refused wholesale — `mutateActiveTree` reports it as not-accepted, the node-insert action returns '' rather than a phantom id, and the user gets one toast per block EPISODE (offline / connecting / syncing), not one per keystroke. The first notice of an episode also ends the inline-edit session so the contentEditable snaps back to the last value the relay actually took. Finishing this pushed collabBinding.ts past the 700-line god-file ceiling, so the block/reset policy and its user-facing copy move to a sibling collabNotices.ts (transportBlockReason, collabBlockToast, clearCollabBlockNotice, collabResetToast, the copy maps, and the BlockedReason/LocalPatchOutcome types). collabBinding keeps only the thin notifyCollabBlocked wrapper that pairs the toast with the store side effect. Behavior-preserving: the collab provider and editor-store suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../site/store/slices/site/collabBinding.ts | 80 ++++++++++------- .../site/store/slices/site/collabNotices.ts | 89 +++++++++++++++++++ .../pages/site/store/slices/site/helpers.ts | 15 ++-- .../site/store/slices/site/nodeActions.ts | 8 +- 4 files changed, 152 insertions(+), 40 deletions(-) create mode 100644 src/admin/pages/site/store/slices/site/collabNotices.ts diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 5d55c890e..8e029643c 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -64,19 +64,14 @@ import type { EditorStoreApi } from '@site/store/types' import { pruneCanvasSelectionDraft } from '../selectionSlice' import type { Awareness } from 'y-protocols/awareness' import type { CollabProvider } from '@site/collab/collabProvider' -import { pushToast } from '@ui/components/Toast' -import type { ResetReason } from '@core/collab' - -/** - * What the user is told when the server discards a doc they were editing. - * `rewritten` never reaches here — it is the routine out-of-relay reseed. - */ -const RESET_REASON_COPY: Record = { - rewritten: '', - stale: 'This document was rebuilt while you were disconnected, so unsent changes were discarded. The latest version is now loaded.', - refused: 'Your role does not allow that change, so it was undone.', - oversize: 'That change was too large to sync in one step and was undone. Try making it in smaller pieces.', -} +import { + collabBlockToast, + clearCollabBlockNotice, + collabResetToast, + transportBlockReason, + type BlockedReason, + type LocalPatchOutcome, +} from './collabNotices' interface ManagedDoc { doc: Y.Doc @@ -99,6 +94,7 @@ let redoRoute: string[][] = [] const lastCoalesce = new Map() let provider: CollabProvider | null = null let detachProviderReset: (() => void) | null = null +let detachProviderStatus: (() => void) | null = null const pendingProjections = new Set() let projectionFlushScheduled = false /** @@ -219,22 +215,31 @@ const managedDocSet: CollabDocSet = { // --------------------------------------------------------------------------- /** - * Translate one local mutation's site patches into the docs. Returns false - * when the write is currently gated (connected but the target docs have not - * finished their first sync) — the caller then rejects the mutation. + * Tell the user their edit did not land (see {@link collabBlockToast} for the + * once-per-episode latch) and, on the first notice of an episode, snap the + * in-flight contentEditable back. Characters typed during the block live only + * in the DOM; ending the session re-renders that node from the model, so what + * the user sees returns to the last value we actually accepted. NOT + * `cancelInlineEdit` — that calls undo(), which is itself blocked right now. */ +export function notifyCollabBlocked(reason: BlockedReason): void { + if (collabBlockToast(reason)) storeApi?.getState().endInlineEdit() +} + export function applyLocalSitePatches( patches: Patches, preSite: SiteDocument, nextSite: SiteDocument, coalesceKey: string | null, -): boolean { +): LocalPatchOutcome { + const blocked = transportBlockReason(provider) + if (blocked) return { accepted: false, reason: blocked } if (provider) { // 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 false + if (!binding.synced) return { accepted: false, reason: 'syncing' } } } @@ -274,7 +279,7 @@ export function applyLocalSitePatches( const touched = applySitePatchesToDocs(patches, preSite, nextSite, decidingDocSet, LOCAL_ORIGIN) alignedSiteRef = nextSite - if (touched.length === 0) return true + if (touched.length === 0) return { accepted: true } // Docs whose undo stack GREW form this step's routing group. Docs that // only folded into their existing top item (coalescing) push nothing — @@ -287,7 +292,7 @@ export function applyLocalSitePatches( redoRoute = [] syncUndoFlags() } - return true + return { accepted: true } } /** End any in-progress coalescing burst (inline-edit session boundaries). */ @@ -307,6 +312,16 @@ function syncUndoFlags(): void { } export function collabUndo(): boolean { + // Undo transacts with `origin = undoManager`, so it never passes through + // applyLocalSitePatches and the write gate cannot see it. Offline, a Cmd+Z + // would otherwise be applied to the doc, projected into the store, and then + // dropped by sendFrame — displayed and lost, with no warning. Checked BEFORE + // undoRoute.pop() so a refused undo does not consume a history step. + const blocked = transportBlockReason(provider) + if (blocked) { + notifyCollabBlocked(blocked) + return false + } while (undoRoute.length > 0) { const group = undoRoute.pop()! let undid = false @@ -334,6 +349,11 @@ export function collabUndo(): boolean { } export function collabRedo(): boolean { + const blocked = transportBlockReason(provider) + if (blocked) { + notifyCollabBlocked(blocked) + return false + } while (redoRoute.length > 0) { const group = redoRoute.pop()! let redid = false @@ -629,19 +649,15 @@ function bindThroughProvider(docIds: readonly string[]): void { */ export function connectCollabProvider(next: CollabProvider): void { provider = next + 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. + detachProviderStatus = next.onStatus((status) => { + if (status === 'connected') clearCollabBlockNotice() + }) detachProviderReset?.() detachProviderReset = next.onReset((docId, reason) => { - // 'rewritten' is the routine out-of-relay reseed — another admin saved a - // setting, a plugin installed, the data workspace edited a row. Nothing of - // this user's was lost, so saying anything would be noise. The other three - // mean work of theirs was discarded, and they must be told. - if (reason !== 'rewritten') { - pushToast({ - kind: 'error', - title: 'A change was reverted', - body: RESET_REASON_COPY[reason], - }) - } + collabResetToast(reason) // The doc's undo history belongs to the lineage that just died: its // UndoManager is rebuilt empty below, so any routing entry still naming // this doc would make Cmd+Z a silent no-op that consumes a step. @@ -666,6 +682,8 @@ export function connectCollabProvider(next: CollabProvider): void { export function disconnectCollabProvider(): void { detachProviderReset?.() detachProviderReset = null + detachProviderStatus?.() + detachProviderStatus = null provider?.destroy() provider = null providerBindings.clear() diff --git a/src/admin/pages/site/store/slices/site/collabNotices.ts b/src/admin/pages/site/store/slices/site/collabNotices.ts new file mode 100644 index 000000000..c621b77de --- /dev/null +++ b/src/admin/pages/site/store/slices/site/collabNotices.ts @@ -0,0 +1,89 @@ +/** + * Collab block/reset policy and the user-facing copy that goes with it — kept + * out of the binding engine so `collabBinding.ts` stays about docs, undo, and + * projection rather than about wording and transport-state checks. + * + * Two situations reach the user: + * - BLOCK: a local edit the transport cannot deliver right now. There is no + * client-side save behind the relay, so an undeliverable edit is a lost + * edit — the editor refuses it rather than showing work already gone. + * - RESET: the server discarded a doc the user was editing (stale lineage, + * a refused write, an oversize update). Their unsent work is gone and they + * must be told; the routine out-of-relay reseed (`rewritten`) is silent. + */ +import { pushToast } from '@ui/components/Toast' +import type { ResetReason } from '@core/collab' +import type { CollabProvider } from '@site/collab/collabProvider' + +/** + * Why a local mutation was refused. There is no client-side persistence and no + * save path behind the relay, so an edit that cannot be delivered is an edit + * that is gone — the editor refuses it rather than showing work it has already + * lost. + */ +export type BlockedReason = 'connecting' | 'offline' | 'syncing' + +export type LocalPatchOutcome = { accepted: true } | { accepted: false; reason: BlockedReason } + +/** + * Can an edit made right now actually reach the relay? + * + * Detached mode (unit tests, and the window before the socket exists) has no + * server to lose anything to, so it never blocks. Otherwise this is the + * provider's own `canSend()` — one fact, shared with `sendFrame`, rather than + * two views that can disagree. + */ +export function transportBlockReason(provider: CollabProvider | null): BlockedReason | null { + if (!provider) return null + if (provider.canSend()) return null + return provider.status() === 'offline' ? 'offline' : 'connecting' +} + +const BLOCK_COPY: Record = { + offline: 'You are offline, so that change was not applied. It will work again as soon as the connection is back.', + connecting: 'Still connecting to the server — that change was not applied. Try again in a moment.', + syncing: 'This document is still loading — that change was not applied. Try again in a moment.', +} + +const RESET_REASON_COPY: Record = { + rewritten: '', + stale: 'This document was rebuilt while you were disconnected, so unsent changes were discarded. The latest version is now loaded.', + refused: 'Your role does not allow that change, so it was undone.', + oversize: 'That change was too large to sync in one step and was undone. Try making it in smaller pieces.', +} + +/** + * Latch so a burst of refused typing is ONE message per block EPISODE, not one + * per keystroke. Cleared by {@link clearCollabBlockNotice} when the transport + * recovers, so the next outage speaks up instead of being swallowed. + */ +let blockToastShown = false + +/** + * Toast the block message, at most once per episode. Returns `true` on the + * FIRST toast of an episode so the binding can pair it with its own side effect + * (snapping the in-flight contentEditable back), and `false` on the swallowed + * repeats. + */ +export function collabBlockToast(reason: BlockedReason): boolean { + if (blockToastShown) return false + blockToastShown = true + pushToast({ kind: 'error', title: 'Change not applied', body: BLOCK_COPY[reason] }) + return true +} + +/** Called whenever the transport recovers, so the next block can speak again. */ +export function clearCollabBlockNotice(): void { + blockToastShown = false +} + +/** + * Toast that the server discarded a doc the user was editing. `rewritten` is + * the routine out-of-relay reseed — another admin saved a setting, a plugin + * installed, the data workspace edited a row. Nothing of this user's was lost, + * so it stays silent; the other three mean their work was discarded. + */ +export function collabResetToast(reason: ResetReason): void { + if (reason === 'rewritten') return + pushToast({ kind: 'error', title: 'A change was reverted', body: RESET_REASON_COPY[reason] }) +} diff --git a/src/admin/pages/site/store/slices/site/helpers.ts b/src/admin/pages/site/store/slices/site/helpers.ts index a3c3457ef..ca23783bb 100644 --- a/src/admin/pages/site/store/slices/site/helpers.ts +++ b/src/admin/pages/site/store/slices/site/helpers.ts @@ -20,7 +20,7 @@ import type { Draft, Patches } from 'mutative' import type { ImportFragment } from '@core/htmlImport' import type { NewStyleRule } from '@core/siteImport' import { addImportedScriptDependencies, addImportedScripts, addImportedStylesheets } from './importedSiteFiles' -import { applyLocalSitePatches } from './collabBinding' +import { applyLocalSitePatches, notifyCollabBlocked } from './collabBinding' import type { EditorStore } from '@site/store/types' import { reconcileFrameworkClasses } from './framework/reconcile' import { indexStyleRulesByName, linkImportedClassNames } from './importLinking' @@ -192,12 +192,13 @@ export function buildSiteHelpers( .map((p) => ({ ...p, path: p.path.slice(1) })) if (siteForward.length > 0) { - const accepted = applyLocalSitePatches(siteForward, cur.site, next.site!, coalesceKey) - if (!accepted) { - // Connected but not yet synced — the mutation is rejected wholesale - // so an unseeded doc can never receive local ops (which would - // duplicate server content on merge). - console.warn('[collab] edit dropped — documents still syncing') + const outcome = applyLocalSitePatches(siteForward, cur.site, next.site!, coalesceKey) + if (!outcome.accepted) { + // The edit cannot reach the relay, and there is no save path behind + // it — so it is refused wholesale rather than applied to a store the + // server will never agree with. The user is told; silence here is how + // work disappeared. + notifyCollabBlocked(outcome.reason) return false } } diff --git a/src/admin/pages/site/store/slices/site/nodeActions.ts b/src/admin/pages/site/store/slices/site/nodeActions.ts index 1cffefdf2..b77df010e 100644 --- a/src/admin/pages/site/store/slices/site/nodeActions.ts +++ b/src/admin/pages/site/store/slices/site/nodeActions.ts @@ -161,7 +161,11 @@ export function createNodeActions(helpers: SiteSliceHelpers): NodeActions { const newNode = createNode(moduleId, resolvedDefaults) let inserted = false let blockedByOutlet = false - mutateActiveTree((tree) => { + // `mutateActiveTree` returns whether the mutation was ACCEPTED. The + // Mutative recipe below runs before the collab write gate, so `inserted` + // alone would hand callers — including the AI and MCP tools — a live id + // for a node that exists nowhere. + const accepted = mutateActiveTree((tree) => { // Structural invariant: a document tree holds AT MOST ONE base.outlet. // Matched content (a page or the current entry body) flows into a single // outlet — both the publisher's `composeTemplateChain` and the canvas's @@ -183,7 +187,7 @@ export function createNodeActions(helpers: SiteSliceHelpers): NodeActions { 'This document already has a content outlet — matched content can flow into just one.', ) } - return inserted ? newNode.id : '' + return accepted && inserted ? newNode.id : '' }, insertImportedNodes: (parentId, fragment, opts) => { From eb5558582d75633d58a4c48deb07daa96bb47cfa Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Mon, 27 Jul 2026 21:18:12 +0200 Subject: [PATCH 46/49] fix(collab): co-typing one text node no longer deletes the peer's characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline-edit contentEditable was seeded once per session and never received remote Y.Text changes; every keystroke committed the element's WHOLE string through the snapshot diff (applyInlineEditValue → applyTextDiff). With a peer's characters in the doc but absent from the frozen surface, the next local keystroke's diff read them as a local deletion and removed them from the CRDT — every replica converged to text missing the peer's edit. Reproduced end-to-end on a canvas mount: peer typed " world" into "hello", one local "!" keystroke converged BOTH replicas to "hello!". Fix at source: attachInlineEditRemoteMerge observes the edited prop's Y.Text for the session's lifetime and folds every non-local change into the DOM through the same seeding writer, restoring the local caret at an index transformed through the Yjs delta (insert-at-caret pushes right, matching relative-position association). IME composition defers the rewrite to compositionend, with the caret transformed through a synthesized single-splice delta. Wired in NodeRenderer's session layout effect; nodeTextOf deduped out of caretPositions into @core/collab. Mutation check: with the NodeRenderer wiring reverted, both canvas behavioral tests fail (surface frozen at "hello" while the store shows "hello world") and the wiring source gate fails; restored, all green. Verified: bun run build, bun test (6353 pass ×2), bun run lint. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/features/site-shell.md | 15 + .../canvas/inlineTextEditingWiring.test.ts | 9 + .../collab/inlineEditRemoteMerge.test.tsx | 273 ++++++++++++++++++ src/admin/pages/site/canvas/NodeRenderer.tsx | 40 ++- src/admin/pages/site/collab/caretPositions.ts | 17 +- .../site/collab/inlineEditRemoteMerge.ts | 248 ++++++++++++++++ src/core/collab/index.ts | 1 + src/core/collab/schema.ts | 18 ++ 8 files changed, 598 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/collab/inlineEditRemoteMerge.test.tsx create mode 100644 src/admin/pages/site/collab/inlineEditRemoteMerge.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 91fa72095..111646c7e 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -511,6 +511,21 @@ anything unattributable repopulates the doc (the conservative escape hatch). Remote/undo/reconcile changes flow the OTHER way: a per-doc projection replaces the affected row or shell in the store. +One surface needs more than the projection: the inline text editor is a +contentEditable React does not own, and every keystroke commits the element's +WHOLE string back through the snapshot diff. A frozen surface would therefore +make the next local keystroke delete a peer's concurrent characters from the +CRDT (the snapshot doesn't contain them, so the diff reads them as a local +deletion). During a session, `attachInlineEditRemoteMerge` +(`src/admin/pages/site/collab/inlineEditRemoteMerge.ts`, wired by +`NodeRenderer`'s session effect) observes the edited prop's Y.Text and folds +every non-local change into the DOM — content rewritten through the same +seeding writer, local caret restored at an index transformed through the Yjs +delta (insert-at-caret pushes right, matching relative-position association). +IME composition defers the rewrite to `compositionend`. This is what makes +co-typing ONE text node intent-preserving, not just convergent — gated +end-to-end by `src/__tests__/collab/inlineEditRemoteMerge.test.tsx`. + **Undo** is per-editor and per-doc: Y.UndoManagers track only `LOCAL_ORIGIN` (a peer's edits are never undone by your Cmd+Z), coalescing reproduces the old `coalesceKey` typing-burst semantics, and multi-doc diff --git a/src/__tests__/canvas/inlineTextEditingWiring.test.ts b/src/__tests__/canvas/inlineTextEditingWiring.test.ts index 63a3087fb..38df4fcbf 100644 --- a/src/__tests__/canvas/inlineTextEditingWiring.test.ts +++ b/src/__tests__/canvas/inlineTextEditingWiring.test.ts @@ -59,6 +59,15 @@ describe('inline text editing wiring (in-place contentEditable)', () => { expect(src).toContain('el.focus()') }) + it('NodeRenderer keeps the session surface live under co-editing (remote Y.Text merge)', () => { + // Without this attach, a peer's characters never reach the frozen + // contentEditable and the next local keystroke's whole-string snapshot + // commit DELETES them from the CRDT. Behavior gated end-to-end by + // src/__tests__/collab/inlineEditRemoteMerge.test.tsx. + const src = readFileSync(NODE_RENDERER, 'utf-8') + expect(src).toContain('attachInlineEditRemoteMerge') + }) + it('the canvas keyboard handler bails while an inline edit is active', () => { const src = readFileSync(KEYBOARD_SHORTCUTS, 'utf-8') expect(src).toContain('if (useEditorStore.getState().activeInlineEdit) return') diff --git a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx new file mode 100644 index 000000000..39e70db91 --- /dev/null +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -0,0 +1,273 @@ +/** + * Co-typing ONE text node — the headline collab promise ("two admins can + * co-type one text node simultaneously"). + * + * The inline-edit surface is a contentEditable that React does not own: it is + * seeded once at session start and every keystroke commits the element's + * WHOLE string through `applyInlineEditValue` → `applyTextDiff`. If a remote + * peer's characters land in the doc mid-session but never reach the frozen + * surface, the next local keystroke's snapshot diff DELETES them from the + * CRDT — both replicas converge, to text missing the peer's edit. + * + * These tests mount the real canvas (NodeRenderer wiring, iframe portal) and + * play the remote peer at the Y level, proving the surface merges remote + * edits mid-session and the next keystroke preserves both intents. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import React from 'react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import * as Y from 'yjs' +import { CanvasTransformLayer } from '@site/canvas/CanvasTransformLayer' +import { useEditorStore } from '@site/store/store' +import { encodeCollabDocId, LOCAL_ORIGIN, seedPageDoc, treeMap } from '@core/collab' +import { collabDocFor } from '@site/store/slices/site/collabBinding' +import { + attachInlineEditRemoteMerge, + transformIndexThroughTextDelta, +} from '@site/collab/inlineEditRemoteMerge' +import { seedInlineEditableContent } from '@modules/base/shared/inlineText' +import { waitForCanvasNodeInFrame } from '../canvas/iframeCanvasQuery' +import { makeNode, makePage } from '../fixtures' +import '@modules/base' + +const originalFetch = globalThis.fetch + +function yTextOf(doc: Y.Doc, nodeId: string): Y.Text { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + const props = node.get('props') as Y.Map + return props.get('text') as Y.Text +} + +function storeText(nodeId: string): unknown { + const site = useEditorStore.getState().site + if (!site) return undefined + for (const page of site.pages) { + if (page.nodes[nodeId]) return page.nodes[nodeId].props.text + } + return undefined +} + +beforeEach(() => { + cleanup() + document.body.replaceChildren() + globalThis.fetch = (async () => new Response('', { status: 404 })) as typeof fetch + useEditorStore.getState().clearSite() +}) + +afterEach(() => { + cleanup() + useEditorStore.getState().clearSite() + document.body.replaceChildren() + globalThis.fetch = originalFetch +}) + +/** Create a real site via store actions (keeps the detached collab docs + * aligned), insert a text node, and mount the canvas on the desktop frame. */ +async function setupEditingSession(initialText: string): Promise<{ + nodeId: string + pageId: string + editable: HTMLElement + local: Y.Doc +}> { + const store = useEditorStore.getState() + store.createSite('Co-typing') + const pageId = useEditorStore.getState().activePageId! + const page = useEditorStore.getState().site!.pages.find((p) => p.id === pageId)! + const nodeId = useEditorStore + .getState() + .insertNode('base.text', { text: initialText }, page.rootNodeId) + + render( + p.id === pageId)!} + breakpoints={useEditorStore.getState().site!.breakpoints} + activeBreakpointId="desktop" + onBreakpointActivate={() => {}} + />, + ) + await waitForCanvasNodeInFrame('desktop', nodeId) + + await act(async () => { + useEditorStore.getState().startInlineEdit(nodeId, 'desktop') + }) + const editable = await waitForCanvasNodeInFrame('desktop', nodeId) + await waitFor(() => expect(editable.getAttribute('contenteditable')).toBeTruthy()) + expect(editable.textContent).toBe(initialText) + + const local = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: pageId }))! + expect(local).toBeTruthy() + return { nodeId, pageId, editable, local } +} + +/** Clone the local doc into a peer replica, mutate its Y.Text, sync back. */ +async function remoteTextEdit( + local: Y.Doc, + nodeId: string, + edit: (text: Y.Text) => void, +): Promise { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local)) + edit(yTextOf(remote, nodeId)) + await act(async () => { + Y.applyUpdate(local, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(local))) + // Flush the projection microtask so the store catches up too. + await Promise.resolve() + }) + return remote +} + +describe('co-typing one text node (remote merge into the inline-edit surface)', () => { + it('a remote peer edit lands in the editing surface and survives the next local keystroke', async () => { + const { nodeId, editable, local } = await setupEditingSession('hello') + + // Remote peer appends " world" while the local session is open. + const remote = await remoteTextEdit(local, nodeId, (t) => t.insert(5, ' world')) + + // The store projected the remote edit (this always worked)... + expect(storeText(nodeId)).toBe('hello world') + // ...and the SESSION SURFACE shows it too. A frozen surface here means the + // next keystroke's whole-string snapshot diff deletes " world" from the CRDT. + expect(editable.textContent).toBe('hello world') + + // Local user keeps typing: append "!". + editable.textContent = `${editable.textContent}!` + fireEvent.input(editable) + + // Intent preservation: BOTH edits survive, on every replica. + expect(yTextOf(local, nodeId).toString()).toBe('hello world!') + expect(storeText(nodeId)).toBe('hello world!') + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + expect(yTextOf(remote, nodeId).toString()).toBe('hello world!') + }) + + it('interleaved co-typing keeps every character from both peers', async () => { + const { nodeId, editable, local } = await setupEditingSession('ab') + + // Peer prepends "X" — local surface must become "Xab". + const remote = await remoteTextEdit(local, nodeId, (t) => t.insert(0, 'X')) + expect(editable.textContent).toBe('Xab') + + // Local types "!" at the end. + editable.textContent = `${editable.textContent}!` + fireEvent.input(editable) + expect(yTextOf(local, nodeId).toString()).toBe('Xab!') + + // Peer deletes its own "X" concurrently with another local keystroke. + await remoteTextEdit(local, nodeId, (t) => t.delete(0, 1)) + expect(editable.textContent).toBe('ab!') + editable.textContent = `${editable.textContent}?` + fireEvent.input(editable) + expect(yTextOf(local, nodeId).toString()).toBe('ab!?') + expect(storeText(nodeId)).toBe('ab!?') + // The first remote replica converges to the same text. + Y.applyUpdate(remote, Y.encodeStateAsUpdate(local, Y.encodeStateVector(remote))) + expect(yTextOf(remote, nodeId).toString()).toBe('ab!?') + }) +}) + +describe('transformIndexThroughTextDelta', () => { + it('shifts the caret right past inserts at or before it', () => { + // "hello" → "Xhello": caret 3 → 4. + expect(transformIndexThroughTextDelta([{ insert: 'X' }], 3)).toBe(4) + // Insert exactly AT the caret pushes it right (Y relative-position rule). + expect(transformIndexThroughTextDelta([{ retain: 3 }, { insert: 'ab' }], 3)).toBe(5) + // Insert after the caret leaves it alone. + expect(transformIndexThroughTextDelta([{ retain: 4 }, { insert: 'z' }], 3)).toBe(3) + }) + + it('shifts the caret left past deletes before it and clamps inside a spanning delete', () => { + // Delete [0,2) with caret at 3 → 1. + expect(transformIndexThroughTextDelta([{ delete: 2 }], 3)).toBe(1) + // Delete [1,4) spans the caret at 2 → collapses to the delete start (1). + expect(transformIndexThroughTextDelta([{ retain: 1 }, { delete: 3 }], 2)).toBe(1) + // Delete at/after the caret leaves it alone. + expect(transformIndexThroughTextDelta([{ retain: 3 }, { delete: 2 }], 3)).toBe(3) + }) + + it('composes mixed op sequences', () => { + // "hello world" → "hi world!": caret after "hello" (5). + const delta = [{ retain: 1 }, { delete: 4 }, { insert: 'i' }, { retain: 6 }, { insert: '!' }] + expect(transformIndexThroughTextDelta(delta, 5)).toBe(2) + }) +}) + +describe('attachInlineEditRemoteMerge (surface-level)', () => { + function seededSurface(text: string, nodeId = 't1') { + const doc = new Y.Doc() + seedPageDoc( + doc, + makePage({ + id: 'p1', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: [nodeId] }), + [nodeId]: makeNode({ id: nodeId, moduleId: 'base.text', props: { text, tag: 'p' } }), + }, + }), + ) + const el = document.createElement('p') + document.body.appendChild(el) + seedInlineEditableContent(el, text) + const detach = attachInlineEditRemoteMerge({ el, doc, nodeId, prop: 'text' }) + return { doc, el, nodeId, detach } + } + + function remoteInsert(doc: Y.Doc, nodeId: string, index: number, value: string): void { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(doc)) + yTextOf(remote, nodeId).insert(index, value) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(doc))) + } + + it('preserves the local caret across a remote insert before it — with
line breaks', () => { + const { doc, el, nodeId, detach } = seededSurface('ab\ncd') + // Children: "ab",
, "cd" — caret between "c" and "d" (absolute offset 4). + const cdNode = el.childNodes[2] + const range = document.createRange() + range.setStart(cdNode, 1) + range.collapse(true) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + remoteInsert(doc, nodeId, 0, 'Z') + + expect(el.innerHTML).toBe('Zab
cd') + // Caret followed its character: still between "c" and "d". + expect(selection.anchorNode?.textContent).toBe('cd') + expect(selection.anchorOffset).toBe(1) + detach() + }) + + it('ignores LOCAL_ORIGIN transactions (the DOM is where those came from)', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + // A local applyTextDiff-style write: the surface already shows it, so the + // merge must not touch the DOM (a rewrite would collapse the caret). + el.appendChild(el.ownerDocument.createTextNode('!')) + const marker = el.firstChild + doc.transact(() => { + yTextOf(doc, nodeId).insert(5, '!') + }, LOCAL_ORIGIN) + expect(el.firstChild).toBe(marker) // untouched — no rewrite happened + expect(el.textContent).toBe('hello!') + detach() + }) + + it('defers the merge during IME composition and applies it on compositionend', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + el.dispatchEvent(new Event('compositionstart')) + remoteInsert(doc, nodeId, 5, ' world') + expect(el.textContent).toBe('hello') // frozen mid-composition + el.dispatchEvent(new Event('compositionend')) + expect(el.textContent).toBe('hello world') + detach() + }) + + it('stops merging after detach', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + detach() + remoteInsert(doc, nodeId, 0, 'X') + expect(el.textContent).toBe('hello') + }) +}) diff --git a/src/admin/pages/site/canvas/NodeRenderer.tsx b/src/admin/pages/site/canvas/NodeRenderer.tsx index 27c8928b4..30749a9e7 100644 --- a/src/admin/pages/site/canvas/NodeRenderer.tsx +++ b/src/admin/pages/site/canvas/NodeRenderer.tsx @@ -22,6 +22,9 @@ import { memo, use, useLayoutEffect, useRef, useSyncExternalStore } from 'react' import type { InlineEditBinding } from '@core/module-engine' import { readInlineEditableText, seedInlineEditableContent } from '@modules/base/shared/inlineText' +import { activeEditorDocId } from '@site/collab/awarenessState' +import { attachInlineEditRemoteMerge } from '@site/collab/inlineEditRemoteMerge' +import { collabDocFor } from '@site/store/slices/site/collabBinding' import { useEditorStore, selectActiveCanvasPage } from '@site/store/store' import { resolveProps } from '@core/page-tree' import { registry } from '@core/module-engine' @@ -172,8 +175,11 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP // caret at the end. Layout effect → runs before paint, so the editor is live // on the first frame. The element lives in the breakpoint iframe // (same-origin); focusing it focuses the iframe in the parent — no - // cross-frame negotiation needed. Deps are constant for the whole session, so - // this runs once per session (never mid-edit, which would wipe the edits). + // cross-frame negotiation needed. Deps are constant for the whole session, + // so this runs once per session — React never rewrites mid-edit; the ONLY + // mid-session writer is the remote merge attached below, which folds a + // peer's Y.Text edits into the surface with the local caret transformed + // (see inlineEditRemoteMerge.ts for why a frozen surface would lose them). // Trade-off: a programmatic mutation that swaps the node's element mid-session // (e.g. an RPC changing base.text's `tag`) remounts a fresh, unseeded element // and is not re-seeded. Unreachable from the UI — interacting with the @@ -186,13 +192,29 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP el.focus() const doc = el.ownerDocument const sel = doc.defaultView?.getSelection() - if (!sel) return - const range = doc.createRange() - range.selectNodeContents(el) - range.collapse(false) - sel.removeAllRanges() - sel.addRange(range) - }, [isInlineEditing, inlineEditInitialValue]) + if (sel) { + const range = doc.createRange() + range.selectNodeContents(el) + range.collapse(false) + sel.removeAllRanges() + sel.addRange(range) + } + // Co-typing: keep the session surface live. A peer's Y.Text edits merge + // into the contentEditable mid-session (caret transformed through the + // delta) — a frozen surface would make the next local keystroke's + // whole-string snapshot diff DELETE the peer's characters from the CRDT. + const state = useEditorStore.getState() + const session = state.activeInlineEdit + const collabDocId = activeEditorDocId(state) + const collabDoc = collabDocId ? collabDocFor(collabDocId) : null + if (!session || !collabDoc) return + return attachInlineEditRemoteMerge({ + el, + doc: collabDoc, + nodeId, + prop: session.prop, + }) + }, [isInlineEditing, inlineEditInitialValue, nodeId]) const inlineStyle = useResponsiveBackgroundStyle(node?.inlineStyles) diff --git a/src/admin/pages/site/collab/caretPositions.ts b/src/admin/pages/site/collab/caretPositions.ts index 247dea109..581a71bb6 100644 --- a/src/admin/pages/site/collab/caretPositions.ts +++ b/src/admin/pages/site/collab/caretPositions.ts @@ -12,7 +12,7 @@ */ import * as Y from 'yjs' import { fromBase64, toBase64 } from 'lib0/buffer' -import { treeMap } from '@core/collab' +import { nodeTextOf } from '@core/collab' export interface EncodedCaretRange { nodeId: string @@ -22,17 +22,6 @@ export interface EncodedCaretRange { head: string } -function textOf(doc: Y.Doc, nodeId: string, prop: string): Y.Text | null { - const nodes = treeMap(doc).get('nodes') - if (!(nodes instanceof Y.Map)) return null - const node = nodes.get(nodeId) - if (!(node instanceof Y.Map)) return null - const props = node.get('props') - if (!(props instanceof Y.Map)) return null - const value = props.get(prop) - return value instanceof Y.Text ? value : null -} - /** Character offsets → wire-encodable relative positions. */ export function encodeCaretRange( doc: Y.Doc, @@ -41,7 +30,7 @@ export function encodeCaretRange( anchorIndex: number, headIndex: number, ): EncodedCaretRange | null { - const text = textOf(doc, nodeId, prop) + const text = nodeTextOf(doc, nodeId, prop) if (!text) return null const clamp = (index: number) => Math.max(0, Math.min(text.length, index)) return { @@ -57,7 +46,7 @@ export function resolveCaretRange( doc: Y.Doc, caret: EncodedCaretRange, ): { anchor: number; head: number } | null { - const text = textOf(doc, caret.nodeId, caret.prop) + const text = nodeTextOf(doc, caret.nodeId, caret.prop) if (!text) return null try { const anchor = Y.createAbsolutePositionFromRelativePosition( diff --git a/src/admin/pages/site/collab/inlineEditRemoteMerge.ts b/src/admin/pages/site/collab/inlineEditRemoteMerge.ts new file mode 100644 index 000000000..7a1266b39 --- /dev/null +++ b/src/admin/pages/site/collab/inlineEditRemoteMerge.ts @@ -0,0 +1,248 @@ +/** + * Remote merge into the inline-edit surface — what makes co-typing ONE text + * node actually work. + * + * The inline editor is a contentEditable that React does not own: it is + * seeded once at session start and every keystroke commits the element's + * WHOLE string (`applyInlineEditValue` → `applyTextDiff`). That snapshot + * model is only sound while the surface tracks the document. If a peer's + * characters land in the Y.Text mid-session and never reach the frozen + * surface, the next local keystroke's snapshot diff silently DELETES them + * from the CRDT — every replica converges to text missing the peer's edit. + * + * So, for the session's lifetime, this module observes the edited prop's + * Y.Text and folds every non-local change into the DOM: rewrite the content + * through the same writer that seeded it, then restore the local caret at an + * index transformed through the Yjs delta (the same association rule as + * Y relative positions: an insert AT the caret pushes it right). During IME + * composition the rewrite is deferred to `compositionend` — mutating the DOM + * mid-composition breaks the IME transaction — and the caret then transforms + * through a synthesized single-splice delta instead. + */ +import * as Y from 'yjs' +import { LOCAL_ORIGIN, SEED_ORIGIN, nodeTextOf } from '@core/collab' +import { + readInlineEditableText, + seedInlineEditableContent, +} from '@modules/base/shared/inlineText' + +/** One op of a Y.TextEvent delta (Yjs types it loosely; embeds count as 1). */ +interface TextDeltaOp { + retain?: number + insert?: string | object + delete?: number +} + +/** + * Map a character index in the PRE-change text to the equivalent index in + * the POST-change text. Insert-at-caret pushes the caret right — matching + * `Y.createRelativePositionFromTypeIndex`'s default association, so a peer + * typing exactly at your caret produces the same interleaving a relative + * position would. + */ +export function transformIndexThroughTextDelta( + delta: readonly TextDeltaOp[], + index: number, +): number { + let oldPos = 0 + let out = index + for (const op of delta) { + if (oldPos > index) break + if (typeof op.retain === 'number') { + oldPos += op.retain + } else if (typeof op.delete === 'number') { + if (oldPos < index) out -= Math.min(op.delete, index - oldPos) + oldPos += op.delete + } else if (op.insert !== undefined) { + const length = typeof op.insert === 'string' ? op.insert.length : 1 + if (oldPos <= index) out += length + } + } + return Math.max(0, out) +} + +/** Synthesize the minimal single-splice delta turning `from` into `to` — + * the caret-transform fallback when no real Yjs delta is available + * (composition-deferred merges). Same prefix/suffix walk as applyTextDiff. */ +function spliceDelta(from: string, to: string): TextDeltaOp[] { + if (from === to) return [] + let prefix = 0 + const maxPrefix = Math.min(from.length, to.length) + while (prefix < maxPrefix && from[prefix] === to[prefix]) prefix++ + let suffix = 0 + const maxSuffix = Math.min(from.length, to.length) - prefix + while ( + suffix < maxSuffix && + from[from.length - 1 - suffix] === to[to.length - 1 - suffix] + ) { + suffix++ + } + const ops: TextDeltaOp[] = [] + if (prefix > 0) ops.push({ retain: prefix }) + const deleteCount = from.length - prefix - suffix + if (deleteCount > 0) ops.push({ delete: deleteCount }) + const inserted = to.slice(prefix, to.length - suffix) + if (inserted.length > 0) ops.push({ insert: inserted }) + return ops +} + +const TEXT_NODE = 3 + +function isBr(node: Node): boolean { + return node.nodeName === 'BR' +} + +/** Character length of a DOM subtree in the surface's text coordinates + * (text content, `
` = 1 — the `\n` it renders as). */ +function textLengthOf(node: Node): number { + if (node.nodeType === TEXT_NODE) return (node as Text).length + if (isBr(node)) return 1 + let total = 0 + for (const child of Array.from(node.childNodes)) total += textLengthOf(child) + return total +} + +/** Absolute text offset of a DOM position inside `el`, or null when the + * position sits outside `el`. Element-anchored positions count child index. */ +function textOffsetWithin(el: HTMLElement, container: Node, offset: number): number | null { + if (container !== el && !el.contains(container)) return null + let acc = 0 + const walk = (node: Node): boolean => { + if (node === container) { + if (node.nodeType === TEXT_NODE) { + acc += offset + } else { + const children = node.childNodes + const upTo = Math.min(offset, children.length) + for (let i = 0; i < upTo; i++) acc += textLengthOf(children[i]) + } + return true + } + if (node.nodeType === TEXT_NODE) { + acc += (node as Text).length + return false + } + if (isBr(node)) { + acc += 1 + return false + } + for (const child of Array.from(node.childNodes)) { + if (walk(child)) return true + } + return false + } + return walk(el) ? acc : null +} + +/** DOM position for an absolute text offset inside `el` (clamps to the end). */ +function domPositionAt(el: HTMLElement, target: number): { node: Node; offset: number } { + let remaining = Math.max(0, target) + const find = (parent: Node): { node: Node; offset: number } | null => { + const children = Array.from(parent.childNodes) + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (child.nodeType === TEXT_NODE) { + const length = (child as Text).length + if (remaining <= length) return { node: child, offset: remaining } + remaining -= length + } else if (isBr(child)) { + if (remaining === 0) return { node: parent, offset: i } + remaining -= 1 + } else { + const inner = find(child) + if (inner) return inner + } + } + return null + } + return find(el) ?? { node: el, offset: el.childNodes.length } +} + +/** + * Keep a live inline-edit surface merged with its Y.Text for the session's + * lifetime. Attach on session start, call the returned detach on session end. + * No-ops (returns a no-op detach) when the prop is not stored as Y.Text — a + * doc that has never been through the collab translator merges whole-value. + */ +export function attachInlineEditRemoteMerge(options: { + el: HTMLElement + doc: Y.Doc + nodeId: string + prop: string +}): () => void { + const { el, doc, nodeId, prop } = options + const text = nodeTextOf(doc, nodeId, prop) + if (!text) return () => {} + + let composing = false + let deferredWhileComposing = false + + const mergeIntoSurface = (delta: readonly TextDeltaOp[]): void => { + const nextValue = text.toString() + if (readInlineEditableText(el) === nextValue) return + + const view = el.ownerDocument.defaultView + const selection = view?.getSelection() ?? null + let anchor: number | null = null + let head: number | null = null + if (selection && selection.rangeCount > 0 && selection.anchorNode && selection.focusNode) { + anchor = textOffsetWithin(el, selection.anchorNode, selection.anchorOffset) + head = textOffsetWithin(el, selection.focusNode, selection.focusOffset) + } + + seedInlineEditableContent(el, nextValue) + + if (selection && anchor !== null && head !== null) { + const nextAnchor = domPositionAt(el, transformIndexThroughTextDelta(delta, anchor)) + const nextHead = domPositionAt(el, transformIndexThroughTextDelta(delta, head)) + try { + selection.setBaseAndExtent( + nextAnchor.node, + nextAnchor.offset, + nextHead.node, + nextHead.offset, + ) + } catch { + // Selection restore is cosmetic — the merged text is already safe. + // A collapsed-caret fallback beats throwing out of a Y observer. + const range = el.ownerDocument.createRange() + range.setStart(nextHead.node, nextHead.offset) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + + const observer = (event: Y.YTextEvent, transaction: Y.Transaction): void => { + // Local edits are already in the DOM (the DOM is where they came from); + // seeds mirror content the session was opened on. + if (transaction.origin === LOCAL_ORIGIN || transaction.origin === SEED_ORIGIN) return + if (composing) { + deferredWhileComposing = true + return + } + mergeIntoSurface(event.delta as readonly TextDeltaOp[]) + } + + const onCompositionStart = (): void => { + composing = true + } + const onCompositionEnd = (): void => { + composing = false + if (!deferredWhileComposing) return + deferredWhileComposing = false + // No single Yjs delta covers the deferred window — synthesize the splice + // between what the surface shows and what the doc now holds. + mergeIntoSurface(spliceDelta(readInlineEditableText(el), text.toString())) + } + + text.observe(observer) + el.addEventListener('compositionstart', onCompositionStart) + el.addEventListener('compositionend', onCompositionEnd) + return () => { + text.unobserve(observer) + el.removeEventListener('compositionstart', onCompositionStart) + el.removeEventListener('compositionend', onCompositionEnd) + } +} diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 75bebf79d..2f815bf8c 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -18,6 +18,7 @@ export { dataMap, LOCAL_ORIGIN, metaMap, + nodeTextOf, REMOTE_ORIGIN, rostersMap, SEED_CLIENT_ID, diff --git a/src/core/collab/schema.ts b/src/core/collab/schema.ts index 30918c786..a1e728283 100644 --- a/src/core/collab/schema.ts +++ b/src/core/collab/schema.ts @@ -53,3 +53,21 @@ export const SHELL_PER_ENTRY_KEYS = new Set(['settings', 'styleRules', 'explorer export function inlineTextPropOf(moduleId: string): string | null { return registry.get(moduleId)?.inlineTextEdit?.prop ?? null } + +/** + * The Y.Text stored at `nodes[nodeId].props[prop]` of a page/component doc, + * or null when the path is missing or holds a plain value (a node that has + * never been through the collab translator stores plain strings). Shared by + * caret presence and the inline-edit remote merge — both no-op gracefully on + * null. + */ +export function nodeTextOf(doc: Y.Doc, nodeId: string, prop: string): Y.Text | null { + const nodes = treeMap(doc).get('nodes') + if (!(nodes instanceof Y.Map)) return null + const node = nodes.get(nodeId) + if (!(node instanceof Y.Map)) return null + const props = node.get('props') + if (!(props instanceof Y.Map)) return null + const value = props.get(prop) + return value instanceof Y.Text ? value : null +} From 8f3c3f5f0ec6e0f26c89cf1c85b95e6821361fe1 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Mon, 27 Jul 2026 21:20:09 +0200 Subject: [PATCH 47/49] docs(collab): start the cross-run hardening ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the co-typing fix as done, pins what this run verified solid (Y-level merge granularity, canvas co-typing end-to-end, the microtask ordering that protects the store-level diff), and ranks the open findings for the next run — applyTextDiff's missing drift guard first. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/collab-hardening-log.md | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/collab-hardening-log.md diff --git a/docs/collab-hardening-log.md b/docs/collab-hardening-log.md new file mode 100644 index 000000000..1d81de327 --- /dev/null +++ b/docs/collab-hardening-log.md @@ -0,0 +1,90 @@ +# Collab hardening log + +Cross-run ledger for the autonomous review-and-harden loop on real-time +co-editing. Each run: pick the top open finding (or next priority area), break +it, prove it, fix it at source, gate it with a mutation-checked test, log it +here. Keep this file honest — it is the only memory between runs. + +## Verified solid (don't re-review) + +- **Y-level Y.Text merge granularity** — `src/__tests__/collab/merge.test.ts` + pins character-level convergence for concurrent edits on pre-synced + replicas, and `applyTextDiff`'s minimal-splice semantics when its `oldValue` + matches the doc content. (2026-07-27) +- **Co-typing one text node end-to-end (canvas surface)** — remote Y.Text + edits merge into the live contentEditable mid-session with caret transform; + next local keystroke preserves both intents. Gated by + `src/__tests__/collab/inlineEditRemoteMerge.test.tsx` (canvas mount, real + NodeRenderer wiring) + the wiring source gate in + `src/__tests__/canvas/inlineTextEditingWiring.test.ts`. (2026-07-27) +- **Browser event ordering protects the store-level text diff** — remote + updates apply in WS message macrotasks and the projection flushes on a + microtask (`scheduleProjection` → `queueMicrotask`), so the store is always + projected before the next input-event macrotask reads it. The stale party + was the *DOM surface*, not the store (analysis 2026-07-27; the store-level + path has no dedicated contention test — see open finding 1). + +## Open findings (ranked) + +1. **`applyTextDiff` has no drift guard** (`src/core/collab/applyPatches.ts` + ~line 290). `applyChildrenDiff` explicitly detects "actual array ≠ + pre-mutation array" and falls back to a wholesale rewrite; + the Y.Text path applies splice indices computed from the store's + `preValue` blindly. If `existing.toString() !== preValue` (unreachable + from browser input ordering as far as this run could prove, but MCP + bridge / plugin RPC / future callers may differ), the splice corrupts + text or throws Yjs "Length exceeded" mid-transaction. Add the analogous + guard (diff against `existing.toString()` on drift) + a test. +2. **Y.Text instance replacement mid-session freezes the merge** — a remote + whole-node rebuild (`yNodes.set(nodeId, buildNodeMap(...))`) swaps the + props map and its Y.Text; `attachInlineEditRemoteMerge` observes the dead + instance, so the surface silently degrades to frozen (and the next local + keystroke overwrites the peer's whole-node write). Re-resolve the Y.Text + per event (observeDeep on the node map) or force-end the session. +3. **Doc RESET mid-inline-session** — `connectCollabProvider`'s reset handler + rebinds a fresh doc; an active inline session keeps observing the dead + doc's Y.Text and its node may not exist in the reseed. Probably should + force-end the inline session when the active doc resets. +4. **Properties-panel textarea under co-editing** — same whole-string commit + path (`updateNodeProps`), but a controlled input re-rendered from the + store: an in-flight local value can clobber a just-projected remote edit + between renders. Unreviewed; needs the same adversarial pass the canvas + surface got. +5. **Priority areas untouched by any run yet** (from the loop brief, in + order): reconnect/reset overlap + stale-generation frames; crash + durability of the persist debounce; update-guard adversarial bypass + siblings; Postgres parity for `collab_documents` + continuous persistence; + offline-block stress (flaky transport, toast latch); relay/awareness/ + provider leak+lifetime audit. + +## Done this run + +### 2026-07-27 — co-typing one text node deleted the peer's characters + +- **Defect** (priority area 1, the headline promise): the inline-edit + contentEditable was seeded once per session and never received remote + Y.Text changes; every keystroke committed the element's whole string via + the snapshot diff. With a peer's characters in the doc but not in the + frozen surface, the next local keystroke's diff read them as a local + deletion and removed them from the CRDT. +- **Proof**: canvas-mount repro (real NodeRenderer, iframe portal, real + store + detached collab docs, remote replica at the Y level): peer typed + `" world"` into `"hello"`; store projected `"hello world"`; surface stayed + `"hello"`; one local keystroke `"!"` converged BOTH replicas to + `"hello!"` — the peer's edit silently destroyed everywhere. +- **Fix at source**: `src/admin/pages/site/collab/inlineEditRemoteMerge.ts` — + observe the edited prop's Y.Text for the session's lifetime; fold every + non-local change into the DOM via the same seeding writer; restore the + local caret at an index transformed through the Yjs delta (insert-at-caret + pushes right, matching relative-position association); defer rewrites + during IME composition to `compositionend` (caret then transformed through + a synthesized single-splice delta). Wired in `NodeRenderer`'s session + layout effect. Dedup: `nodeTextOf` moved to `@core/collab` schema (was a + private helper in `caretPositions.ts`). +- **Mutation check**: reverted the NodeRenderer wiring → both canvas + behavioral tests fail (frozen surface, `"hello"` ≠ `"hello world"`) and + the wiring source gate fails; restored → all green. +- **Tests**: `src/__tests__/collab/inlineEditRemoteMerge.test.tsx` (2 canvas + end-to-end incl. convergence back to the peer replica, 3 pure caret + transform, 4 surface-level: `
` caret preservation, LOCAL_ORIGIN + no-rewrite, composition deferral, detach). From df290ca5ee4f6fc2fc0a5a9a8d20ef4a26838b38 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 29 Jul 2026 12:56:39 +0200 Subject: [PATCH 48/49] fix(collab): harden persistence and concurrent editing --- docs/collab-hardening-log.md | 110 +++++++--- docs/features/site-shell.md | 13 +- server/collab/relay.ts | 65 ++++-- src/__tests__/collab/applyPatches.test.ts | 25 +++ src/__tests__/collab/awareness.test.tsx | 45 ++++- src/__tests__/collab/collabNotices.test.ts | 79 ++++++++ .../collab/inlineEditRemoteMerge.test.tsx | 54 +++++ .../collab/propertiesPanelTextarea.test.tsx | 43 ++++ src/__tests__/collab/provider.test.ts | 28 +++ src/__tests__/server/collabRelay.test.ts | 96 ++++++++- .../server/collabUpdateGuard.test.ts | 191 ++++++++++++++++++ src/admin/pages/site/canvas/NodeRenderer.tsx | 1 + src/admin/pages/site/collab/collabProvider.ts | 14 +- .../site/collab/inlineEditRemoteMerge.ts | 58 +++++- .../site/store/slices/site/collabBinding.ts | 11 +- .../site/store/slices/site/collabNotices.ts | 27 ++- src/core/collab/applyPatches.ts | 14 +- 17 files changed, 806 insertions(+), 68 deletions(-) create mode 100644 src/__tests__/collab/collabNotices.test.ts create mode 100644 src/__tests__/collab/propertiesPanelTextarea.test.tsx create mode 100644 src/__tests__/server/collabUpdateGuard.test.ts diff --git a/docs/collab-hardening-log.md b/docs/collab-hardening-log.md index 1d81de327..8f2532c33 100644 --- a/docs/collab-hardening-log.md +++ b/docs/collab-hardening-log.md @@ -22,43 +22,91 @@ here. Keep this file honest — it is the only memory between runs. microtask (`scheduleProjection` → `queueMicrotask`), so the store is always projected before the next input-event macrotask reads it. The stale party was the *DOM surface*, not the store (analysis 2026-07-27; the store-level - path has no dedicated contention test — see open finding 1). + path is now additionally guarded against drift). (2026-07-27, hardened + 2026-07-29) +- **Patch-to-Y text drift cannot corrupt the live document** — + `applySitePatchesToDocs` detects a projected pre-value that differs from the + authoritative Y.Text and falls back to a safe diff from the actual value. + Gated by `src/__tests__/collab/applyPatches.test.ts`. (2026-07-29) +- **Whole-node replacement does not freeze an inline session** — + `attachInlineEditRemoteMerge` observes the tree deeply and re-resolves the + current Y.Text after every non-local transaction, so replacing a node map + and its nested text instance keeps merging. Removing the text invalidates + the session. Gated by + `src/__tests__/collab/inlineEditRemoteMerge.test.tsx`. (2026-07-29) +- **RESET closes an inline session only for its active document** — the store + ends the contentEditable session before rebinding the fresh CRDT lineage; + unrelated document resets leave it alone. Gated by + `src/__tests__/collab/awareness.test.tsx`. (2026-07-29) +- **Properties-panel textarea follows the projected value** — it is a + controlled React input with immediate `onChange`; a remote projection + rewrites any not-yet-dispatched native DOM value before the next local input + event, so the stale DOM snapshot cannot clobber the peer. Gated by + `src/__tests__/collab/propertiesPanelTextarea.test.tsx`. (2026-07-29) +- **Reconnect and reset cannot merge a dead CRDT lineage** — reconnect catches + up edits missed in either direction, while a stale generation receives a + RESET instead of being applied. Gated over real WebSockets by + `src/__tests__/server/collabRelayIntegration.test.ts`. (2026-07-29) +- **Partial-role relay writes use the same category policy as HTTP saves** — + direct guard tests cover content/style/structure separation for page and + site docs, roster membership, and the structure-only component/layout rule. + Gated by `src/__tests__/server/collabUpdateGuard.test.ts` plus the real-socket + refused-write/reset path. (2026-07-29) +- **Transient persistence failure cannot silently evict an accepted edit** — + a dirty zero-reference doc remains resident, retries automatically, and is + evicted only after persistence succeeds. Explicit publish/reset flushes fail + rather than continuing with stale derived JSON. Gated by + `src/__tests__/server/collabRelay.test.ts`. Normal shutdown, last-client + release, and publish all flush synchronously; a hard process/host crash can + still lose at most the default 800 ms debounce window (an explicit bounded + recovery-point tradeoff, not an unbounded dirty state). (2026-07-29) +- **Offline/backlogged transport blocks edits visibly once per episode** — + heartbeat timeout and buffered-byte pressure both close the write gate; the + first refused edit toasts and snaps inline editing back, repeats stay quiet, + and recovery re-arms the notice. Gated by + `src/__tests__/collab/provider.test.ts` and + `src/__tests__/collab/collabNotices.test.ts`. (2026-07-29) +- **Provider teardown is terminal** — destroy detaches socket callbacks before + close, clears timers/listeners, and removes every Y.Doc update handler; a + socket that finishes opening late cannot resurrect the heartbeat. Gated by + `src/__tests__/collab/provider.test.ts`. (2026-07-29) +- **PostgreSQL collaboration persistence parity** — on a disposable Postgres + 16 database, the migrations applied from scratch, relay persistence wrote + both `collab_documents` state and derived row JSON, and two real WebSocket + clients edited concurrently, converged, and persisted. The same focused + tests also pass on SQLite. (2026-07-29) ## Open findings (ranked) -1. **`applyTextDiff` has no drift guard** (`src/core/collab/applyPatches.ts` - ~line 290). `applyChildrenDiff` explicitly detects "actual array ≠ - pre-mutation array" and falls back to a wholesale rewrite; - the Y.Text path applies splice indices computed from the store's - `preValue` blindly. If `existing.toString() !== preValue` (unreachable - from browser input ordering as far as this run could prove, but MCP - bridge / plugin RPC / future callers may differ), the splice corrupts - text or throws Yjs "Length exceeded" mid-transaction. Add the analogous - guard (diff against `existing.toString()` on drift) + a test. -2. **Y.Text instance replacement mid-session freezes the merge** — a remote - whole-node rebuild (`yNodes.set(nodeId, buildNodeMap(...))`) swaps the - props map and its Y.Text; `attachInlineEditRemoteMerge` observes the dead - instance, so the surface silently degrades to frozen (and the next local - keystroke overwrites the peer's whole-node write). Re-resolve the Y.Text - per event (observeDeep on the node map) or force-end the session. -3. **Doc RESET mid-inline-session** — `connectCollabProvider`'s reset handler - rebinds a fresh doc; an active inline session keeps observing the dead - doc's Y.Text and its node may not exist in the reseed. Probably should - force-end the inline session when the active doc resets. -4. **Properties-panel textarea under co-editing** — same whole-string commit - path (`updateNodeProps`), but a controlled input re-rendered from the - store: an in-flight local value can clobber a just-projected remote edit - between renders. Unreviewed; needs the same adversarial pass the canvas - surface got. -5. **Priority areas untouched by any run yet** (from the loop brief, in - order): reconnect/reset overlap + stale-generation frames; crash - durability of the persist debounce; update-guard adversarial bypass - siblings; Postgres parity for `collab_documents` + continuous persistence; - offline-block stress (flaky transport, toast latch); relay/awareness/ - provider leak+lifetime audit. +None from the release-hardening brief. The bounded hard-crash recovery point +(at most the default 800 ms persistence debounce) is documented above. ## Done this run +### 2026-07-29 — release integration and text-surface hardening + +- Merged current `origin/main`; the only conflicts were the newer removal of + the publish success callout. The resolution keeps collab sync gating and + adopts the newer global error-toast behavior. +- Guarded patch-to-Y text translation against a stale projected pre-value. +- Reworked inline remote merge to follow replacement Y.Text instances and + invalidate when the edited text disappears. +- Closed active inline editing before a reset rebinds that document. +- Proved the controlled Properties-panel textarea adopts remote projection + before the next local input event. +- Hardened failed persistence: automatic retry, no dirty-doc eviction, and + explicit flush failure instead of stale publish/reset continuation. +- Added adversarial per-document capability-guard coverage. +- Verified real-socket reconnect, offline-authored edit recovery, reset overlap, + stale-generation refusal, and publish flush behavior. +- Verified migrations, continuous persistence, and two-client convergence on + a disposable PostgreSQL 16 instance as well as SQLite. +- Closed provider late-open/listener teardown and pinned the offline notice + latch across outage/recovery episodes. +- Production build and lint pass; the full suite passes 6,419/6,419 tests, + including the real-WebSocket collaboration integration and architecture + gates. + ### 2026-07-27 — co-typing one text node deleted the peer's characters - **Defect** (priority area 1, the headline promise): the inline-edit diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 111646c7e..4019d8b85 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -540,7 +540,11 @@ JSON to `data_rows`/site on a short debounce (~800 ms), 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 -inside the debounce window. +inside the debounce window. A transient persistence failure keeps the dirty +doc resident and retries; explicit publish/reset flushes fail instead of +continuing against stale derived JSON. Normal shutdown and final-client +release flush synchronously. A hard process/host crash can still lose the +bounded debounce window (at most ~800 ms with the default). **Wire** (`server/collab/socket.ts` + `@core/collab/protocol`): binary frames `docId | frameType | payload` multiplex every doc over ONE WebSocket @@ -597,9 +601,10 @@ each sparse sample every animation frame (exponential smoothing, snap on oversized jumps), so motion stays glassy at a fraction of the wire rate. Peer states are wire data — validated with TypeBox before rendering. -MCP note: headless MCP reads hit the DB, so a browser-relayed write is -visible to them after the relay's persist debounce (≤ ~1 s) — the old -client-side flush is gone. +MCP note: headless MCP reads hit the DB, so every headless read and +`site_publish` runs the server-side relay flush before touching persisted +rows. A browser-relayed write is therefore immediately ordered before the +following MCP read/publish without a client-side save step. ### Atomic diff validation diff --git a/server/collab/relay.ts b/server/collab/relay.ts index e0c445259..380a1e3c3 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -97,6 +97,7 @@ interface RelayEntry { } type DerivedWrite = 'written' | 'incomplete' | 'invalid' +type PersistOutcome = 'clean' | 'retry' | 'invalid' export type RelayUpdateListener = ( docId: string, @@ -273,9 +274,9 @@ export function createCollabRelay( return 'written' } - async function persistNow(docId: string): Promise { + async function persistNow(docId: string): Promise { const entry = entries.get(docId) - if (!entry || !entry.dirty) return + if (!entry || !entry.dirty) return 'clean' entry.dirty = false try { await putCollabDocumentState(db, docId, Y.encodeStateAsUpdate(entry.doc), entry.generation) @@ -283,10 +284,15 @@ export function createCollabRelay( // A shell that failed validation MUST be retried: the blob is fresh but // the derived JSON is stale, and a reset reseeds from that JSON. Leaving // the doc clean here is how an accepted page silently disappears. - if (derived === 'invalid') entry.dirty = true + if (derived === 'invalid') { + entry.dirty = true + return 'invalid' + } + return 'clean' } catch (err) { - entry.dirty = true // retry on the next schedule + entry.dirty = true console.error(`[collab] persist failed for ${docId}:`, err) + return 'retry' } } @@ -297,7 +303,19 @@ export function createCollabRelay( if (entry.persistTimer) return entry.persistTimer = setTimeout(() => { entry.persistTimer = null - entry.persistChain = entry.persistChain.then(() => persistNow(docId)) + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + void persist.then((outcome) => { + if (outcome === 'retry') { + schedulePersist(docId) + } else if (outcome === 'clean' && entry.refs <= 0) { + // A final persist may have failed after the last editor disconnected. + // Keep the doc resident until a retry succeeds, then finish eviction. + void evict(docId, { persist: false }).catch((err) => { + console.error(`[collab] eviction after retry failed for ${docId}:`, err) + }) + } + }) }, persistDebounceMs) } @@ -372,10 +390,19 @@ export function createCollabRelay( // Await the chain even when NOT persisting: a persist already past its // `dirty` check would otherwise resolve after `resetDocs` deleted the // row and re-insert the dead blob via upsert, undoing the reset. - entry.persistChain = entry.persistChain.then(() => - opts2.persist ? persistNow(docId) : undefined, + const persist = entry.persistChain.then(() => + opts2.persist ? persistNow(docId) : Promise.resolve('clean'), ) - await entry.persistChain + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error( + outcome === 'invalid' + ? `cannot evict ${docId}: collaborative state does not project to valid persisted JSON` + : `cannot evict ${docId}: collaborative state persistence failed`, + ) + } // An update may have landed during that await and scheduled a new timer. if (entry.persistTimer) { clearTimeout(entry.persistTimer) @@ -410,8 +437,13 @@ export function createCollabRelay( if (affected.includes(docId)) continue const entry = entries.get(docId) if (!entry?.dirty) continue - entry.persistChain = entry.persistChain.then(() => persistNow(docId)) - await entry.persistChain + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error(`cannot reset ${affected.join(', ')}: failed to flush ${docId}`) + } } const heldRefs = new Map() @@ -472,8 +504,17 @@ export function createCollabRelay( clearTimeout(entry.persistTimer) entry.persistTimer = null } - entry.persistChain = entry.persistChain.then(() => persistNow(docId)) - await entry.persistChain + const persist = entry.persistChain.then(() => persistNow(docId)) + entry.persistChain = persist.then(() => undefined) + const outcome = await persist + if (outcome !== 'clean') { + if (outcome === 'retry') schedulePersist(docId) + throw new Error( + outcome === 'invalid' + ? `cannot flush ${docId}: collaborative state does not project to valid persisted JSON` + : `cannot flush ${docId}: collaborative state persistence failed`, + ) + } } } diff --git a/src/__tests__/collab/applyPatches.test.ts b/src/__tests__/collab/applyPatches.test.ts index 64cd1d049..29cc682b4 100644 --- a/src/__tests__/collab/applyPatches.test.ts +++ b/src/__tests__/collab/applyPatches.test.ts @@ -62,6 +62,13 @@ function seededDocSet(site: SiteDocument): CollabDocSet { return docs } +function yTextOf(doc: Y.Doc, nodeId: string): Y.Text { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + const props = node.get('props') as Y.Map + return props.get('text') as Y.Text +} + function mutate( site: SiteDocument, recipe: (draft: SiteDocument) => void, @@ -115,6 +122,24 @@ describe('applySitePatchesToDocs — page tree edits', () => { expect(projectPageDoc(remote, 'p1').nodes.t1.props.text).toBe(merged) }) + it('falls back safely when the projected pre-value drifted from the live Y.Text', () => { + const site = fixtureSite() + const docs = seededDocSet(site) + const local = docs.ensure('page:p1') + // Simulate a caller holding a stale JSON snapshot after a remote update + // already landed in the authoritative doc. The stale splice indexes used + // to target the wrong characters or throw when the live text was shorter. + yTextOf(local, 't1').delete(0, 11) + yTextOf(local, 't1').insert(0, 'x') + + expect(() => { + translate(site, docs, (d) => { + updateNodeProps(d.pages[0], 't1', { text: 'hello brave world' }) + }) + }).not.toThrow() + expect(projectPageDoc(local, 'p1').nodes.t1.props.text).toBe('hello brave world') + }) + it('node move and delete project back identical children', () => { const site = fixtureSite() const docs = seededDocSet(site) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index 3d8c68ba9..d18b7d76d 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -18,15 +18,24 @@ import { connectCollabProvider, disconnectCollabProvider, } from '@site/store/slices/site/collabBinding' -import type { BoundCollabDoc, CollabProvider } from '@site/collab/collabProvider' +import type { + BoundCollabDoc, + CollabProvider, + CollabResetListener, +} from '@site/collab/collabProvider' +import type { ResetReason } from '@core/collab' import { PeerPresenceOverlay } from '@admin/pages/site/canvas/PeerPresenceOverlay' import '@modules/base/index' /** Minimal provider stub: real Awareness, synced-on-bind docs, no transport. */ -function fakeProvider(): CollabProvider & { awareness: awarenessProtocol.Awareness } { +function fakeProvider(): CollabProvider & { + awareness: awarenessProtocol.Awareness + triggerReset: (docId: string, reason?: ResetReason) => void +} { const presenceDoc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(presenceDoc) const bound = new Map() + const resetListeners = new Set() return { bind: (docId) => { let entry = bound.get(docId) @@ -42,8 +51,16 @@ function fakeProvider(): CollabProvider & { awareness: awarenessProtocol.Awarene }, awareness, status: () => 'connected', + canSend: () => true, + reconnectNow: () => {}, onStatus: () => () => {}, - onReset: () => () => {}, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + triggerReset: (docId, reason = 'rewritten') => { + for (const listener of resetListeners) listener(docId, reason) + }, destroy: () => { awareness.destroy() presenceDoc.destroy() @@ -96,6 +113,28 @@ describe('activeEditorDocId', () => { }) }) +describe('collab reset during inline editing', () => { + it('ends only the inline session owned by the reset document', () => { + const store = useEditorStore.getState() + store.createSite('Reset Site') + const pageId = useEditorStore.getState().activePageId! + const page = useEditorStore.getState().site!.pages[0] + const nodeId = useEditorStore + .getState() + .insertNode('base.text', { text: 'hello' }, page.rootNodeId) + useEditorStore.getState().startInlineEdit(nodeId, 'desktop') + expect(useEditorStore.getState().activeInlineEdit).not.toBeNull() + + const provider = fakeProvider() + connectCollabProvider(provider) + provider.triggerReset('page:another-page') + expect(useEditorStore.getState().activeInlineEdit).not.toBeNull() + + provider.triggerReset(`page:${pageId}`) + expect(useEditorStore.getState().activeInlineEdit).toBeNull() + }) +}) + describe('usePeerPresences', () => { it('returns peers on the same doc, drops other docs and malformed states', async () => { const provider = fakeProvider() diff --git a/src/__tests__/collab/collabNotices.test.ts b/src/__tests__/collab/collabNotices.test.ts new file mode 100644 index 000000000..155428bd1 --- /dev/null +++ b/src/__tests__/collab/collabNotices.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { + clearCollabBlockNotice, + collabBlockToast, + collabResetToast, + resetTargetsActiveDocument, +} from '@site/store/slices/site/collabNotices' +import { + __resetToastBusForTests, + subscribeToasts, + type Toast, +} from '@ui/components/Toast/toastBus' + +afterEach(() => { + clearCollabBlockNotice() + __resetToastBusForTests() +}) + +describe('collab notices', () => { + it('shows one blocked-edit toast per outage and re-arms after recovery', () => { + let toasts: ReadonlyArray = [] + const unsubscribe = subscribeToasts((next) => { + toasts = next + }) + + expect(collabBlockToast('offline')).toBe(true) + expect(collabBlockToast('offline')).toBe(false) + expect(collabBlockToast('connecting')).toBe(false) + expect(toasts).toHaveLength(1) + expect(toasts[0]).toMatchObject({ + kind: 'error', + title: 'Change not applied', + }) + + clearCollabBlockNotice() + expect(collabBlockToast('syncing')).toBe(true) + expect(toasts).toHaveLength(2) + unsubscribe() + }) + + it('keeps routine reseeds silent but reports discarded local work', () => { + let toasts: ReadonlyArray = [] + const unsubscribe = subscribeToasts((next) => { + toasts = next + }) + + collabResetToast('rewritten') + expect(toasts).toHaveLength(0) + collabResetToast('stale') + collabResetToast('refused') + collabResetToast('oversize') + + expect(toasts.map((toast) => toast.title)).toEqual([ + 'A change was reverted', + 'A change was reverted', + 'A change was reverted', + ]) + unsubscribe() + }) + + it('matches resets only to the active page or visual component', () => { + expect( + resetTargetsActiveDocument('page:page-1', { kind: 'page', pageId: 'page-1' }, null), + ).toBe(true) + expect( + resetTargetsActiveDocument('page:page-1', { kind: 'visualComponent', vcId: 'vc-1' }, 'page-1'), + ).toBe(true) + expect( + resetTargetsActiveDocument( + 'component:vc-1', + { kind: 'visualComponent', vcId: 'vc-1' }, + 'page-1', + ), + ).toBe(true) + expect( + resetTargetsActiveDocument('component:vc-2', { kind: 'page', pageId: 'page-1' }, 'page-1'), + ).toBe(false) + }) +}) diff --git a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx index 39e70db91..5c2ae07fd 100644 --- a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -264,6 +264,60 @@ describe('attachInlineEditRemoteMerge (surface-level)', () => { detach() }) + it('keeps merging after a remote whole-node write replaces the Y.Text instance', () => { + const { doc, el, nodeId, detach } = seededSurface('hello') + const nodes = treeMap(doc).get('nodes') as Y.Map + const replacement = new Y.Map() + const props = new Y.Map() + props.set('text', new Y.Text('replaced')) + props.set('tag', 'p') + replacement.set('id', nodeId) + replacement.set('moduleId', 'base.text') + replacement.set('props', props) + replacement.set('breakpointOverrides', new Y.Map()) + replacement.set('children', new Y.Array()) + + doc.transact(() => { + nodes.set(nodeId, replacement) + }, 'remote-whole-node') + expect(el.textContent).toBe('replaced') + + yTextOf(doc, nodeId).insert(8, ' again') + expect(el.textContent).toBe('replaced again') + detach() + }) + + it('invalidates the edit session when a remote write removes its Y.Text', () => { + let invalidations = 0 + const doc = new Y.Doc() + seedPageDoc( + doc, + makePage({ + id: 'p1', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body', children: ['t1'] }), + t1: makeNode({ id: 't1', moduleId: 'base.text', props: { text: 'hello', tag: 'p' } }), + }, + }), + ) + const el = document.createElement('p') + seedInlineEditableContent(el, 'hello') + const detach = attachInlineEditRemoteMerge({ + el, + doc, + nodeId: 't1', + prop: 'text', + onInvalidated: () => { invalidations++ }, + }) + + doc.transact(() => { + ;(treeMap(doc).get('nodes') as Y.Map).delete('t1') + }, 'remote-delete') + expect(invalidations).toBe(1) + detach() + }) + it('stops merging after detach', () => { const { doc, el, nodeId, detach } = seededSurface('hello') detach() diff --git a/src/__tests__/collab/propertiesPanelTextarea.test.tsx b/src/__tests__/collab/propertiesPanelTextarea.test.tsx new file mode 100644 index 000000000..dd3a05c62 --- /dev/null +++ b/src/__tests__/collab/propertiesPanelTextarea.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TextareaControl } from '@site/property-controls/TextareaControl' + +afterEach(cleanup) + +describe('properties-panel textarea under co-editing', () => { + it('adopts a remote controlled value before the next local input event', () => { + const commits: string[] = [] + const onChange = (_key: string, value: string) => { + commits.push(value) + } + const view = render( + , + ) + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + + // A native edit has changed the DOM but its input event has not run yet. + // Remote projection then re-renders the controlled property value. + textarea.value = 'hello stale local' + view.rerender( + , + ) + expect(textarea.value).toBe('hello remote') + + // The next local event starts from the projected remote value rather than + // committing the stale DOM snapshot over it. + fireEvent.change(textarea, { target: { value: 'hello remote!' } }) + expect(commits).toEqual(['hello remote!']) + }) +}) diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index 8d88ad447..4a660424e 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -196,4 +196,32 @@ describe('collab provider', () => { expect(created).toBe(2) provider.destroy() }) + + it('detaches a late-opening socket and every document listener on destroy', async () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ + createSocket: () => socket, + pingIntervalMs: 10, + }) + const binding = provider.bind('page:p1') + const statuses: string[] = [] + const resets: string[] = [] + provider.onStatus((status) => statuses.push(status)) + provider.onReset((docId) => resets.push(docId)) + const sentBeforeDestroy = socket.sent.length + + provider.destroy() + binding.doc.getMap('meta').set('title', 'After destroy') + socket.open() + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_RESET, new Uint8Array())) + await Bun.sleep(30) + + expect(socket.onopen).toBeNull() + expect(socket.onmessage).toBeNull() + expect(socket.onclose).toBeNull() + expect(socket.onerror).toBeNull() + expect(socket.sent).toHaveLength(sentBeforeDestroy) + expect(statuses).toEqual([]) + expect(resets).toEqual([]) + }) }) diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts index 033a7e7dc..6b9f7d08e 100644 --- a/src/__tests__/server/collabRelay.test.ts +++ b/src/__tests__/server/collabRelay.test.ts @@ -4,7 +4,7 @@ * Runs on a real migrated database via the capability harness (setup seeds * the home page row exactly like a live install). */ -import { afterEach, describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it, spyOn } from 'bun:test' import * as Y from 'yjs' import { LOCAL_ORIGIN, @@ -79,6 +79,37 @@ function gateCollabBlobWrites(db: DbClient): { return { db: wrapped, arm: () => { armed = true }, blocked, release } } +function failNextCollabBlobWrite(db: DbClient): { + db: DbClient + arm: () => void + failed: Promise +} { + let announceFailed!: () => void + const failed = new Promise((resolve) => { + announceFailed = resolve + }) + let armed = false + let hasFailed = false + + const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + if ( + armed && + !hasFailed && + strings.join('?').includes('insert into collab_documents') + ) { + hasFailed = true + announceFailed() + throw new Error('simulated transient collab persistence failure') + } + return db(strings, ...values) + }) as DbClient + Object.defineProperty(wrapped, 'dialect', { get: () => db.dialect }) + wrapped.unsafe = ((sql: string, params?: unknown[]) => db.unsafe(sql, params)) as DbClient['unsafe'] + wrapped.transaction = ((fn: Parameters[0]) => + db.transaction(fn)) as DbClient['transaction'] + return { db: wrapped, arm: () => { armed = true }, failed } +} + function editTitleUpdate(doc: Y.Doc, nodeText: string): void { doc.transact(() => { const nodes = treeMap(doc).get('nodes') as Y.Map @@ -121,6 +152,67 @@ describe('collab relay', () => { expect(body.nodes[body.rootNodeId].label).toBe('Hero section') }) + it('keeps a dirty doc resident and retries when the final persist fails', async () => { + const errorLog = spyOn(console, 'error').mockImplementation(() => {}) + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const failing = failNextCollabBlobWrite(harness.db) + const relay = createCollabRelay(failing.db, { persistDebounceMs: 5 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const homeId = rows[0].id + const docId = `page:${homeId}` + const { doc } = await relay.retain(docId) + + failing.arm() + editTitleUpdate(doc, 'Survives a transient failure') + relay.release(docId) + await failing.failed + + await new Promise((resolve) => setTimeout(resolve, 30)) + const persisted = await getCollabDocumentState(harness.db, docId) + expect(persisted?.state).toBeDefined() + const { rows: pageRows } = await harness.db<{ cells_json: Record }>` + select cells_json from data_rows where id = ${homeId} + ` + const body = pageRows[0].cells_json.body as { + nodes: Record + rootNodeId: string + } + expect(body.nodes[body.rootNodeId].label).toBe('Survives a transient failure') + expect(errorLog).toHaveBeenCalled() + errorLog.mockRestore() + }) + + it('makes an explicit flush fail instead of publishing stale derived JSON', async () => { + const errorLog = spyOn(console, 'error').mockImplementation(() => {}) + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + await harness.setupOwner() + const failing = failNextCollabBlobWrite(harness.db) + const relay = createCollabRelay(failing.db, { persistDebounceMs: 1_000 }) + cleanups.push(() => relay.destroy()) + const { rows } = await harness.db<{ id: string }>` + select id from data_rows where table_id = ${'pages'} + ` + const docId = `page:${rows[0].id}` + const { doc } = await relay.openDoc(docId) + + failing.arm() + editTitleUpdate(doc, 'Must not publish yet') + await expect(relay.flushAll()).rejects.toThrow('collaborative state persistence failed') + await failing.failed + + // The failed flush schedules a retry; a later explicit flush can safely + // wait for it and succeeds once the transient database error is gone. + await relay.flushAll() + expect(errorLog).toHaveBeenCalled() + errorLog.mockRestore() + }) + it('roster removal soft-deletes the row on site-doc persist', async () => { const { harness, relay, homeId } = await setup() const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) @@ -263,4 +355,4 @@ describe('collab relay', () => { const pages = rostersMap(reseeded).get('pages') as Y.Map expect([...pages.keys()]).toContain(rowId) }) -}) \ No newline at end of file +}) diff --git a/src/__tests__/server/collabUpdateGuard.test.ts b/src/__tests__/server/collabUpdateGuard.test.ts new file mode 100644 index 000000000..8c35ae885 --- /dev/null +++ b/src/__tests__/server/collabUpdateGuard.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { + dataMap, + metaMap, + rostersMap, + seedComponentDoc, + seedLayoutDoc, + seedPageDoc, + seedSiteDoc, + shellMap, + SITE_DOC_ID, + treeMap, +} from '@core/collab' +import type { CoreCapability } from '@core/capabilities' +import type { SavedLayout } from '@core/layouts' +import { validateGuardedUpdate } from '../../../server/collab/updateGuard' +import { makeNode, makePage, makeSite, makeVC } from '../fixtures' + +const CONTENT: CoreCapability[] = ['site.content.edit'] +const STYLE: CoreCapability[] = ['site.style.edit'] +const STRUCTURE: CoreCapability[] = ['site.structure.edit'] + +function updateFrom(doc: Y.Doc, mutate: (fork: Y.Doc) => void): Uint8Array { + const fork = new Y.Doc() + Y.applyUpdate(fork, Y.encodeStateAsUpdate(doc)) + const stateVector = Y.encodeStateVector(doc) + fork.transact(() => mutate(fork)) + return Y.encodeStateAsUpdate(fork, stateVector) +} + +function nodeMap(doc: Y.Doc, nodeId: string): Y.Map { + const nodes = treeMap(doc).get('nodes') as Y.Map + return nodes.get(nodeId) as Y.Map +} + +function expectAllowed( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: CoreCapability[], +): void { + expect(validateGuardedUpdate(docId, doc, update, capabilities)).toEqual({ ok: true }) +} + +function expectForbidden( + docId: string, + doc: Y.Doc, + update: Uint8Array, + capabilities: CoreCapability[], + reason: string, +): void { + expect(validateGuardedUpdate(docId, doc, update, capabilities)).toMatchObject({ + ok: false, + reason: expect.stringContaining(reason), + }) +} + +describe('collab update capability guard', () => { + it('separates page content, style, and structure updates', () => { + const page = makePage({ + id: 'page-1', + nodes: { + root: makeNode({ + id: 'root', + moduleId: 'base.body', + children: ['copy'], + }), + copy: makeNode({ + id: 'copy', + moduleId: 'base.text', + props: { text: 'Original' }, + }), + }, + }) + const doc = new Y.Doc() + seedPageDoc(doc, page) + const docId = 'page:page-1' + + const contentUpdate = updateFrom(doc, (fork) => { + const props = nodeMap(fork, 'copy').get('props') as Y.Map + const text = props.get('text') as Y.Text + text.insert(text.length, ' copy') + }) + expectAllowed(docId, doc, contentUpdate, CONTENT) + expectForbidden(docId, doc, contentUpdate, STYLE, 'forbidden content change') + + const styleUpdate = updateFrom(doc, (fork) => { + nodeMap(fork, 'copy').set('classIds', ['hero-copy']) + }) + expectAllowed(docId, doc, styleUpdate, STYLE) + expectForbidden(docId, doc, styleUpdate, CONTENT, 'forbidden style change') + + const structureUpdate = updateFrom(doc, (fork) => { + nodeMap(fork, 'copy').set('label', 'Hero copy') + }) + expectAllowed(docId, doc, structureUpdate, STRUCTURE) + expectForbidden(docId, doc, structureUpdate, CONTENT, 'forbidden structure change') + }) + + it('separates site-shell content, style, structure, and roster updates', () => { + const site = makeSite() + const doc = new Y.Doc() + seedSiteDoc(doc, site) + + const contentUpdate = updateFrom(doc, (fork) => { + const settings = shellMap(fork).get('settings') as Y.Map + settings.set('metaTitle', 'Collaborative title') + }) + expectAllowed(SITE_DOC_ID, doc, contentUpdate, CONTENT) + expectForbidden(SITE_DOC_ID, doc, contentUpdate, STYLE, 'forbidden content change') + + const styleUpdate = updateFrom(doc, (fork) => { + const styleRules = shellMap(fork).get('styleRules') as Y.Map + styleRules.set('hero-copy', { + id: 'hero-copy', + name: 'Hero copy', + styles: { color: 'var(--foreground)' }, + }) + }) + expectAllowed(SITE_DOC_ID, doc, styleUpdate, STYLE) + expectForbidden(SITE_DOC_ID, doc, styleUpdate, CONTENT, 'forbidden style change') + + const structureUpdate = updateFrom(doc, (fork) => { + shellMap(fork).set('name', 'Renamed site') + }) + expectAllowed(SITE_DOC_ID, doc, structureUpdate, STRUCTURE) + expectForbidden(SITE_DOC_ID, doc, structureUpdate, CONTENT, 'forbidden structure change') + + const rosterUpdate = updateFrom(doc, (fork) => { + const pages = rostersMap(fork).get('pages') as Y.Map + pages.set('page-2', true) + }) + expectAllowed(SITE_DOC_ID, doc, rosterUpdate, STRUCTURE) + expectForbidden(SITE_DOC_ID, doc, rosterUpdate, CONTENT, 'roster changes') + }) + + it('requires structure capability for component and layout documents', () => { + const componentDoc = new Y.Doc() + seedComponentDoc(componentDoc, makeVC({ id: 'component-1', name: 'Hero' })) + const componentUpdate = updateFrom(componentDoc, (fork) => { + metaMap(fork).set('name', 'Renamed hero') + }) + expectAllowed('component:component-1', componentDoc, componentUpdate, STRUCTURE) + expectForbidden( + 'component:component-1', + componentDoc, + componentUpdate, + CONTENT, + 'component changes require', + ) + + const layout: SavedLayout = { + id: 'layout-1', + name: 'Hero layout', + rootNodeId: 'root', + nodes: { + root: makeNode({ id: 'root', moduleId: 'base.body' }), + }, + classes: {}, + createdAt: 1_700_000_000_000, + } + const layoutDoc = new Y.Doc() + seedLayoutDoc(layoutDoc, layout) + const layoutUpdate = updateFrom(layoutDoc, (fork) => { + dataMap(fork).set('snapshot', { + ...dataMap(fork).get('snapshot') as Record, + classes: { 'hero-layout': { id: 'hero-layout', name: 'Hero layout', styles: {} } }, + }) + }) + expectAllowed('layout:layout-1', layoutDoc, layoutUpdate, STRUCTURE) + expectForbidden( + 'layout:layout-1', + layoutDoc, + layoutUpdate, + STYLE, + 'layout changes require', + ) + }) + + it('rejects updates for unknown document ids', () => { + const doc = new Y.Doc() + const update = updateFrom(doc, (fork) => { + fork.getMap('unknown').set('value', true) + }) + expect(validateGuardedUpdate('unknown:doc', doc, update, STRUCTURE)).toEqual({ + ok: false, + reason: 'unknown doc id unknown:doc', + }) + }) +}) diff --git a/src/admin/pages/site/canvas/NodeRenderer.tsx b/src/admin/pages/site/canvas/NodeRenderer.tsx index d8104f6da..2a373d49f 100644 --- a/src/admin/pages/site/canvas/NodeRenderer.tsx +++ b/src/admin/pages/site/canvas/NodeRenderer.tsx @@ -213,6 +213,7 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP doc: collabDoc, nodeId, prop: session.prop, + onInvalidated: () => useEditorStore.getState().endInlineEdit(), }) }, [isInlineEditing, inlineEditInitialValue, nodeId]) diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index 8aa8cfb74..8dbe4b595 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -388,8 +388,20 @@ export function createCollabProvider( awarenessProtocol.removeAwarenessStates(awareness, [awareness.clientID], 'destroy') awareness.destroy() for (const docId of [...bound.keys()]) unbind(docId) - socket?.close() + const closingSocket = socket socket = null + if (closingSocket) { + // A socket can finish opening after the editor has already unmounted. + // Detach every callback before close so that late browser events cannot + // resurrect the heartbeat or notify listeners on a destroyed provider. + closingSocket.onopen = null + closingSocket.onmessage = null + closingSocket.onclose = null + closingSocket.onerror = null + closingSocket.close() + } + statusListeners.clear() + resetListeners.clear() }, } } diff --git a/src/admin/pages/site/collab/inlineEditRemoteMerge.ts b/src/admin/pages/site/collab/inlineEditRemoteMerge.ts index 7a1266b39..3a7010374 100644 --- a/src/admin/pages/site/collab/inlineEditRemoteMerge.ts +++ b/src/admin/pages/site/collab/inlineEditRemoteMerge.ts @@ -20,7 +20,7 @@ * through a synthesized single-splice delta instead. */ import * as Y from 'yjs' -import { LOCAL_ORIGIN, SEED_ORIGIN, nodeTextOf } from '@core/collab' +import { LOCAL_ORIGIN, SEED_ORIGIN, nodeTextOf, treeMap } from '@core/collab' import { readInlineEditableText, seedInlineEditableContent, @@ -169,16 +169,16 @@ export function attachInlineEditRemoteMerge(options: { doc: Y.Doc nodeId: string prop: string + onInvalidated?: () => void }): () => void { - const { el, doc, nodeId, prop } = options - const text = nodeTextOf(doc, nodeId, prop) - if (!text) return () => {} + const { el, doc, nodeId, prop, onInvalidated } = options + if (!nodeTextOf(doc, nodeId, prop)) return () => {} let composing = false let deferredWhileComposing = false + let invalidated = false - const mergeIntoSurface = (delta: readonly TextDeltaOp[]): void => { - const nextValue = text.toString() + const mergeIntoSurface = (nextValue: string, delta: readonly TextDeltaOp[]): void => { if (readInlineEditableText(el) === nextValue) return const view = el.ownerDocument.defaultView @@ -214,15 +214,38 @@ export function attachInlineEditRemoteMerge(options: { } } - const observer = (event: Y.YTextEvent, transaction: Y.Transaction): void => { + const observer = ( + events: readonly Y.YEvent>[], + transaction: Y.Transaction, + ): void => { // Local edits are already in the DOM (the DOM is where they came from); // seeds mirror content the session was opened on. if (transaction.origin === LOCAL_ORIGIN || transaction.origin === SEED_ORIGIN) return + const text = nodeTextOf(doc, nodeId, prop) + if (!text) { + if (!invalidated) { + invalidated = true + onInvalidated?.() + } + return + } + invalidated = false if (composing) { deferredWhileComposing = true return } - mergeIntoSurface(event.delta as readonly TextDeltaOp[]) + const matchingEvent = events.find( + (event) => event instanceof Y.YTextEvent && event.target === text, + ) + const textEvent = matchingEvent instanceof Y.YTextEvent ? matchingEvent : null + const currentSurface = readInlineEditableText(el) + const nextValue = text.toString() + mergeIntoSurface( + nextValue, + textEvent + ? textEvent.delta as readonly TextDeltaOp[] + : spliceDelta(currentSurface, nextValue), + ) } const onCompositionStart = (): void => { @@ -232,16 +255,29 @@ export function attachInlineEditRemoteMerge(options: { composing = false if (!deferredWhileComposing) return deferredWhileComposing = false + const text = nodeTextOf(doc, nodeId, prop) + if (!text) { + if (!invalidated) { + invalidated = true + onInvalidated?.() + } + return + } // No single Yjs delta covers the deferred window — synthesize the splice // between what the surface shows and what the doc now holds. - mergeIntoSurface(spliceDelta(readInlineEditableText(el), text.toString())) + const nextValue = text.toString() + mergeIntoSurface(nextValue, spliceDelta(readInlineEditableText(el), nextValue)) } - text.observe(observer) + const tree = treeMap(doc) + // Observe the tree rather than one Y.Text instance. A whole-node remote + // write replaces the node map and its nested Y.Text; observing only the old + // instance silently froze the editing surface after that replacement. + tree.observeDeep(observer) el.addEventListener('compositionstart', onCompositionStart) el.addEventListener('compositionend', onCompositionEnd) return () => { - text.unobserve(observer) + tree.unobserveDeep(observer) el.removeEventListener('compositionstart', onCompositionStart) el.removeEventListener('compositionend', onCompositionEnd) } diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 8e029643c..2f34d6473 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -68,11 +68,11 @@ import { collabBlockToast, clearCollabBlockNotice, collabResetToast, + resetTargetsActiveDocument, transportBlockReason, type BlockedReason, type LocalPatchOutcome, } from './collabNotices' - interface ManagedDoc { doc: Y.Doc manager: Y.UndoManager @@ -658,7 +658,14 @@ export function connectCollabProvider(next: CollabProvider): void { detachProviderReset?.() detachProviderReset = next.onReset((docId, reason) => { collabResetToast(reason) - // The doc's undo history belongs to the lineage that just died: its + const current = storeApi?.getState() + if ( + current?.activeInlineEdit && + resetTargetsActiveDocument(docId, current.activeDocument, current.activePageId) + ) { + current.endInlineEdit() + } + // The undo history belongs to the lineage that just died: its // UndoManager is rebuilt empty below, so any routing entry still naming // this doc would make Cmd+Z a silent no-op that consumes a step. undoRoute = undoRoute.map((group) => group.filter((id) => id !== docId)).filter((g) => g.length > 0) diff --git a/src/admin/pages/site/store/slices/site/collabNotices.ts b/src/admin/pages/site/store/slices/site/collabNotices.ts index c621b77de..0975e2967 100644 --- a/src/admin/pages/site/store/slices/site/collabNotices.ts +++ b/src/admin/pages/site/store/slices/site/collabNotices.ts @@ -12,7 +12,7 @@ * must be told; the routine out-of-relay reseed (`rewritten`) is silent. */ import { pushToast } from '@ui/components/Toast' -import type { ResetReason } from '@core/collab' +import { parseCollabDocId, type ResetReason } from '@core/collab' import type { CollabProvider } from '@site/collab/collabProvider' /** @@ -87,3 +87,28 @@ export function collabResetToast(reason: ResetReason): void { if (reason === 'rewritten') return pushToast({ kind: 'error', title: 'A change was reverted', body: RESET_REASON_COPY[reason] }) } + +type ActiveEditorDocument = + | { kind: 'page'; pageId: string } + | { kind: 'visualComponent'; vcId: string } + | null + | undefined + +/** Whether a reset invalidated the Y.Text owned by the active inline editor. */ +export function resetTargetsActiveDocument( + docId: string, + activeDocument: ActiveEditorDocument, + fallbackActivePageId: string | null | undefined, +): boolean { + const parsed = parseCollabDocId(docId) + const activePageId = + activeDocument?.kind === 'page' ? activeDocument.pageId : fallbackActivePageId + return ( + (parsed?.kind === 'page' && activePageId === parsed.rowId) || + ( + parsed?.kind === 'component' && + activeDocument?.kind === 'visualComponent' && + activeDocument.vcId === parsed.rowId + ) + ) +} diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts index 2460b27e7..ed5af6f2b 100644 --- a/src/core/collab/applyPatches.ts +++ b/src/core/collab/applyPatches.ts @@ -289,7 +289,19 @@ function applyRowTargets( const existing = props.get(key) if (existing instanceof Y.Text) { const preValue = preTree?.nodes[nodeId]?.props[key] - applyTextDiff(existing, typeof preValue === 'string' ? preValue : existing.toString(), nextValue) + const expectedValue = typeof preValue === 'string' ? preValue : existing.toString() + const actualValue = existing.toString() + // Browser input ordering normally keeps the projected pre-value + // aligned with the Y.Text. Other callers (MCP, plugin RPC, future + // async surfaces) may hold a stale JSON snapshot, though. Applying + // stale splice indexes to the live text can throw "Length exceeded" + // or edit the wrong characters, so fall back to a safe rewrite diff + // against the actual document value when drift is detected. + applyTextDiff( + existing, + actualValue === expectedValue ? expectedValue : actualValue, + nextValue, + ) } else { props.set(key, new Y.Text(nextValue)) } From bd35ed162ddedd44006c1395a9b83bf88ae45bc4 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 29 Jul 2026 13:31:34 +0200 Subject: [PATCH 49/49] fix(collab): report persistence failures through toasts --- .../siteEditorDataDeepLink.test.tsx | 64 ++++++++++++++ src/admin/pages/site/hooks/usePersistence.ts | 87 ++++++++++++------- 2 files changed, 122 insertions(+), 29 deletions(-) diff --git a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx index 16adb1add..637bb5cd6 100644 --- a/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx +++ b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx @@ -9,6 +9,10 @@ import type { SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { IPersistenceAdapter } from '@core/persistence/types' import { buildCoreFrameworkSettings } from '@core/framework' +import { + subscribeToasts, + type Toast, +} from '@ui/components/Toast/toastBus' afterEach(cleanup) @@ -198,3 +202,63 @@ describe('Site editor Data workspace deep links', () => { }) }) }) + +describe('Site editor persistence failures', () => { + it('reports a failed site load through the global toast bus', async () => { + const toasts: Toast[] = [] + const unsubscribe = subscribeToasts((snapshot) => { + toasts.splice(0, toasts.length, ...snapshot) + }) + const adapter: IPersistenceAdapter = { + async loadSite() { + throw new Error('Database unavailable') + }, + async saveSite() { + return { seq: 1 } + }, + } + + const hook = renderHook(() => usePersistence('default', adapter, { enabled: true })) + + await waitFor(() => { + expect(hook.result.current.saveStatus).toEqual({ + state: 'error', + message: 'Database unavailable', + }) + expect(toasts).toContainEqual(expect.objectContaining({ + kind: 'error', + title: 'Site load failed', + body: 'Database unavailable', + location: 'site-editor:persistence', + })) + }) + unsubscribe() + }) + + it('reports a failed initial draft creation through the global toast bus', async () => { + const toasts: Toast[] = [] + const unsubscribe = subscribeToasts((snapshot) => { + toasts.splice(0, toasts.length, ...snapshot) + }) + const adapter: IPersistenceAdapter = { + async loadSite() { + return null + }, + async saveSite() { + throw new Error('Storage is read-only') + }, + } + + renderHook(() => usePersistence('default', adapter, { enabled: true })) + + await waitFor(() => { + expect(toasts).toContainEqual(expect.objectContaining({ + kind: 'error', + title: 'Draft creation failed', + body: 'Storage is read-only', + location: 'site-editor:persistence', + })) + }) + unsubscribe() + }) +}) diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index feee10c88..a7548d717 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -29,6 +29,7 @@ import type { IPersistenceAdapter } from '@core/persistence/types' import { cmsAdapter } from '@core/persistence/cms' import { SiteValidationError } from '@core/persistence/validate' import { getErrorMessage } from '@core/utils/errorMessage' +import { pushToast } from '@ui/components/Toast' import { readEditorSelectPreference } from '@site/preferences/editorPreferences' import type { CollabProvider } from '@site/collab/collabProvider' import { @@ -138,12 +139,19 @@ export function usePersistence( } } catch (err) { if (err instanceof SiteValidationError) { - console.warn('[persistence] Corrupt CMS site data:', err.message) + console.error('[persistence] Corrupt CMS site data:', err) } else { - console.warn('[persistence] Failed to load CMS site:', err) + console.error('[persistence] Failed to load CMS site:', err) } if (!cancelled) { - setLoadState({ phase: 'error', message: getErrorMessage(err, 'Failed to load CMS site') }) + const message = getErrorMessage(err, 'Failed to load CMS site') + setLoadState({ phase: 'error', message }) + pushToast({ + kind: 'error', + title: 'Site load failed', + body: message, + location: 'site-editor:persistence', + }) } return } @@ -162,7 +170,15 @@ export function usePersistence( if (!cancelled) setLoadState({ phase: 'ready' }) } catch (err) { if (!cancelled) { - setLoadState({ phase: 'error', message: getErrorMessage(err, 'Draft not saved yet') }) + const message = getErrorMessage(err, 'Draft not saved yet') + console.error('[persistence] Failed to create CMS draft:', err) + setLoadState({ phase: 'error', message }) + pushToast({ + kind: 'error', + title: 'Draft creation failed', + body: message, + location: 'site-editor:persistence', + }) } } } @@ -171,31 +187,44 @@ export function usePersistence( let offStatus: (() => void) | null = null async function boot(): Promise { - // Fetch the provider module (yjs transport) in parallel with the HTTP - // load — a dynamic import keeps it out of the route-shell chunk. - const providerModule = import('@site/collab/collabProvider') - await load() - if (cancelled) return - // Only connect when the store actually holds a site to bind. A hard load - // failure with NO in-memory site would otherwise open a socket with zero - // docs and an infinite reconnect loop; skip it and let saveStatus show - // the error. When a stale in-memory site survived a transient reload - // failure, we DO connect — live sync recovers against those docs and the - // connected state then supersedes the stale load error in saveStatus. - if (useEditorStore.getState().site === null) return - const { createCollabProvider } = await providerModule - if (cancelled) return - provider = createCollabProvider() - offStatus = provider.onStatus((status) => { - setCollabState(status === 'connected' ? 'connected' : status) - }) - setCollabState(provider.status() === 'connected' ? 'connected' : provider.status()) - // Connect AFTER the HTTP load hydrated the store: connectCollabProvider - // rebinds every doc for the loaded site through the provider - // (server-seeded), and the HTTP-loaded projection keeps the canvas - // painted while the initial sync streams in. - connected = true - connectCollabProvider(provider) + try { + // Fetch the provider module (yjs transport) in parallel with the HTTP + // load — a dynamic import keeps it out of the route-shell chunk. + const providerModule = import('@site/collab/collabProvider') + await load() + if (cancelled) return + // Only connect when the store actually holds a site to bind. A hard load + // failure with NO in-memory site would otherwise open a socket with zero + // docs and an infinite reconnect loop; skip it and let saveStatus show + // the error. When a stale in-memory site survived a transient reload + // failure, we DO connect — live sync recovers against those docs and the + // connected state then supersedes the stale load error in saveStatus. + if (useEditorStore.getState().site === null) return + const { createCollabProvider } = await providerModule + if (cancelled) return + provider = createCollabProvider() + offStatus = provider.onStatus((status) => { + setCollabState(status === 'connected' ? 'connected' : status) + }) + setCollabState(provider.status() === 'connected' ? 'connected' : provider.status()) + // Connect AFTER the HTTP load hydrated the store: connectCollabProvider + // rebinds every doc for the loaded site through the provider + // (server-seeded), and the HTTP-loaded projection keeps the canvas + // painted while the initial sync streams in. + connected = true + connectCollabProvider(provider) + } catch (err) { + if (cancelled) return + const message = getErrorMessage(err, 'Failed to start collaborative editing') + console.error('[persistence] Failed to start collaborative editing:', err) + setLoadState({ phase: 'error', message }) + pushToast({ + kind: 'error', + title: 'Collaboration startup failed', + body: message, + location: 'site-editor:persistence', + }) + } } void boot()