diff --git a/CLAUDE.md b/CLAUDE.md index 61d190068..cf58f4eb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,10 +38,11 @@ Read [`docs/architecture.md`](docs/architecture.md) for the system overview, [`d - **Runtime:** Bun (server + tooling). Use Bun, not Node. - **Language:** TypeScript everywhere. -- **Frontend:** React 19 with the **React Compiler enabled** (Babel preset in `vite.config.ts`) + Vite, Zustand + Mutative for state (via `zustand-mutative`; patch-based undo history uses Mutative `create({ enablePatches })` — `immer` is banned), CodeMirror for code-editing UI, `@dnd-kit/core` for drag-and-drop. The compiler auto-memoizes — do not hand-write `useMemo`/`useCallback`/`memo`. See "React Compiler and memoization". Store mutations use draft-mutation style (`set((s) => { s.x = … })`); a recipe that returns a partial must wrap it in `rawReturn(...)` or Mutative emits a perf warning. +- **Frontend:** React 19 with the **React Compiler enabled** (Babel preset in `vite.config.ts`) + Vite, Zustand + Mutative for state (via `zustand-mutative`; `immer` is banned), CodeMirror for code-editing UI, `@dnd-kit/core` for drag-and-drop. The compiler auto-memoizes — do not hand-write `useMemo`/`useCallback`/`memo`. See "React Compiler and memoization". Store mutations use draft-mutation style (`set((s) => { s.x = … })`); a recipe that returns a partial must wrap it in `rawReturn(...)` or Mutative emits a perf warning. - **Server:** `Bun.serve` with a hand-written router (`server/router.ts`). CMS modules at `server/{repositories,handlers/cms,auth,plugins,publish}/`. Deep dive: [`docs/server.md`](docs/server.md). - **Database:** Postgres (`Bun.sql`) OR SQLite (`bun:sqlite`), selected by `DATABASE_URL`. One `DbClient` interface, two adapters, two migration files with identical IDs. Rules: [`docs/reference/database-dialects.md`](docs/reference/database-dialects.md). - **Content model:** All content lives in `data_tables` + `data_rows`. The four system tables (`posts`, `pages`, `components`, `layouts`) are seeded and locked from rename/delete. There are no separate `pages` or `page_versions` tables. +- **Real-time co-editing:** Yjs CRDT engine. One Y doc per row (`page:`, `component:`, `layout:`) + one site-shell doc, multiplexed over `/admin/api/cms/site-socket`. The editor store stays the render source of truth: local mutations apply directly AND translate to Y ops (`@core/collab`); remote/undo changes project back. The server relay (`server/collab/`) persists continuously (blob + derived row JSON) — there is NO client-side save pipeline, no autosave, no Cmd+S. Undo is per-editor per-doc `Y.UndoManager`. Feature doc: [`docs/features/site-shell.md`](docs/features/site-shell.md) → "Real-time co-editing". - **Validation:** TypeBox at every untyped boundary. Schemas are source of truth (`type Foo = Static`, never a parallel `interface`). `zod` is banned repo-wide (the AI drivers pass TypeBox schemas through as JSON Schema, so no typebox→zod adapter is needed). Helpers + patterns: [`docs/reference/typebox-patterns.md`](docs/reference/typebox-patterns.md). - **Sanitization:** DOMPurify at the publisher boundary (`src/core/sanitize.ts`). - **Plugins:** Zip packages with a `plugin.json` manifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a **QuickJS-WASM sandbox** — no Node/Bun ambient access, network gated by `network.outbound` permission + `networkAllowedHosts`. The VM bootstrap (SDK factory + `__run*` dispatchers) is authored as typed TS in `server/plugins/quickjs/bootstrap/src/` and bundled to committed string artifacts in `bootstrap/generated/` — after editing the source run `bun run bootstrap:sync` (gated by `plugin-bootstrap-fresh.test.ts`). Permission enforcement everywhere (VM, host, editor) validates against `grantedPermissions`, never the declared `permissions` array. Feature doc: [`docs/features/plugin-system.md`](docs/features/plugin-system.md). diff --git a/bun.lock b/bun.lock index 5c908ad2d..c37f99ff0 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.35.0", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1", }, @@ -1155,6 +1158,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=="], @@ -1199,6 +1204,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=="], @@ -1685,10 +1692,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/docs/architecture.md b/docs/architecture.md index 3c4b67c7a..5c4ea327a 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 | +| Real-time co-editing | `src/core/collab/`, `server/collab/*` | CRDT co-editing (Yjs): one Y doc per row + the site shell, multiplexed over the `/admin/api/cms/site-socket` WebSocket; the server relay persists continuously and resets docs on out-of-relay writes | | 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/collab-hardening-log.md b/docs/collab-hardening-log.md new file mode 100644 index 000000000..8f2532c33 --- /dev/null +++ b/docs/collab-hardening-log.md @@ -0,0 +1,138 @@ +# 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 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) + +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 + 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). 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/docs/editor.md b/docs/editor.md index af3fa4843..f10e1d0bf 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -618,7 +618,7 @@ Site-specific controls that were previously sections of this modal (Pages roster **Data source — `useSiteSettingsController`** (`src/admin/modals/Settings/useSiteSettingsController.ts`): the General and Publishing sections edit fields of the persisted `SiteDocument` (`name`, `settings.*`, framework preferences), but where that document lives depends on the route. The modal is global, so a section cannot just read the editor store — that store is only hydrated on the Site editor (`AdminCanvasLayout`). The controller hides the split behind one uniform shape: -- **Site editor** (editor store holds a live draft): delegate to the editor-store mutations. Settings edits join the unsaved draft and persist through the editor's autosave / Save pipeline alongside page-tree edits — never clobbered. +- **Site editor** (editor store holds the live document): delegate to the editor-store mutations. Settings edits ride the collab write path alongside page-tree edits — streamed live to every peer and persisted by the server relay. - **Every other admin page** (no in-memory draft): a standalone Zustand store loads the document once via `cmsAdapter`, edits a local copy, and persists immediately with a shell-only `saveSite` (empty dirty sets, so pages / components / layouts are left untouched). After each save it refreshes the `adminUi` site summary and fires `CMS_SITE_RELOAD_EVENT` so the toolbar brand and `useSiteSummary` re-sync. There is no Save button on those pages, so writes commit on blur / toggle. Because the controller is imported only by the lazy section components, the editor-store import it carries stays inside the `SettingsModal` chunk and never enters the eager graph of the lightweight layouts. This is what makes the modal *actually* global — before it, General and Publishing rendered a permanent skeleton anywhere outside the Site editor. diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index 01e3dc267..cec9814b5 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -1,6 +1,8 @@ # Editor Preferences -Local UI preferences for the editor — auto-save behaviour, hover-preview gating, admin theme, UI text size, density, layers panel options, etc. Stored in `localStorage`, scoped to the device, never written to the site file. +Local UI preferences for the editor — hover-preview gating, admin theme, UI text size, density, layers panel options, etc. Stored in `localStorage`, scoped to the device, never written to the site file. + +There are no auto-save preferences: the collab relay persists continuously (see [docs/features/site-shell.md](site-shell.md) → "Real-time co-editing"), so there is nothing to schedule. The feature is **catalog-driven**: one declarative array drives the schema, the runtime defaults, and the Settings → Preferences UI. Adding a preference is two lines. @@ -9,8 +11,8 @@ The feature is **catalog-driven**: one declarative array drives the schema, the ## TL;DR - Source of truth: `PREFERENCE_CATALOG` in `src/admin/pages/site/preferences/catalog.ts`. -- Read from React: `useEditorPreference('autoSave')` / `useEditorSelectPreference('density')` / `useEditorAppearancePreferences()`. -- Read from non-React: `readEditorPreference('autoSave')` + `subscribeToEditorPrefsChanged(listener)`. +- Read from React: `useEditorPreference('hoverPreview')` / `useEditorSelectPreference('density')` / `useEditorAppearancePreferences()`. +- Read from non-React: `readEditorPreferenceBool('hoverPreview')` + `subscribeToEditorPrefsChanged(listener)`. - Settings UI renders automatically from the catalog — no per-preference wiring. - Storage: `localStorage["instatic-editor-prefs"]` (`EDITOR_PREFS_KEY`). `additionalProperties: true` on the schema keeps forward / backward compatibility silent. @@ -58,11 +60,11 @@ A single `PREFERENCE_CATALOG` array lists every preference. Each entry declares ```ts export const PREFERENCE_CATALOG = [ { - id: 'autoSave', + id: 'hoverPreview', type: 'boolean', category: 'editor', - label: 'Auto-save', - description: 'Automatically save the site every 30 seconds.', + label: 'Hover preview', + description: 'Preview classes and tokens on the canvas while hovering them.', default: true, }, // … @@ -120,7 +122,7 @@ Both go through `parseJsonWithFallback(EditorPrefsSchema, …)` so corrupt or pa **3. Event bus + React hooks** ```ts -// Event bus — for non-React consumers (e.g. usePersistence's auto-save scheduler) +// Event bus — for non-React consumers export function subscribeToEditorPrefsChanged(listener: () => void): () => void export function notifyEditorPrefsChanged(): void @@ -197,35 +199,30 @@ Because the effect keys on the document id, it also re-centers when the active d ## Reading preferences from non-React code -Some call sites are not React components — `usePersistence.ts`'s auto-save scheduler is one example. They use the imperative API: +Some call sites are not React components. They use the imperative API: ```ts import { - readAutoSavePreference, - readAutoSaveDelayMs, + readEditorPreferenceBool, readEditorSelectPreference, subscribeToEditorPrefsChanged, } from '@site/preferences/editorPreferences' // Read once at setup time -const enabled = readAutoSavePreference() -const delayMs = readAutoSaveDelayMs() +const hoverPreview = readEditorPreferenceBool('hoverPreview') +const breakpoint = readEditorSelectPreference('defaultBreakpoint') // React to changes const unsub = subscribeToEditorPrefsChanged(() => { - scheduleAutoSave() + reapplyPreferences() }) ``` -Named convenience wrappers (`readAutoSavePreference`, `readHoverPreviewPreference`, `readAutoSaveDelayMs`) sit on top of the generic getters for one reason: - -They self-document at the call site — `readAutoSavePreference()` reads better than `readEditorPreference('autoSave')`. - -When a new preference needs an imperative reader, add a similarly-named wrapper in `editorPreferences.ts`. They're one-liners. +Two generic getters cover every preference — `readEditorPreferenceBool(id)` for booleans and `readEditorSelectPreference(id)` for select / select-dynamic. Both are typed against the catalog, so a typo'd id is a compile error. There are no per-preference convenience wrappers: a wrapper per preference is a second place to keep in sync for no gain. ### Imperative settings via `setEditorPreference` / `setEditorSelectPreference` -Both setters dispatch the change event so all hook consumers re-render and the bus listeners (`usePersistence.ts`) re-evaluate. They're available outside React for migration scripts, plugin defaults, or one-shot programmatic toggles, but the typical setter path is the Settings UI. +Both setters dispatch the change event so all hook consumers re-render and bus listeners re-evaluate. They're available outside React for migration scripts, plugin defaults, or one-shot programmatic toggles, but the typical setter path is the Settings UI. --- @@ -234,7 +231,6 @@ Both setters dispatch the change event so all hook consumers re-render and the b ```jsonc // localStorage["instatic-editor-prefs"] { - "autoSave": true, "hoverPreview": false, "theme": "light", "density": "comfortable", @@ -266,8 +262,6 @@ The Settings → Preferences screen renders this list automatically from the cat | Category | Id | Type | Default | Wired in | |------------------|-----------------------------|----------------------|-------------|------------------------------------------------| -| Editor | `autoSave` | boolean | `true` | `usePersistence.ts` | -| Editor | `autoSaveDelay` | select (5s/15s/30s/60s/5min) | `'30'` | `usePersistence.ts` (`readAutoSaveDelayMs`) | | Editor | `hoverPreview` | boolean | `true` | `ClassPicker.tsx`, `SpacingBoxControl.tsx` | | Editor | `confirmBeforeDelete` | boolean | `false` | `ConfirmDeleteProvider` | | Editor | `theme` | select (dark / light) | `'dark'` | `data-editor-theme` on the document + layout roots | diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 9beb570ec..5bc123313 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -137,7 +137,7 @@ Server-resolved tools work without an editor open. They include content reads, ` Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call. -There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and risk autosave overwrites. Successful relayed edits flush the draft before returning, so a following headless read or explicit publish sees the saved result. +There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and overwrite the live document. Relayed edits need no post-tool save step: store mutations stream to the collab relay the moment they land, and every headless read (plus `site_publish`) flushes the relay server-side before it touches the DB — so a following read or publish always observes the edit. There is no client-side save flush, and no window in which the MCP caller can see stale data. Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested. diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 0ecf80cfd..4019d8b85 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -373,14 +373,22 @@ Pages and VCs follow the same principle: `validateVisualComponents` silently dro ## Saving the site +**The editor does not save over HTTP anymore** — it persists continuously +through the real-time co-editing relay (see "Real-time co-editing" below). +The transactional endpoint remains the write path for every **standalone +HTTP writer**: the Settings modal outside the editor, onboarding's +framework import, Super Import, and the fresh-install bootstrap. + 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: @@ -395,22 +403,9 @@ Two modes: the server derives deletions as stored − shipped. `deleted*Ids` must be empty. -Saves are **incremental**: the editor store derives which pages/VCs/layouts -changed — and which were deleted — from the same Mutative patches that power -undo (`src/admin/pages/site/store/slices/site/dirtyTracking.ts`; deletions -come from a pre/post membership diff, robust to any recipe style), and -`usePersistence.ts` ships only those — a one-prop edit uploads one page, not -the site. Delete-then-recreate within one save window nets to a plain write -at snapshot time; the server 400s any id in both the changed and deleted -sets as a backstop. Anything the tracker can't attribute marks `all` and -ships as a replace-mode full save. Granular write gates -(`SITE_WRITE_CAPABILITIES`) enforce what each role can actually change -inside the diff. - -`usePersistence` runs saves through a **single-flight queue**: at most one -save on the wire and one queued follow-up that reads the latest store state -— autosave, Cmd+S, save-request events, the MCP bridge, and the unmount -flush can never interleave requests. +Granular write gates (`SITE_WRITE_CAPABILITIES`) enforce what each role can +actually change inside the shell/page diffs. The server 400s any id in both +the changed and deleted sets as a backstop. The save is **atomic and fail-closed**: shell + components + layouts + pages commit in one transaction (`server/handlers/cms/siteDocument.ts`), so one @@ -442,14 +437,178 @@ 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 (HTTP writers) + +Editors co-edit through the CRDT relay and never conflict; this check guards +the remaining HTTP writers against each other (two Settings modals open on +two admin tabs, onboarding racing an import). 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). + +Writers OUTSIDE the transactional save (plugin pack installs via +`saveDraftSite`/`saveDataRowDraft`, data-workspace row edits) do not stamp +seqs, so this check cannot see them — but every repository write fires +`notifyRowWrite`/`notifyShellWrite` (`server/repositories/rowWriteEvents.ts`), +which makes the collab relay RESET the affected docs: connected editors +rebind and pick the external change up live. + +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. + +### Real-time co-editing (CRDT) + +Every open editor is a live peer on the same document — edits merge +granularly (two admins can restyle two nodes of the same page, or co-type +one text node, simultaneously), presence is visible, and there is **no save +UI at all**: the server persists continuously. + +**Document model** (`src/core/collab/`): one Yjs doc per logical row — +`page:`, `component:`, `layout:` — plus one `site:default` +doc for the shell and the roster order. Page/component trees map to +`getMap('tree')` (`rootNodeId` + a `nodes` Y.Map of per-node Y.Maps: `props` +as a Y.Map with the module's inline-text prop as Y.Text, nested +`breakpointOverrides` Y.Maps, `children` as Y.Array; `parentId` is derived, +never stored). Layout snapshots are whole-value LWW. The shell keeps +`settings` / `styleRules` / `explorer` as per-entry Y.Maps and everything +else plain. Deterministic reconciles (`integrity.ts` tree repair, roster +order) run identically on every peer. + +**Editor write path** (`src/admin/pages/site/store/slices/site/collabBinding.ts`): +local mutations keep applying directly to the Zustand store (the hot path is +untouched), and their Mutative patches translate into targeted Y operations +(`@core/collab` `applySitePatchesToDocs`) — text via minimal Y.Text splices, +children via array diffs, roster membership via pre/post id-set diffs; +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 +mutations (convert-to-component, roster ops, Super Import) undo as ONE step +across all their docs via a routing-group stack. + +**Server relay** (`server/collab/relay.ts`): owns the authoritative docs, +seeds them from the stored JSON (the server is the ONLY seeder — fixed seed +clientID, so two clients can never build divergent initial histories), +persists each doc's update blob to `collab_documents` AND the derived row +JSON to `data_rows`/site on a short debounce (~800 ms), applies +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. 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 +at `/admin/api/cms/site-socket` (y-protocols sync + awareness + reset). +Upgrade is gated by a session with `site.read` and `originAllowed` (CSWSH +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 (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. 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 +moment they commit (no flush window to lose on unload); reconnect uses +exponential backoff, and each (re)connect re-runs syncStep1 so Yjs state +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 +color from the user id + the same upload→Gravatar avatar fields every admin +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 +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 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 -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/reference/capabilities.md b/docs/reference/capabilities.md index a6abc0d24..d625185cf 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -35,6 +35,18 @@ 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`). +**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 | Capability | Grants | Roles | diff --git a/docs/reference/editor-history.md b/docs/reference/editor-history.md index 79e9735ab..c8713c715 100644 --- a/docs/reference/editor-history.md +++ b/docs/reference/editor-history.md @@ -1,179 +1,81 @@ -# Editor Undo/Redo History - -How the visual editor captures, stores, and applies undo/redo history using Mutative patch pairs. - -Every undoable mutation captures a `HistoryEntry` — a pair of Mutative patch arrays scoped to the `SiteDocument`. Undo applies the `inverse` patches; redo applies the `forward` patches. Cost is O(change): only the paths the recipe touches are drafted and copied. - ---- - -## TL;DR - -- History is `_historyPast: HistoryEntry[]` and `_historyFuture: HistoryEntry[]` on the editor store. Max depth: `MAX_HISTORY` (50). -- Each `HistoryEntry` holds `{ inverse, forward, coalesceKey }` — patch arrays, not full-site clones. -- `runHistoricMutation` is the single entry point. All six `mutate*` helpers delegate to it. -- Continuous-input bursts (per-keystroke text/number edits) fold into one entry via `commitHistory` coalescing. -- Patches are scoped to `site` (`state.site.*`) — editor-local state (selection, zoom, panel visibility) is not undoable. -- History is in-memory session state — never serialized. - ---- - -## Performance - -Per-mutation wall time is flat at ~0.25–0.4 ms regardless of site size: - -| Nodes | Patch-based | structuredClone (old) | Speedup | -|--------|-------------|----------------------|---------| -| 500 | 0.25 ms | 0.76 ms | 3× | -| 5,000 | 0.28 ms | 8.8 ms | 31× | -| 20,000 | 0.32 ms | 34 ms | 106× | -| 50,000 | 0.40 ms | 98 ms | ~245× | - -A full 50-deep history stores ~240 small patches (KB total) instead of 50 whole-site clones (hundreds of MB). - ---- - -## Data model - -`src/admin/pages/site/store/slices/site/types.ts`: - -```ts -import type { Patches } from 'mutative' - -export interface HistoryEntry { - /** Patches that revert this transaction. Applied on undo. */ - inverse: Patches - /** Patches that re-apply this transaction. Applied on redo. */ - forward: Patches - /** Coalescing burst identity, or null. */ - coalesceKey: string | null -} -``` - -The store holds: - -```ts -_historyPast: HistoryEntry[] // stack — most recent last -_historyFuture: HistoryEntry[] // entries available for redo -canUndo: boolean -canRedo: boolean -_historyCoalesceKey: string | null // identity of the in-progress burst -``` - ---- - -## How patches are captured - -`runHistoricMutation` in `helpers.ts` is the core engine: - -```ts -function runHistoricMutation(recipe, coalesceKey) { - const [next, patches, inverse] = create(cur, (draft) => { - result = recipe(draft) - if (result !== false) draft.site.updatedAt = Date.now() - }, { enablePatches: true }) - - // History stores patches relative to `site` (strip the leading path segment) - const siteForward = patches .filter(p => p.path[0] === 'site').map(p => ({ ...p, path: p.path.slice(1) })) - const siteInverse = inverse.filter(p => p.path[0] === 'site').map(p => ({ ...p, path: p.path.slice(1) })) - - set(state => { - // Apply all changed fields to the live store (site + any editor fields) - for (const key of touched) live[key] = produced[key] - if (siteForward.length > 0) commitHistory(state, { inverse: siteInverse, forward: siteForward, coalesceKey }) - state.hasUnsavedChanges = true - }) -} -``` - -`create(cur, recipe, { enablePatches: true })` returns `[next, forwardPatches, inversePatches]`. Only `site`-prefixed patches go into the history entry. Editor-only fields (selection, zoom) are applied live but never recorded. - ---- - -## The six `mutate*` helpers - -All six helpers in `SiteSliceHelpers` delegate to `runHistoricMutation`: - -| Helper | Recipe receives | Coalescing | -|---|---|---| -| `mutateSite(fn, opts?)` | `SiteDocument` draft | `opts.coalesceKey` | -| `mutateSiteWithExplorerReconcile(fn)` | `SiteDocument` draft; calls `reconcileSiteExplorerInPlace` after | none | -| `mutatePage(fn)` | Active `Page` draft | none | -| `mutateActiveTree(fn, opts?)` | Active `NodeTree` draft; routes page vs. VC | `opts.coalesceKey` | -| `mutateActiveTreeAndSite(fn)` | Active `NodeTree` + `SiteDocument` drafts | none | -| `mutateAllPagesAndSite(fn)` | `SiteDocument` + `SuperImportHelpers` | none | - -`mutateActiveTree` is the only place that branches on page-mode vs. VC-mode. Gated by `no-vc-mode-branches-in-mutations.test.ts`. - ---- - -## Coalescing - -Per-keystroke mutations (text edits, number sliders) pass a stable `coalesceKey` such as `props::`. While the incoming key matches `_historyCoalesceKey`, `commitHistory` folds the new entry into the existing top entry **per patch path** (`foldIntoCoalescedEntry` in `helpers.ts`): - -- **inverse**: the OLDEST patch per path wins (undo restores the pre-burst value); new paths append. -- **forward**: the NEWEST patch's value per path wins (redo replays the final value), preserving the oldest patch's op (an `add` stays an `add` so redo works from the post-undo state where the prop is absent). - -A whole typing burst becomes one undo step holding at most one inverse + one forward patch per touched path — a 2,000-keystroke burst retains 2 paths' worth of patches, not 4,000 progressively-longer string snapshots. - -Any non-coalescing mutation, `undo`, `redo`, or a site (re)load resets `_historyCoalesceKey` to `null`. - ---- - -## Undo / redo apply - -`undoRedoActions.ts` uses `apply` from Mutative: - -```ts -// undo -const restored = apply(site, entry.inverse) -const packageJson = clonePackageJson(restored.packageJson) -const siteRuntime = cloneSiteRuntimeConfig(restored.runtime) -set(state => { - state._historyPast.pop() - state._historyFuture.push(entry) - state._historyCoalesceKey = null - state.site = { ...restored, packageJson, runtime: siteRuntime } - state.packageJson = packageJson - state.siteRuntime = siteRuntime - // re-derive mirrors; keep activePageId valid -}) -``` - -`redo` is symmetric: pops from `_historyFuture`, applies `entry.forward`, pushes back onto `_historyPast`. - ---- - -## Auto-freeze - -The Zustand store is created with `mutative({ enableAutoFreeze: true })`. That keeps a dev guard against accidental external mutation, and existing code already tolerates frozen state. `apply()` and `create()` handle frozen bases correctly. - ---- - -## What is NOT undoable - -- Selection, hover, zoom, pan — editor-local UI state, not in the `site` document. -- `mutateSiteState` — the recipe may write editor fields (e.g. `activeDocument`) alongside a `site` mutation; the editor fields go live but only the `site` patches enter history (parity with the prior snapshot model). -- History stacks themselves — resetting to `[]` on `clearSite` is a lifecycle operation, not a mutation. - ---- - -## Forbidden patterns - -- `structuredClone(site)` for history — the old snapshot model is gone. Never re-introduce it. -- Calling `set(state => { state.site = ... })` directly on a mutation — go through a `mutate*` helper so patches are captured. -- Returning a value from a `create` recipe — Mutative treats it as a full replacement. Capture no-op signals in a closure variable and return `false`. - ---- - -## Related - -- `src/admin/pages/site/store/slices/site/helpers.ts` — `runHistoricMutation`, `commitHistory`, all six `mutate*` helpers -- `src/admin/pages/site/store/slices/site/undoRedoActions.ts` — `undo`, `redo` -- `src/admin/pages/site/store/slices/site/types.ts` — `HistoryEntry`, `SiteSliceHelpers` -- `src/admin/pages/site/store/slices/site/defaults.ts` — `MAX_HISTORY` -- `docs/editor.md` — editor store overview -- `docs/reference/page-tree.md` — the `NodeTree` primitive mutations operate on -- Gate tests: - - `src/__tests__/architecture/centralized-site-mutation-history.test.ts` - - `src/__tests__/architecture/no-vc-mode-branches-in-mutations.test.ts` - - `src/__tests__/editor-store/undo-redo.test.ts` +# Editor undo/redo + +How Cmd+Z works in the visual editor since real-time co-editing landed. + +## Where history lives + +History does **not** live in the Zustand store anymore. It lives in the +collab binding (`src/admin/pages/site/store/slices/site/collabBinding.ts`) +as one **`Y.UndoManager` per collab document** — one per page, Visual +Component, and layout, plus one for the site shell/rosters. The store only +mirrors availability flags (`canUndo` / `canRedo`) and exposes the `undo` / +`redo` actions, which delegate to the binding (`site/undoRedoActions.ts`). + +Because each manager tracks **`LOCAL_ORIGIN` only**, undo is per-editor by +construction: your Cmd+Z reverts *your* edits, never a peer’s — the +co-editing invariant. + +## The mutation path + +Every store mutation still runs through `runHistoricMutation` +(`site/helpers.ts`): the recipe mutates a Mutative draft, the resulting +site-relative patches are handed to `applyLocalSitePatches` (the binding), +which translates them into Y operations on the touched docs +(`@core/collab` `applySitePatchesToDocs`). The Y transaction is what the +UndoManager captures — the undo stack IS the CRDT edit history. + +Undoing pops the doc’s stack; the resulting doc change projects back into +the store through the binding’s projection path (the same path remote +peers’ edits use), synchronously flushed so undo repaints immediately. + +## Coalescing (typing bursts) + +Per-keystroke mutations (text edits, number sliders) pass a stable +`coalesceKey` such as `props::` through the `mutate*` helpers. +The managers run with an infinite `captureTimeout`; the binding calls +`stopCapturing()` exactly when the incoming key differs from the previous +one — so consecutive same-key edits merge into ONE undo step, and any +non-coalescing mutation, undo/redo, or inline-edit session boundary +(`collabBreakCoalescing`) starts a fresh step. Typing a word is one Cmd+Z. + +## Multi-doc undo groups + +A single mutation can touch several docs (convert-to-component writes the +page, the new component, and the site roster; Super Import touches +everything). The binding records each undoable step as a **group of docIds** +whose managers captured a new stack item, and `undo()` reverts the whole +group — one Cmd+Z, one logical mutation, across all its documents. The site +doc always sorts first in a group so roster reverts project after row-level +reverts. + +## Lifecycle + +`createSite` / `loadSite` / `clearSite` call `resetCollabDocsFromSite`, +which rebuilds the doc world for the new document and clears every undo +manager — history never survives a document swap. In detached mode (tests, +the pre-connect window) docs seed locally from the loaded site; in connected +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, + routing groups, coalescing, projection +- `src/admin/pages/site/store/slices/site/helpers.ts` — `runHistoricMutation` + and the six `mutate*` helpers (unchanged recipe API) +- `src/admin/pages/site/store/slices/site/undoRedoActions.ts` — the store’s + `undo`/`redo` delegates +- `src/core/collab/applyPatches.ts` — patch → Y translation +- Tests: `src/__tests__/editor-store/undo-redo.test.ts` (behavioral + contract), `src/__tests__/collab/*` (round-trip identity) diff --git a/docs/server.md b/docs/server.md index 00b62ded4..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`. @@ -38,7 +39,17 @@ 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) }) + ├─→ createCollabRelay(db) ← server/collab/relay.ts: live Y docs, + │ debounced persistence, reset protocol + ├─→ createCollabSocketLayer(relay) ← server/collab/socket.ts: multiplexed + │ y-protocols wire + awareness + ├─→ Bun.serve({ fetch: req => handleServerRequest(req, runtime), + │ websocket: collabSocket.handlers }) + │ (the co-editing socket `/admin/api/cms/site-socket` upgrades at this + │ boundary — see server/collab/socket.ts; everything else goes + │ through the router) + │ + └─→ collabSocket.setPublisher(server) ← wire the collab fan-out 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. @@ -347,6 +358,7 @@ All SQL lives in `server/repositories/`. Each file owns one resource: | File | Owns | |----------------------------|---------------------------------------------------| | `audit.ts` | Audit log writes and queries | +| `collabDocuments.ts` | Persisted CRDT document blobs (`collab_documents`) — the co-editing relay's durable state | | `data/` | `data_tables` + `data_rows` (the universal store) | | `fonts.ts` | Font assets | | `loginAttempts.ts` | Failed-login records for lockout | @@ -358,11 +370,12 @@ All SQL lives in `server/repositories/`. Each file owns one resource: | `plugins.ts` | Installed plugins + lifecycle state | | `publish.ts` | Published-page roster: snapshot getters + the transactional publish write (orchestration lives in `server/publish/publishSite.ts`) | | `roles.ts` | System and custom roles | +| `rowWriteEvents.ts` | In-process out-of-relay write notifications (repositories notify; the collab relay resets affected docs) | | `runtimeAsset.ts` | Published runtime assets (JS, CSS, fonts) | | `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/package.json b/package.json index 57771a0ee..e551b4ded 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.35.0", "uqr": "^0.1.3", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zustand": "^5.0.12", "zustand-mutative": "^1.3.1" }, 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/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index 5a72f5ea5..fb11868ca 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 @@ -135,6 +136,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/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/relay.ts b/server/collab/relay.ts new file mode 100644 index 000000000..380a1e3c3 --- /dev/null +++ b/server/collab/relay.ts @@ -0,0 +1,569 @@ +/** + * 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. + * - 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) + * 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 { nanoid } from 'nanoid' +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 { + getDataRow, + listDataRowIdSlugs, + softDeleteDataRow, + upsertDataRowDraft, +} 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' +import { registerPublishFlush } from '../publish/publishFlush' + +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 + /** This doc's CRDT lineage id — see @core/collab/protocol. */ + generation: string + refs: number + dirty: boolean + persistTimer: ReturnType | null + /** Serializes persists per doc so row writes never overlap. */ + persistChain: Promise + detachUpdateHandler: () => void +} + +type DerivedWrite = 'written' | 'incomplete' | 'invalid' +type PersistOutcome = 'clean' | 'retry' | 'invalid' + +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 + release(docId: string): void + 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>() + /** + * 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 + 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 ─────────────────────────────────────────────────────────── + + /** + * 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 'incomplete' + if (parsed.kind === 'site') { + const projected = projectSiteDoc(doc) + if (Object.keys(projected.shell).length === 0) return 'incomplete' // 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 'invalid' + } + 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 'written' + 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 + } + } + lastSweptRostersKey = rostersKey + if (deletedPublished) await bumpPublishVersionSerialized() + return 'written' + } + + 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 '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 'incomplete' + cells = visualComponentToCells(vc) + slug = vcSlugFromName(vc.name) + } else { + const layout = projectLayoutDoc(doc, parsed.rowId) + if (!layout.rootNodeId || layout.name === '') return 'incomplete' + cells = savedLayoutToCells(layout) + slug = layoutSlugFromName(layout.name) + } + + // 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 }, + ) + return 'written' + } + + async function persistNow(docId: string): Promise { + const entry = entries.get(docId) + if (!entry || !entry.dirty) return 'clean' + entry.dirty = false + try { + 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 + // the doc clean here is how an accepted page silently disappears. + if (derived === 'invalid') { + entry.dirty = true + return 'invalid' + } + return 'clean' + } catch (err) { + entry.dirty = true + console.error(`[collab] persist failed for ${docId}:`, err) + return 'retry' + } + } + + 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 + 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) + } + + // ── Registry ────────────────────────────────────────────────────────────── + + async function openDoc(docId: string): Promise { + const existing = entries.get(docId) + if (existing) return { doc: existing.doc, generation: existing.generation } + const inFlight = opening.get(docId) + 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 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, generation) + schedulePersist(docId) + } + doc.on('update', updateHandler) + entries.set(docId, { + doc, + generation, + refs: 0, + dirty: false, + persistTimer: null, + persistChain: Promise.resolve(), + detachUpdateHandler: () => doc.off('update', updateHandler), + }) + 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) + 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 + 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. + const persist = entry.persistChain.then(() => + opts2.persist ? persistNow(docId) : Promise.resolve('clean'), + ) + 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) + 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) + } + } + + 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 + + // 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 + 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() + for (const docId of affected) { + const refs = entries.get(docId)?.refs ?? 0 + if (refs > 0) heldRefs.set(docId, refs) + await evict(docId, { persist: false }) + } + + // 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) + } + } + + // ── 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) + }) + }) + + 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 + } + 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`, + ) + } + } + } + + // 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) => { + // 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 { doc: entry.doc, generation: entry.generation } + } + }, + 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) + }) + } + }, + subscribeUpdates: (listener) => { + updateListeners.add(listener) + return () => updateListeners.delete(listener) + }, + onReset: (listener) => { + resetListeners.add(listener) + return () => resetListeners.delete(listener) + }, + resetDocs, + flushAll, + destroy: async () => { + detachRowListener() + detachShellListener() + detachPublishFlush() + for (const docId of [...entries.keys()]) { + await evict(docId, { persist: true }) + } + }, + } +} diff --git a/server/collab/socket.ts b/server/collab/socket.ts new file mode 100644 index 000000000..7e0dd53e0 --- /dev/null +++ b/server/collab/socket.ts @@ -0,0 +1,490 @@ +/** + * 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, + encodeResetPayload, + FRAME_AWARENESS, + FRAME_PING, + FRAME_PONG, + FRAME_RESET, + FRAME_SYNC, + parseCollabDocId, + 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' +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' +import type { CollabRelay, RelayDoc } 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 + +// 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 + +/** 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 }, +) + +/** + * 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. + */ +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, +): AwarenessRefusal | null { + try { + const decoder = decoding.createDecoder(payload) + const count = decoding.readVarUint(decoder) + for (let i = 0; i < count; i++) { + const clientId = decoding.readVarUint(decoder) + decoding.readVarUint(decoder) // clock + const raw = decoding.readVarString(decoder) + if (raw === 'null') { + if (!data.awarenessClients.has(clientId)) return 'foreignClear' + continue + } + const parsed = safeParseValue(PresenceUserSchema, JSON.parse(raw)) + if (!parsed.ok) return 'malformed' + 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 'impersonation' + } + } + return null + } catch { + // Undecodable update — refuse rather than relay garbage. + return 'malformed' + } +} + +export interface CollabSocketData { + userId: string + /** The session identity this connection may publish over presence. */ + identity: CollabPresenceIdentity + /** + * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common + * 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. */ + 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 +} + +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 fullSiteWriter = SITE_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) + const upgraded = server.upgrade(req, { + data: { + userId: user.id, + identity: { + id: user.id, + name: user.displayName, + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + }, + fullSiteWriter, + capabilities: user.capabilities, + boundDocs: new Set(), + probedDocs: 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, _origin, generation) => { + if (!publisher) return + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + publisher.publish( + docTopic(docId), + encodeCollabFrame(docId, generation, FRAME_SYNC, encoding.toUint8Array(encoder)), + ) + }) + relay.onReset((docId) => { + // 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 + * guard must NOT escape as an unhandled rejection. + */ + async function dispatchFrame( + 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 + 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 + // 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 + 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 + } + + const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload)) + + let bound: RelayDoc + if (ws.data.boundDocs.has(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 + // 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 { + 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 + } + + // 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) + 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). + sendReset(ws, frame.docId, 'refused') + return + } + Y.applyUpdate(doc, update, ws) + 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) + if (encoding.length(encoder) > 0) { + ws.send(encodeCollabFrame(frame.docId, generation, 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 { + sendReset(ws, frame.docId, 'refused') + } catch (_sendErr) { + // Socket already closing — nothing to recover. + } + } + } + }, + + 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/collab/updateGuard.ts b/server/collab/updateGuard.ts new file mode 100644 index 000000000..f86d087ed --- /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 '../writePolicy/siteDiff' +import { validatePageWriteDiff } from '../writePolicy/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/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 6fad59d68..e6ca8ee65 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1120,4 +1120,31 @@ export const pgMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // 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: '022_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() + ); + `, + }, + { + // 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 40d5ba57b..734a53fb3 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1184,4 +1184,31 @@ export const sqliteMigrations: Migration[] = [ on ai_mcp_oauth_tokens (connector_id); `, }, + { + // 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: '022_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 + ); + `, + }, + { + // 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/handlers/cms/components.ts b/server/handlers/cms/components.ts index 0b000830f..7e48f7df7 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, 'components') } diff --git a/server/handlers/cms/data/rows.ts b/server/handlers/cms/data/rows.ts index b04d3947d..bf06bb15b 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 { badRequest, jsonResponse, readValidatedBody } from '../../../http' @@ -255,6 +256,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/server/handlers/cms/layouts.ts b/server/handlers/cms/layouts.ts index 6bb3c8b92..cb42d825e 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 { + 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 +209,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 +350,50 @@ 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) { + // 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 // issues no row queries inside the transaction. if (componentWrites.length > 0 || componentDeleteIds.size > 0) { @@ -337,10 +421,25 @@ 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() + // 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 }) + } + return jsonResponse({ ok: true, seq }) } catch (err) { if (err instanceof SiteValidationError) return badRequest(err.message) @@ -350,6 +449,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/index.ts b/server/index.ts index e4f3675b0..7d5b7579b 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 { createCollabRelay } = await import('./collab/relay') +const { SITE_SOCKET_PATH, createCollabSocketLayer, handleCollabSocketUpgrade } = + await import('./collab/socket') const config = readServerConfig() configureTrustedProxyCidrs(config.trustedProxyCidrs) @@ -29,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. @@ -58,7 +66,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 +96,17 @@ Bun.serve({ ) } + // 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 handleCollabSocketUpgrade(req, db, server) + if (rejection === null) return undefined + return applySecurityHeaders(rejection, pathname) + } + try { const res = await handleServerRequest(req, { db, @@ -116,10 +135,36 @@ Bun.serve({ } }, + websocket: collabSocket.handlers, + error(err: Error) { console.error('[server] Unhandled error:', err) return new Response('Internal Server Error', { status: 500 }) }, }) +// The collab fan-out publishes through Bun pub/sub — register the live +// 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/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/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts new file mode 100644 index 000000000..804d97e99 --- /dev/null +++ b/server/repositories/collabDocuments.ts @@ -0,0 +1,64 @@ +/** + * 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). + * + * `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; generation: string }>` + select state_blob, generation from collab_documents + where doc_id = ${docId} + limit 1 + ` + 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, 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 + ` +} + +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/server/repositories/data/index.ts b/server/repositories/data/index.ts index 4d3e91a84..3e55ba96c 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -31,6 +31,8 @@ export { export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, + listChangedDataRowRefsSince, listDataRowsWithFilter, searchDataRows, getDataRow, @@ -41,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/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/index.ts b/server/repositories/data/rows/index.ts index c4d9fa393..0d452f16a 100644 --- a/server/repositories/data/rows/index.ts +++ b/server/repositories/data/rows/index.ts @@ -19,6 +19,8 @@ export { listDataRows, listDataRowIdSlugs, + listDataRowSeqs, + listChangedDataRowRefsSince, getDataRow, getDataRowMany, getDataRowBySlug, @@ -35,6 +37,7 @@ export { listDataRowsWithFilter } from './filter' export { createDataRow, saveDataRowDraft, + upsertDataRowDraft, updateDataRowDraftCells, softDeleteDataRow, updateDataRowTable, 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/mutations.ts b/server/repositories/data/rows/mutations.ts index c4e49ea32..756cf643d 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 } /** @@ -126,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 @@ -158,6 +204,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 +223,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/data/rows/read.ts b/server/repositories/data/rows/read.ts index c0be6c707..d0a9cb407 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,68 @@ 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 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/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 0d3cff389..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,13 +96,35 @@ 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() +} + +/** + * 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/server/handlers/cms/pageDiff.ts b/server/writePolicy/pageDiff.ts similarity index 86% rename from server/handlers/cms/pageDiff.ts rename to server/writePolicy/pageDiff.ts index d0063089e..f6877b01b 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,8 @@ * - 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 { deepEqual } from '@core/utils/deepEqual' import { ForbiddenSiteChangeError } from './siteDiff' import { registry, resolvePropertyControlCategory } from '@core/module-engine' import type { Page, PageNode } from '@core/page-tree' @@ -185,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/server/handlers/cms/siteDiff.ts b/server/writePolicy/siteDiff.ts similarity index 87% rename from server/handlers/cms/siteDiff.ts rename to server/writePolicy/siteDiff.ts index c8671f032..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,11 +31,12 @@ * 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, } from '@core/page-tree' +import { deepEqual } from '@core/utils/deepEqual' type SiteChangeKind = 'structure' | 'content' | 'style' @@ -237,33 +241,4 @@ function diffFiles( } } -// --------------------------------------------------------------------------- -// 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/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__/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__/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/__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__/canvas/selectionToolbar.test.tsx b/src/__tests__/canvas/selectionToolbar.test.tsx index 9ac429cbe..9d2dd6a6a 100644 --- a/src/__tests__/canvas/selectionToolbar.test.tsx +++ b/src/__tests__/canvas/selectionToolbar.test.tsx @@ -272,7 +272,10 @@ describe('canvas selection toolbar', () => { act(() => { // Iframe content is mounted on the iframe's `load` event (microtask // in happy-dom). One RAF tick may run before the iframe portal has - // populated, so flush a few in case the first observation is stale. + // populated, and sibling overlays (peer presence) enqueue their own + // portal-capture frames — flush a few so the positioning tick runs. + raf.flushOne() + raf.flushOne() raf.flushOne() raf.flushOne() raf.flushOne() diff --git a/src/__tests__/collab/applyPatches.test.ts b/src/__tests__/collab/applyPatches.test.ts new file mode 100644 index 000000000..29cc682b4 --- /dev/null +++ b/src/__tests__/collab/applyPatches.test.ts @@ -0,0 +1,232 @@ +/** + * 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 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, +): { 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('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) + 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/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx new file mode 100644 index 000000000..d18b7d76d --- /dev/null +++ b/src/__tests__/collab/awareness.test.tsx @@ -0,0 +1,217 @@ +/** + * 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, + 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 + 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) + 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', + canSend: () => true, + reconnectNow: () => {}, + onStatus: () => () => {}, + 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() + }, + } +} + +/** 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('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() + connectCollabProvider(provider) + + render() + + await act(async () => { + injectPeerState(provider.awareness, { + user: { id: 'u2', name: 'Ada', color: peerColor('u2'), avatarUrl: null, gravatarHash: null }, + docId: 'page:p1', + selectedNodeIds: ['n1'], + editingNodeId: null, + pointer: null, + textCaret: null, + }) + injectPeerState(provider.awareness, { + user: { id: 'u3', name: 'Grace', color: peerColor('u3'), avatarUrl: null, gravatarHash: null }, + docId: 'page:OTHER', + 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' }) + }) + + expect(screen.getByText('Ada')).toBeTruthy() + expect(screen.queryByText('Grace')).toBeNull() + }) +}) + +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', + ) + // Scan RULES only — comments may (and do) mention the forbidden pattern. + const rulesOnly = css.replace(/\/\*[\s\S]*?\*\//g, '') + expect(rulesOnly).not.toContain('display: none') + }) + + + 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'), avatarUrl: null, gravatarHash: null }, + docId: `page:${pageId}`, + selectedNodeIds: [site.pages[0].rootNodeId], + editingNodeId: site.pages[0].rootNodeId, + pointer: { x: 10, y: 20, breakpointId: 'bp-desktop' }, + textCaret: null, + }) + }) + + const tag = document.querySelector('[data-peer-name-tag]') + 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/__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/__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 new file mode 100644 index 000000000..5c2ae07fd --- /dev/null +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -0,0 +1,327 @@ +/** + * 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('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() + remoteInsert(doc, nodeId, 0, 'X') + expect(el.textContent).toBe('hello') + }) +}) diff --git a/src/__tests__/collab/merge.test.ts b/src/__tests__/collab/merge.test.ts new file mode 100644 index 000000000..d6bb29ecc --- /dev/null +++ b/src/__tests__/collab/merge.test.ts @@ -0,0 +1,188 @@ +/** + * 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) + }) +}) + +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', 'gen-1', 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', '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/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/__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 new file mode 100644 index 000000000..4a660424e --- /dev/null +++ b/src/__tests__/collab/provider.test.ts @@ -0,0 +1,227 @@ +/** + * 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_PING, + 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 + bufferedAmount = 0 + 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', 'gen-1', 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', 'gen-1', 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', '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') + 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', 'gen-1', FRAME_RESET, new Uint8Array())) + await binding.whenSynced // resolves instead of hanging the test + await Promise.resolve() + 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() + }) + + 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__/collab/seedProject.test.ts b/src/__tests__/collab/seedProject.test.ts new file mode 100644 index 000000000..e93fb0f1a --- /dev/null +++ b/src/__tests__/collab/seedProject.test.ts @@ -0,0 +1,270 @@ +/** + * 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, + reconcileTreeIntegrity, + seedComponentDoc, + 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() { + 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('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' })], + }) + 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') + }) + + 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__/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__/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', - }, + }), }) } 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/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx b/src/__tests__/editor-hooks/siteEditorDataDeepLink.test.tsx index 5a56ca7b2..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) @@ -63,9 +67,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 +85,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()?.() @@ -194,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/__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 0cd43b174..000000000 --- a/src/__tests__/editor-store/dirtyTracking.test.ts +++ /dev/null @@ -1,471 +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 nothing for shell-field paths — the shell is always saved', () => { - 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()) - }) - - 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 no 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()) - }) -}) 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/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..a9007b58d 100644 --- a/src/__tests__/editor-store/undo-redo.test.ts +++ b/src/__tests__/editor-store/undo-redo.test.ts @@ -1,12 +1,16 @@ /** - * 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' +// `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() { @@ -14,17 +18,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, }) }) @@ -101,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 @@ -148,7 +189,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 +233,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 +261,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 +302,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 +322,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 +376,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 098c1b8eb..a5450e452 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,89 @@ 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 }) + }) +}) diff --git a/src/__tests__/persistence/savePersistenceQueue.test.tsx b/src/__tests__/persistence/savePersistenceQueue.test.tsx deleted file mode 100644 index 4bc282d88..000000000 --- a/src/__tests__/persistence/savePersistenceQueue.test.tsx +++ /dev/null @@ -1,208 +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 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 }) - return gate.promise - }, - } - 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 - }) -}) 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/__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/cmsDataAuthorization.test.ts b/src/__tests__/server/cmsDataAuthorization.test.ts index 0e2bbb63b..997e84b9d 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' @@ -360,6 +362,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/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/collabDocuments.test.ts b/src/__tests__/server/collabDocuments.test.ts new file mode 100644 index 000000000..b48e9d85f --- /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, '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, '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'} + ` + 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]), '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() + } finally { + await cleanup() + } + }) +}) diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts new file mode 100644 index 000000000..6b9f7d08e --- /dev/null +++ b/src/__tests__/server/collabRelay.test.ts @@ -0,0 +1,358 @@ +/** + * 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, spyOn } 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 type { DbClient } from '../../../server/db' +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 } +} + +/** + * 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 +} { + let announceBlocked!: () => void + const blocked = new Promise((resolve) => { + announceBlocked = resolve + }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let armed = false + let gated = false + + const wrapped = (async (strings: TemplateStringsArray, ...values: unknown[]) => { + if (armed && !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, 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 + 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: 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 { 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: 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))?.state).toBeDefined() + 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('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) + 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))?.state).toBeDefined() + + 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: 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') + }) + + // ── 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) + // 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. + 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 { doc: 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 { 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 new file mode 100644 index 000000000..b01e750e0 --- /dev/null +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -0,0 +1,656 @@ +/** + * Collab end-to-end — a real `Bun.serve` running the relay + socket layer, + * with real client providers (the same transport the editor uses) over real + * WebSockets: + * + * - two clients edit different nodes concurrently and CONVERGE, + * - the relay persists the blob and the derived row JSON after its + * debounce (the publisher path reads exactly what admins see), + * - 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, + * - 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 * 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, + treeMap, +} from '@core/collab' +import { pageFromRow } from '@core/data/pageFromRow' +import { + createCollabProvider, + type CollabProvider, + type CollabSocketLike, +} from '@site/collab/collabProvider' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { + createCollabSocketLayer, + 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' +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 + +let cleanups: Array<() => Promise | void> = [] + +afterEach(async () => { + for (const fn of cleanups.reverse()) await fn() + cleanups = [] +}) + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 4_000, +): Promise { + const start = Date.now() + while (!(await predicate())) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +interface Stack { + harness: CapabilityTestHarness + relay: CollabRelay + url: string + cookie: string + homeId: string +} + +async function startStack(): Promise { + const harness = await createCapabilityTestHarness() + cleanups.push(() => harness.cleanup()) + const cookie = await harness.setupOwner() + const relay = createCollabRelay(harness.db, { persistDebounceMs: PERSIST_DEBOUNCE_MS }) + 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'} + ` + return { + harness, + relay, + url: `ws://localhost:${server.port}${SITE_SOCKET_PATH}`, + cookie, + homeId: rows[0].id, + } +} + +/** Real editor transport over a real WebSocket, authenticated by cookie. */ +function connectClient( + stack: Stack, + cookie = stack.cookie, +): CollabProvider & { lastSocket: () => WebSocket | null } { + let lastSocket: WebSocket | null = null + const provider = createCollabProvider({ + createSocket: () => { + // Bun's WebSocket client supports custom handshake headers. + lastSocket = new WebSocket(stack.url, { headers: { cookie } }) + return lastSocket as unknown as CollabSocketLike + }, + }) + cleanups.push(() => provider.destroy()) + return Object.assign(provider, { lastSocket: () => lastSocket }) +} + +function setNodeLabel(doc: Y.Doc, nodeId: string, label: string): void { + doc.transact(() => { + const nodes = treeMap(doc).get('nodes') as Y.Map + const node = nodes.get(nodeId) as Y.Map + node.set('label', label) + }, LOCAL_ORIGIN) +} + +function nodeLabel(doc: Y.Doc, nodeId: string): unknown { + const nodes = treeMap(doc).get('nodes') as Y.Map | undefined + const node = nodes?.get(nodeId) as Y.Map | undefined + return node?.get('label') +} + +function insertChildNode(doc: Y.Doc, nodeId: string, moduleId: string): void { + doc.transact(() => { + const tree = treeMap(doc) + const nodes = tree.get('nodes') as Y.Map + const rootId = tree.get('rootNodeId') as string + const node = new Y.Map() + node.set('moduleId', moduleId) + node.set('props', new Y.Map()) + node.set('breakpointOverrides', new Y.Map()) + node.set('children', new Y.Array()) + node.set('classIds', []) + nodes.set(nodeId, node) + const rootChildren = (nodes.get(rootId) as Y.Map).get('children') as Y.Array + rootChildren.push([nodeId]) + }, LOCAL_ORIGIN) +} + +describe('collab relay integration (real server, real sockets)', () => { + it('two clients edit concurrently, converge, and the relay persists blob + derived JSON', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const clientA = connectClient(stack) + const clientB = connectClient(stack) + const boundA = clientA.bind(docId) + const boundB = clientB.bind(docId) + await boundA.whenSynced + await boundB.whenSynced + + const rootId = treeMap(boundA.doc).get('rootNodeId') as string + expect(treeMap(boundB.doc).get('rootNodeId')).toBe(rootId) + + // Concurrent edits on DIFFERENT nodes: A relabels the root, B inserts a + // sibling — merged, not last-writer-wins. + setNodeLabel(boundA.doc, rootId, 'Renamed by A') + insertChildNode(boundB.doc, 'node-from-b', 'base.text') + + await waitFor( + () => + nodeLabel(boundB.doc, rootId) === 'Renamed by A' && + (treeMap(boundA.doc).get('nodes') as Y.Map).has('node-from-b'), + ) + + // The relay persists the blob AND the derived row JSON after its debounce. + await waitFor(async () => { + const stored = await getCollabDocumentState(stack.harness.db, docId) + if (!stored) return false + const restored = new Y.Doc() + Y.applyUpdate(restored, stored.state) + return nodeLabel(restored, rootId) === 'Renamed by A' + }) + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, stack.homeId) + if (!row) return false + const page = pageFromRow(row) + return page.nodes[rootId]?.label === 'Renamed by A' && page.nodes['node-from-b'] !== undefined + }) + }) + + 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}` + + const viewer = await stack.harness.createRoleUser({ + name: 'Read Only', + slug: 'collab-viewer', + capabilities: ['site.read'], + }) + 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 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 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 + ;(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' + }) + + // 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 () => { + 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). Identity is server-verified — the state + // 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: realIdentity }) + await waitFor(() => { + for (const [, state] of owner.awareness.getStates()) { + const s = state as { user?: { id?: string; name?: string } } + if (s.user?.id === viewerUserId) return true + } + return false + }) + // Neither spoofed frame relayed: no foreign id, no impersonated name. + for (const [, state] of owner.awareness.getStates()) { + 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) + } + }) + + it('resets a doc when the row is written outside the relay', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + + const resets: string[] = [] + client.onReset((id) => resets.push(id)) + + // Out-of-relay write (imports, plugins, HTTP save): mutate the stored + // JSON directly — the repository notifies, the relay drops the doc and + // broadcasts FRAME_RESET. + const row = await getDataRow(stack.harness.db, stack.homeId) + await saveDataRowDraft(stack.harness.db, stack.homeId, { + cells: { ...row!.cells, title: 'Rewritten outside the relay' }, + slug: row!.slug, + }) + + await waitFor(() => resets.includes(docId)) + + // Rebinding gets a FRESH server seed carrying the out-of-relay write. + const rebound = client.bind(docId) + await rebound.whenSynced + const projected = projectPageDoc(rebound.doc, stack.homeId) + expect(projected.title).toBe('Rewritten outside the relay') + }) + + it('a reconnecting client catches up on edits it missed while offline', async () => { + const stack = await startStack() + const docId = `page:${stack.homeId}` + + const clientA = connectClient(stack) + const clientB = connectClient(stack) + const boundA = clientA.bind(docId) + const boundB = clientB.bind(docId) + await boundA.whenSynced + await boundB.whenSynced + const rootId = treeMap(boundA.doc).get('rootNodeId') as string + + // Drop A's socket; B keeps editing while A is offline. + clientA.lastSocket()?.close() + setNodeLabel(boundB.doc, rootId, 'Edited while A was away') + + // A's provider reconnects on its own (1s backoff) and step1's state + // 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') + }) + + // ── 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) + }) + + 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') + }) + + // 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') + }) + }) +}) diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts new file mode 100644 index 000000000..b1e8fad2b --- /dev/null +++ b/src/__tests__/server/collabSocket.test.ts @@ -0,0 +1,121 @@ +/** + * 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 fullSiteWriter resolved from capabilities', async () => { + const { harness, cookie } = await setup() + const server = fakeServer(true) + expect(await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, server)).toBeNull() + // 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', + 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()?.fullSiteWriter).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/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/__tests__/server/siteDocumentSave.test.ts b/src/__tests__/server/siteDocumentSave.test.ts index 78feb5d84..75cc27624 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,217 @@ 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) + + // 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() + } + }) + + 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/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx index 868442494..b126aabcc 100644 --- a/src/__tests__/settings/settingsModal.test.tsx +++ b/src/__tests__/settings/settingsModal.test.tsx @@ -409,30 +409,32 @@ describe('SettingsModal — PreferencesSection toggles', () => { openModal('preferences') render() const switches = screen.getAllByRole('switch') - expect(switches.length).toBe(12) + expect(switches.length).toBe(11) }) - it('Auto-save toggle has aria-checked="true" by default', () => { + it('Hover-preview toggle has aria-checked="true" by default', () => { openModal('preferences') render() - const autoSaveToggle = screen.getByRole('switch', { name: /auto-save/i }) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('true') + const previewToggle = screen.getByRole('switch', { name: /preview suggestions on hover/i }) + expect(previewToggle.getAttribute('aria-checked')).toBe('true') }) - it('retired snap-to-grid and reduce-motion preferences are not rendered', () => { + it('retired snap-to-grid, reduce-motion and auto-save preferences are not rendered', () => { openModal('preferences') render() expect(screen.queryByRole('switch', { name: /snap to grid/i })).toBeNull() expect(screen.queryByRole('switch', { name: /reduce motion/i })).toBeNull() + // Auto-save retired with live co-editing — the relay persists continuously. + expect(screen.queryByRole('switch', { name: /auto-save/i })).toBeNull() }) it('clicking a toggle flips its aria-checked state', () => { openModal('preferences') render() - const autoSaveToggle = screen.getByRole('switch', { name: /auto-save/i }) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('true') - fireEvent.click(autoSaveToggle) - expect(autoSaveToggle.getAttribute('aria-checked')).toBe('false') + const previewToggle = screen.getByRole('switch', { name: /preview suggestions on hover/i }) + expect(previewToggle.getAttribute('aria-checked')).toBe('true') + fireEvent.click(previewToggle) + expect(previewToggle.getAttribute('aria-checked')).toBe('false') }) it('hover preview toggle is enabled by default and can be disabled', () => { @@ -450,11 +452,11 @@ describe('SettingsModal — PreferencesSection toggles', () => { it('toggle labels are linked via htmlFor / id (label accessibility)', () => { openModal('preferences') render() - const autoSaveToggle = document.getElementById('pref-autoSave') - expect(autoSaveToggle).not.toBeNull() - const label = document.querySelector('label[for="pref-autoSave"]') + const previewToggle = document.getElementById('pref-hoverPreview') + expect(previewToggle).not.toBeNull() + const label = document.querySelector('label[for="pref-hoverPreview"]') expect(label).not.toBeNull() - expect(label!.textContent).toContain('Auto-save') + expect(label!.textContent).toContain('Preview suggestions on hover') }) }) diff --git a/src/__tests__/settings/settingsSections.test.tsx b/src/__tests__/settings/settingsSections.test.tsx index 450b9e0db..4e6b027a4 100644 --- a/src/__tests__/settings/settingsSections.test.tsx +++ b/src/__tests__/settings/settingsSections.test.tsx @@ -54,7 +54,7 @@ describe('PreferencesSection — catalog-driven rendering', () => { render() // Boolean preferences currently declared in `admin/pages/site/preferences/catalog.ts`: - // autoSave, hoverPreview, confirmBeforeDelete, + // hoverPreview, confirmBeforeDelete, // layersShowIcon, layersShowTag, layersShowClasses, // layersAutoExpandSelected, layersSmoothScroll, // dimInactiveBreakpoints, propertiesSmoothScroll, @@ -62,8 +62,7 @@ describe('PreferencesSection — catalog-driven rendering', () => { // spotlightTelemetryEnabled ← Phase 6: opt-in command-usage telemetry // Adding/removing a boolean preference is one catalog edit and this // assertion updates with it. - expect(screen.getAllByRole('switch')).toHaveLength(12) - expect(screen.getByRole('switch', { name: /auto-save/i })).toBeDefined() + expect(screen.getAllByRole('switch')).toHaveLength(11) expect(screen.getByRole('switch', { name: /preview suggestions on hover/i })).toBeDefined() expect(screen.getByRole('switch', { name: /confirm before deleting/i })).toBeDefined() expect(screen.getByRole('switch', { name: /show module icon/i })).toBeDefined() @@ -81,10 +80,10 @@ describe('PreferencesSection — catalog-driven rendering', () => { it('auto-renders one combobox per select catalog entry', () => { render() - // Select preferences: autoSaveDelay, theme, density, textScale, defaultBreakpoint + // Select preferences: theme, density, textScale, defaultBreakpoint + // (auto-save delay is gone — the collab relay persists continuously) const selects = screen.getAllByRole('combobox') - expect(selects.length).toBe(5) - expect(screen.getByRole('combobox', { name: /auto-save delay/i })).toBeDefined() + expect(selects.length).toBe(4) expect(screen.getByRole('combobox', { name: /theme/i })).toBeDefined() expect(screen.getByRole('combobox', { name: /ui density/i })).toBeDefined() expect(screen.getByRole('combobox', { name: /ui text size/i })).toBeDefined() 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 cc78cd312..ae104b7c8 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -337,19 +337,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', () => { @@ -362,13 +360,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')") }) @@ -553,6 +554,17 @@ describe('Toolbar — structural requirements', () => { expect(src).toContain('useEffect') }) + it('PublishButton reflects collaboration sync and has no manual save action', () => { + const { readFileSync } = require('fs') + const src = readFileSync( + new URL('../../admin/pages/site/toolbar/PublishButton.tsx', import.meta.url), + 'utf-8', + ) + expect(src).toContain('Draft synced') + expect(src).toContain('Offline — reconnecting') + expect(src).toContain('publishDisabled={disabled || state === \'published\'}') + expect(src).not.toContain('Save draft') + }) it('PublishActionGroup keeps the status pill and delegates its split control to the shared SplitButton', () => { const { readFileSync } = require('fs') const src = readFileSync( diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 381f862ac..75dcb0bdd 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. @@ -40,6 +40,7 @@ import { Toolbar } from '@admin/pages/site/toolbar/Toolbar' import { ZoomControls } from '@admin/pages/site/toolbar/ZoomControls' import { PublishButton } from '@admin/pages/site/toolbar/PublishButton' +import { PeerAvatarStack } from '@admin/pages/site/toolbar/PeerAvatarStack' import { useEditorAppearancePreferences } from '@admin/pages/site/preferences/editorPreferences' import { usePersistence } from '@admin/pages/site/hooks/usePersistence' import { useSiteEditorUrlSync } from '@admin/pages/site/hooks/useSiteEditorUrlSync' @@ -159,11 +160,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. @@ -193,10 +192,6 @@ export function AdminCanvasLayout() { : null const loadEditorBody = usePostPaintEditorBodyGate() - async function saveBeforeWorkspaceNavigation(): Promise { - if (!useEditorStore.getState().hasUnsavedChanges) return - await persistence.saveSite() - } return ( @@ -217,11 +212,7 @@ export function AdminCanvasLayout() { faviconUrl={faviconUrl} section="site" adminNavigationSlot={( - + )} overlay={previewOpen && ( @@ -230,12 +221,9 @@ export function AdminCanvasLayout() { )} rightSlot={( <> + - + )} /> diff --git a/src/admin/modals/Settings/useSiteSettingsController.ts b/src/admin/modals/Settings/useSiteSettingsController.ts index f440d8902..c5d7c8113 100644 --- a/src/admin/modals/Settings/useSiteSettingsController.ts +++ b/src/admin/modals/Settings/useSiteSettingsController.ts @@ -39,6 +39,7 @@ import { useEffect } from 'react' import { create } from 'zustand' import { cmsAdapter } from '@core/persistence/cms' +import { SaveConflictError } from '@core/persistence/saveConflict' import { DEFAULT_FRAMEWORK_PREFERENCES } from '@core/framework' import type { SiteDocument, SiteSettings } from '@core/page-tree' import type { FrameworkPreferencesSettings } from '@core/framework-schema' @@ -68,6 +69,11 @@ const SHELL_ONLY_DIRTY = { interface SettingsDraftState { /** The persisted document loaded for standalone (non-editor) editing. */ doc: SiteDocument | null + /** + * The shell seq this store last synchronized with — the conflict-detection + * base its shell-only saves ship (and bump from each save response). + */ + shellBaseSeq: number status: 'idle' | 'loading' | 'ready' | 'error' error: string | null /** Load the document once (idempotent; shares a single in-flight fetch). */ @@ -91,8 +97,16 @@ const useSettingsDraftStore = create((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..6a4625f05 100644 --- a/src/admin/modals/SiteImport/shared/importPlanning.ts +++ b/src/admin/modals/SiteImport/shared/importPlanning.ts @@ -174,10 +174,10 @@ 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) + return loaded.site } return useEditorStore.getState().createSite('My Site') @@ -187,7 +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 — 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) - 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/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/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, + avatarUrl: currentUser.avatarUrl, + gravatarHash: currentUser.gravatarHash, + } + : null, + ) + // Store subscriptions const canvasPage = useEditorStore(selectActiveCanvasPage) const breakpoints = useEditorStore((s) => s.site?.breakpoints ?? EMPTY_BREAKPOINTS) diff --git a/src/admin/pages/site/canvas/NodeRenderer.tsx b/src/admin/pages/site/canvas/NodeRenderer.tsx index 9cc5fcd16..2a373d49f 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,30 @@ 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, + onInvalidated: () => useEditorStore.getState().endInlineEdit(), + }) + }, [isInlineEditing, inlineEditInitialValue, nodeId]) const inlineStyle = useResponsiveBackgroundStyle(node?.inlineStyles) 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..9469a3665 --- /dev/null +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.module.css @@ -0,0 +1,132 @@ +/* + * 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 { + position: absolute; + inset: 0; + pointer-events: none; + 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); +} + +/* 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-3xs) var(--space-2xs); + border-radius: var(--peer-tag-corner) var(--peer-tag-corner) 0 0; + background: var(--peer-color); + color: var(--text); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1; + white-space: nowrap; + width: max-content; +} + +.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) + ); +} + +/* 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. + * 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 + * so the TIP lands on the published pointer coordinate. */ +.pointerIcon { + display: block; + 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); +} + +/* 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: 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 new file mode 100644 index 000000000..942dc93c2 --- /dev/null +++ b/src/admin/pages/site/canvas/PeerPresenceOverlay.tsx @@ -0,0 +1,406 @@ +/** + * 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 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). + */ +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, + usePeerPresences, + type PeerPresence, +} from '@site/collab/awarenessState' +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' +import { createCanvasOverlayMeasureSession } from './canvasOverlayGeometry' +import { hideOverlayElement, positionOverlayElement } from './canvasSelectionOverlayPositioning' +import styles from './PeerPresenceOverlay.module.css' + +// 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 + +// 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 + * 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 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 } +} + +/** 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 +} + +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 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) + * 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) + + useEffect(() => { + const frame = requestAnimationFrame(() => { + const root = viewportActions?.canvasRootRef.current ?? null + setPortalCanvasRoot((current) => (current === root ? current : root)) + }) + return () => cancelAnimationFrame(frame) + }, [viewportActions]) + + // 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) => { + 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) + // 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 + } + + 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 + + // 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 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 + const pointerElement = pointerRefs.current?.get(peer.clientId) ?? null + const previous = animated.get(peer.clientId) + if (pointer) { + const target = { + x: iframeRect.left + pointer.x * iframeScale - originLeft, + y: iframeRect.top + pointer.y * iframeScale - originTop, + } + // 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. + 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, { ...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' + } + + // ── 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) + + for (const { element, rect } of ringPlacements) positionOverlayElement(element, rect) + for (const { element, point, lift } of pointPlacements) positionPointElement(element, point, lift) + }) + + // 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 + + 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 = ( +