From b3d2e4b9fd3f7284c1b280143255b36d38723f76 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 00:32:29 -0400 Subject: [PATCH 1/6] fix(editor): stop read-only sessions from wiping scenes with an empty autosave PUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 2026-08-16..18 scene-wipe class (a4993ec9f1ab, 1befee38f973, reproduced live): useAutoSave's store subscription attaches before the Editor's scene-load effect (hook order), so useHostPanels' mount-time default-installedPlugins sync marks the session dirty with zero user edits while the store still holds the empty pre-hydration state. The load effect then runs unloadScene(), whose transient 0-node write re-baselines the wipe guard to 0 via trackLoadedGraph. Any effect cleanup in that window (StrictMode simulated unmount in dev, tab close or navigation in prod) runs flushOnExit, which checked only the dirty flag — it serialized the empty store and PUT it with If-Match: 1, leaving v2 with 0 nodes. Defense in depth, all three layers: - use-auto-save: isLoadingSceneRef now starts true (autosave arms only after the first hydration completes), and the exit flush is decided by the pure decideExitFlush(), which skips any flush while a load is in flight — the store content in that window is transient, not user data. - scene-loader: tracks the server's known node count (initial meta, PUT responses, SSE events) and refuses to PUT a 0-node graph over a populated server copy, with a console.error; 409 empty_graph_rejected responses surface as a save error instead of the conflict banner. - PUT /api/scenes/[id]: rejects a 0-node graph aimed at a scene that has nodes with 409 empty_graph_rejected unless the caller passes force: true. A silent wipe is unrecoverable in place; an intentional full deletion is rare and still available via force (and every version stays in scene_revisions). Gates: decideExitFlush matrix incl. the exact traced wipe sequence, empty-graph-guard unit tests, and a route-level integration test running the real PUT handler against a temp SQLite store (409 body, store untouched after rejection, force path, empty-over-empty allowed). Co-Authored-By: Claude Fable 5 --- apps/editor/app/api/scenes/[id]/route.ts | 23 +++ apps/editor/components/scene-loader.tsx | 30 ++++ apps/editor/lib/empty-graph-guard.test.ts | 34 +++++ apps/editor/lib/empty-graph-guard.ts | 26 ++++ .../editor/lib/scenes-put-empty-guard.test.ts | 135 ++++++++++++++++++ .../editor/src/hooks/use-auto-save.test.ts | 74 +++++++++- packages/editor/src/hooks/use-auto-save.ts | 55 ++++++- 7 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 apps/editor/lib/empty-graph-guard.test.ts create mode 100644 apps/editor/lib/empty-graph-guard.ts create mode 100644 apps/editor/lib/scenes-put-empty-guard.test.ts diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 1712ad4ad4..86423c725a 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { apiGraphSchema } from '@/lib/graph-schema' import { guardSceneApiRequest, @@ -18,6 +19,13 @@ const putSceneSchema = z.object({ graph: apiGraphSchema, thumbnailUrl: z.string().url().nullable().optional(), expectedVersion: z.number().int().nonnegative().optional(), + /** + * Overwriting a populated scene with a 0-node graph is rejected (409 + * `empty_graph_rejected`) unless this is set: an empty PUT is a hydration + * race or a bug far more often than an intentional full deletion, and the + * wipe is silent while the deletion is recoverable from scene_revisions. + */ + force: z.boolean().optional(), }) const patchSceneSchema = z.object({ @@ -83,6 +91,21 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { if (!existing) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } + if ( + !parsed.data.force && + isEmptyGraphOverwrite(countGraphNodes(parsed.data.graph), existing.nodeCount) + ) { + return sceneApiJson( + request, + { + error: 'empty_graph_rejected', + details: `Refusing to overwrite ${existing.nodeCount} nodes with an empty graph. Pass "force": true to overwrite intentionally.`, + currentVersion: existing.version, + currentNodeCount: existing.nodeCount, + }, + { status: 409 }, + ) + } const meta = await operations.saveScene({ id, name: parsed.data.name ?? existing.name, diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index b1d0b503d2..00fd99bd04 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -14,6 +14,7 @@ import Image from 'next/image' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' +import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' import { cn } from '@/lib/utils' import { BuildTab } from './build-tab' @@ -95,6 +96,10 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const router = useRouter() const searchParams = useSearchParams() const versionRef = useRef(meta.version) + // Node count of the graph the server is known to hold. Guards against the + // autosave wipe class: a save fired from a not-yet-hydrated (empty) editor + // store must never overwrite a populated server copy. + const serverNodeCountRef = useRef(meta.nodeCount) const lastRemoteGraphJsonRef = useRef(null) const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) @@ -115,6 +120,19 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { } if (isRecentRemoteApply) return + // Wipe guard: never PUT an empty graph over a populated server copy. + // An empty serialization here means the editor store was not hydrated + // (load in flight or failed), not that the user deleted everything. + const outgoingNodeCount = countGraphNodes(graph) + if (isEmptyGraphOverwrite(outgoingNodeCount, serverNodeCountRef.current)) { + console.error( + `[scene-loader] Blocked autosave: refusing to overwrite scene ${meta.id} ` + + `(${serverNodeCountRef.current} nodes on the server) with an empty graph.`, + ) + setSaveError('Autosave blocked: the editor tried to save an empty scene') + return + } + try { const response = await fetch(`/api/scenes/${meta.id}`, { method: 'PUT', @@ -131,6 +149,16 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { }) if (response.status === 409) { + const body = (await response.json().catch(() => null)) as { error?: string } | null + if (body?.error === 'empty_graph_rejected') { + // Server-side wipe guard (defense in depth behind the client-side + // check above) — not a concurrent-session conflict. + console.error( + `[scene-loader] Server rejected an empty-graph save for scene ${meta.id}.`, + ) + setSaveError('Autosave blocked: the editor tried to save an empty scene') + return + } setConflict(true) return } @@ -142,6 +170,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const next = (await response.json()) as SceneMeta versionRef.current = next.version + serverNodeCountRef.current = next.nodeCount setSaveError(null) } catch (error) { setSaveError(error instanceof Error ? error.message : 'Save failed') @@ -164,6 +193,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { if (payload.version <= versionRef.current) return versionRef.current = payload.version + serverNodeCountRef.current = countGraphNodes(payload.graph) lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph) suppressRemoteSaveUntilRef.current = Date.now() + 2500 applySceneGraphToEditor(payload.graph) diff --git a/apps/editor/lib/empty-graph-guard.test.ts b/apps/editor/lib/empty-graph-guard.test.ts new file mode 100644 index 0000000000..15664c7bf4 --- /dev/null +++ b/apps/editor/lib/empty-graph-guard.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' +import { countGraphNodes, isEmptyGraphOverwrite } from './empty-graph-guard' + +describe('countGraphNodes', () => { + test('counts nodes on a well-formed graph', () => { + expect(countGraphNodes({ nodes: { a: {}, b: {} } })).toBe(2) + }) + + test('treats missing/odd shapes as empty', () => { + expect(countGraphNodes(null)).toBe(0) + expect(countGraphNodes(undefined)).toBe(0) + expect(countGraphNodes({})).toBe(0) + expect(countGraphNodes({ nodes: null })).toBe(0) + }) +}) + +describe('isEmptyGraphOverwrite', () => { + test('blocks a 0-node write over a populated server copy (the wipe class)', () => { + // Scene-wipe repro 2026-08-18: a pre-hydration autosave flush serialized + // the empty editor store and PUT it over a 74-node scene at If-Match: 1, + // leaving v2 with 0 nodes. This is the exact write that must not pass. + expect(isEmptyGraphOverwrite(0, 74)).toBe(true) + expect(isEmptyGraphOverwrite(0, 1)).toBe(true) + }) + + test('allows saves that carry nodes', () => { + expect(isEmptyGraphOverwrite(74, 74)).toBe(false) + expect(isEmptyGraphOverwrite(1, 74)).toBe(false) + }) + + test('allows empty saves over an already-empty scene', () => { + expect(isEmptyGraphOverwrite(0, 0)).toBe(false) + }) +}) diff --git a/apps/editor/lib/empty-graph-guard.ts b/apps/editor/lib/empty-graph-guard.ts new file mode 100644 index 0000000000..af955630c6 --- /dev/null +++ b/apps/editor/lib/empty-graph-guard.ts @@ -0,0 +1,26 @@ +/** + * Guard shared by the scene-save client path and the scenes API PUT route: + * an incoming graph with ZERO nodes must never silently replace a server copy + * that has nodes. + * + * Rationale (scene-wipe class, 2026-08-16..18): an editor session whose store + * has not hydrated yet (load in flight, failed GET, pre-hydration flush) can + * serialize an empty graph. Persisting it destroys the scene at the next + * version. Losing a save of a legitimately-emptied scene is far rarer and is + * recoverable (scene_revisions keeps every version), so the trade is blocking + * empty overwrites by default and requiring an explicit `force` to allow them. + */ + +export function countGraphNodes( + graph: { nodes?: Record | null } | null | undefined, +): number { + if (!graph?.nodes || typeof graph.nodes !== 'object') return 0 + return Object.keys(graph.nodes).length +} + +export function isEmptyGraphOverwrite( + incomingNodeCount: number, + knownServerNodeCount: number, +): boolean { + return incomingNodeCount === 0 && knownServerNodeCount > 0 +} diff --git a/apps/editor/lib/scenes-put-empty-guard.test.ts b/apps/editor/lib/scenes-put-empty-guard.test.ts new file mode 100644 index 0000000000..3be9edbddd --- /dev/null +++ b/apps/editor/lib/scenes-put-empty-guard.test.ts @@ -0,0 +1,135 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { NextRequest } from 'next/server' + +/** + * Integration gate for the scene-wipe class: `PUT /api/scenes/[id]` must + * reject (409 `empty_graph_rejected`) a 0-node graph aimed at a scene that has + * nodes, unless the caller passes `force: true`. Runs the real route handler + * against a real SQLite store in a temp directory. + */ + +const tempDir = mkdtempSync(join(tmpdir(), 'scenes-put-guard-')) +const SCENE_ID = 'wipe-guard-scene' + +// A minimal graph that passes `apiGraphSchema`: a foreign-typed node is held +// to the BaseNode envelope only, so it stays independent of builtin schemas. +const POPULATED_GRAPH = { + nodes: { + n1: { id: 'n1', type: 'qa:box' }, + n2: { id: 'n2', type: 'qa:box' }, + }, + rootNodeIds: ['n1'], +} +const EMPTY_GRAPH = { nodes: {}, rootNodeIds: [] } + +let PUT: typeof import('../app/api/scenes/[id]/route')['PUT'] +let restoreEnv: () => void + +beforeAll(async () => { + const saved = { + PASCAL_DB_PATH: process.env.PASCAL_DB_PATH, + PASCAL_SCENE_API_TOKEN: process.env.PASCAL_SCENE_API_TOKEN, + } + restoreEnv = () => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + process.env.PASCAL_DB_PATH = join(tempDir, 'pascal.db') + delete process.env.PASCAL_SCENE_API_TOKEN // loopback requests need no token + + const storeServer = await import('./scene-store-server') + storeServer.__resetSceneStoreForTests() + + const operations = await storeServer.getSceneOperations() + await operations.saveScene({ + id: SCENE_ID, + name: 'Wipe guard fixture', + projectId: null, + graph: POPULATED_GRAPH as never, + }) + + const route = await import('../app/api/scenes/[id]/route') + PUT = route.PUT +}) + +afterAll(async () => { + const storeServer = await import('./scene-store-server') + const store = await storeServer.getSceneStore() + ;(store as { close?: () => void }).close?.() + storeServer.__resetSceneStoreForTests() + restoreEnv() + rmSync(tempDir, { recursive: true, force: true }) +}) + +function putRequest(body: unknown, ifMatch?: number): NextRequest { + return new NextRequest(`http://127.0.0.1:3000/api/scenes/${SCENE_ID}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + host: '127.0.0.1:3000', + ...(ifMatch === undefined ? {} : { 'If-Match': `"${ifMatch}"` }), + }, + body: JSON.stringify(body), + }) +} + +const params = { params: Promise.resolve({ id: SCENE_ID }) } + +test('rejects an empty graph over a populated scene with 409 empty_graph_rejected', async () => { + const response = await PUT(putRequest({ graph: EMPTY_GRAPH }, 1), params) + + expect(response.status).toBe(409) + const body = (await response.json()) as { + error: string + currentVersion: number + currentNodeCount: number + } + expect(body.error).toBe('empty_graph_rejected') + expect(body.currentVersion).toBe(1) + expect(body.currentNodeCount).toBe(2) +}) + +test('the rejected PUT leaves the stored scene untouched', async () => { + const storeServer = await import('./scene-store-server') + const operations = await storeServer.getSceneOperations() + const scene = await operations.loadStoredScene(SCENE_ID) + + expect(scene?.version).toBe(1) + expect(Object.keys(scene?.graph.nodes ?? {})).toHaveLength(2) +}) + +test('a populated save still goes through', async () => { + const graph = { + nodes: { ...POPULATED_GRAPH.nodes, n3: { id: 'n3', type: 'qa:box' } }, + rootNodeIds: ['n1'], + } + const response = await PUT(putRequest({ graph }, 1), params) + + expect(response.status).toBe(200) + const meta = (await response.json()) as { version: number; nodeCount: number } + expect(meta.version).toBe(2) + expect(meta.nodeCount).toBe(3) +}) + +test('force: true allows an intentional wipe', async () => { + const response = await PUT(putRequest({ graph: EMPTY_GRAPH, force: true }, 2), params) + + expect(response.status).toBe(200) + const meta = (await response.json()) as { version: number; nodeCount: number } + expect(meta.version).toBe(3) + expect(meta.nodeCount).toBe(0) +}) + +test('an empty save over an already-empty scene needs no force', async () => { + const response = await PUT(putRequest({ graph: EMPTY_GRAPH }, 3), params) + + expect(response.status).toBe(200) + const meta = (await response.json()) as { version: number; nodeCount: number } + expect(meta.version).toBe(4) + expect(meta.nodeCount).toBe(0) +}) diff --git a/packages/editor/src/hooks/use-auto-save.test.ts b/packages/editor/src/hooks/use-auto-save.test.ts index 593f6d1d56..aaa514bef6 100644 --- a/packages/editor/src/hooks/use-auto-save.test.ts +++ b/packages/editor/src/hooks/use-auto-save.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { createStoredNodeCountTracker, isSuspiciousNodeDrop } from './use-auto-save' +import { + createStoredNodeCountTracker, + decideExitFlush, + isSuspiciousNodeDrop, +} from './use-auto-save' describe('isSuspiciousNodeDrop', () => { test('blocks populated scenes from being flushed as empty skeletons', () => { @@ -61,3 +65,71 @@ describe('createStoredNodeCountTracker', () => { expect(tracker.allowWrite(3)).toBe(true) }) }) + +describe('decideExitFlush', () => { + test('reproduces the 2026-08-16 scene-wipe sequence and skips the flush', () => { + // The exact traced wipe (dev repro, scenes a4993ec9f1ab/1befee38f973 and + // the live sessions of 2026-08-18): + // 1. useAutoSave subscribes; store = initial empty state. + // 2. useHostPanels' mount effect writes default installedPlugins — a + // scene-store change BEFORE the Editor's load effect runs, so the + // session is marked dirty with zero user edits. + // 3. The load effect sets loading=true and calls unloadScene(); the + // tracker re-baselines to the transient 0-node state. + // 4. StrictMode's simulated unmount (prod: tab close / navigation) + // runs the effect cleanup -> flushOnExit with an EMPTY store. + // The flush must be skipped: the store content is transient, not data. + expect( + decideExitFlush({ + isLoadingScene: true, + hasDirtyChanges: true, + storedNodeCount: 0, + currentNodeCount: 0, + }), + ).toBe('skip-loading') + }) + + test('never flushes while a load is in flight, whatever the counts say', () => { + expect( + decideExitFlush({ + isLoadingScene: true, + hasDirtyChanges: true, + storedNodeCount: 74, + currentNodeCount: 74, + }), + ).toBe('skip-loading') + }) + + test('does nothing when there are no dirty changes', () => { + expect( + decideExitFlush({ + isLoadingScene: false, + hasDirtyChanges: false, + storedNodeCount: 74, + currentNodeCount: 0, + }), + ).toBe('skip-clean') + }) + + test('blocks a populated-to-scaffold drop after hydration', () => { + expect( + decideExitFlush({ + isLoadingScene: false, + hasDirtyChanges: true, + storedNodeCount: 74, + currentNodeCount: 0, + }), + ).toBe('blocked-suspicious') + }) + + test('flushes ordinary dirty sessions on exit', () => { + expect( + decideExitFlush({ + isLoadingScene: false, + hasDirtyChanges: true, + storedNodeCount: 74, + currentNodeCount: 75, + }), + ).toBe('flush') + }) +}) diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 8a7909ead1..a64e6a047d 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -45,6 +45,35 @@ export function createStoredNodeCountTracker(initialNodeCount: number) { } } +export type ExitFlushDecision = 'skip-clean' | 'skip-loading' | 'blocked-suspicious' | 'flush' + +/** + * Decides what the unload/unmount flush may do with the store's current + * content. Pure so the wipe scenarios stay unit-testable. + * + * `skip-loading` is the load-bearing branch: while a scene load is in flight + * the store passes through an intermediate `unloadScene()` state — zero nodes, + * zero roots — that is NOT user data. A flush fired in that window (StrictMode + * simulated unmount in dev, a quick tab close or navigation in prod) used to + * serialize that empty store and PUT it over the server copy, wiping the scene + * at v2. The dirty flag alone cannot protect here: document-level writes that + * land before hydration (e.g. the host-panel default `installedPlugins` sync) + * mark the session dirty without any user edit. + */ +export function decideExitFlush(opts: { + isLoadingScene: boolean + hasDirtyChanges: boolean + storedNodeCount: number + currentNodeCount: number +}): ExitFlushDecision { + if (!opts.hasDirtyChanges) return 'skip-clean' + if (opts.isLoadingScene) return 'skip-loading' + if (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) { + return 'blocked-suspicious' + } + return 'flush' +} + export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error' interface UseAutoSaveOptions { @@ -68,7 +97,13 @@ export function useAutoSave({ }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) - const isLoadingSceneRef = useRef(false) + // Starts TRUE: the scene is "loading" from mount until the Editor's load + // effect completes its first hydration. The Editor's load effect runs + // several hooks AFTER this one (hook order), so store writes in that gap — + // e.g. `useHostPanels` syncing default `installedPlugins` on mount — must + // not mark the session dirty or arm a save: the store still holds the empty + // pre-hydration state, and flushing it wipes the scene server-side. + const isLoadingSceneRef = useRef(true) const pendingSaveRef = useRef(false) const executeSaveRef = useRef<(() => Promise) | null>(null) const hasDirtyChangesRef = useRef(false) @@ -220,17 +255,31 @@ export function useAutoSave({ // would otherwise drop the change entirely. `pagehide` fires in cases // (mobile Safari, bfcache) where `beforeunload` does not. function flushOnExit() { - if (!hasDirtyChangesRef.current) return const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() const currentNodeCount = Object.keys(nodes).length const previousNodeCount = storedNodeCount.count - if (!storedNodeCount.allowWrite(currentNodeCount)) { + const decision = decideExitFlush({ + isLoadingScene: isLoadingSceneRef.current, + hasDirtyChanges: hasDirtyChangesRef.current, + storedNodeCount: previousNodeCount, + currentNodeCount, + }) + if (decision === 'skip-clean') return + if (decision === 'skip-loading') { + console.warn( + '[autosave] Skipped unload flush: a scene load is in flight, the store content is transient. Nothing user-authored is lost.', + ) + return + } + if (decision === 'blocked-suspicious') { console.warn( `[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`, ) setSaveStatus('error') return } + // 'flush' — adopt the write as the new stored baseline. + storedNodeCount.allowWrite(currentNodeCount) hasDirtyChangesRef.current = false const sceneGraph = { From 735236d5776a0b76583696b532d9b2568ec568f3 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:22:20 -0400 Subject: [PATCH 2/6] test: seed the wipe-guard fixture through the raw store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI resolved '@pascal-app/mcp/operations' to a build without saveScene (turbo-cached dist) — the store's save() is the stable primitive the operations layer delegates to anyway. Co-Authored-By: Claude Fable 5 --- apps/editor/lib/scenes-put-empty-guard.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/editor/lib/scenes-put-empty-guard.test.ts b/apps/editor/lib/scenes-put-empty-guard.test.ts index 3be9edbddd..ead3e0b527 100644 --- a/apps/editor/lib/scenes-put-empty-guard.test.ts +++ b/apps/editor/lib/scenes-put-empty-guard.test.ts @@ -45,8 +45,12 @@ beforeAll(async () => { const storeServer = await import('./scene-store-server') storeServer.__resetSceneStoreForTests() - const operations = await storeServer.getSceneOperations() - await operations.saveScene({ + // Seed through the raw STORE, not SceneOperations: CI's module graph can + // resolve '@pascal-app/mcp/operations' to a build predating saveScene + // (turbo cache), while the store's save() is the stable primitive the + // operations layer itself delegates to. + const store = await storeServer.getSceneStore() + await store.save({ id: SCENE_ID, name: 'Wipe guard fixture', projectId: null, From 7f69626c6521e627d31eddcd7fa93a3dc1a98910 Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:29:13 -0400 Subject: [PATCH 3/6] test: guard file must sort before scene-store-server's module mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun's mock.module leaks process-wide to later files in the worker — scene-store-server.test.ts stubs '@pascal-app/mcp/operations', which starved the PUT-guard fixture of saveScene/loadStoredScene in CI (single worker). Renamed to sort first + hazard comment. Co-Authored-By: Claude Fable 5 --- ...es-put-empty-guard.test.ts => api-put-empty-guard.test.ts} | 4 ++++ 1 file changed, 4 insertions(+) rename apps/editor/lib/{scenes-put-empty-guard.test.ts => api-put-empty-guard.test.ts} (94%) diff --git a/apps/editor/lib/scenes-put-empty-guard.test.ts b/apps/editor/lib/api-put-empty-guard.test.ts similarity index 94% rename from apps/editor/lib/scenes-put-empty-guard.test.ts rename to apps/editor/lib/api-put-empty-guard.test.ts index ead3e0b527..94dc4f5167 100644 --- a/apps/editor/lib/scenes-put-empty-guard.test.ts +++ b/apps/editor/lib/api-put-empty-guard.test.ts @@ -23,6 +23,10 @@ const POPULATED_GRAPH = { }, rootNodeIds: ['n1'], } +// FILE NAME MATTERS: scene-store-server.test.ts calls mock.module() on +// '@pascal-app/mcp/operations', and bun module mocks leak process-wide to +// every LATER test file in the same worker — this file must sort BEFORE it +// alphabetically to see the real module (CI runs single-worker). const EMPTY_GRAPH = { nodes: {}, rootNodeIds: [] } let PUT: typeof import('../app/api/scenes/[id]/route')['PUT'] From edc21ba6e373d41dcf50551f10997d5536c3360e Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:35:35 -0400 Subject: [PATCH 4/6] test: restore the real mcp modules after scene-store-server's mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun's mock.module poisons the registry for every later file in the process — downstream route tests saw a stub facade without saveScene/loadStoredScene in CI. Capture + restore in afterAll. Co-Authored-By: Claude Fable 5 --- apps/editor/lib/scene-store-server.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/editor/lib/scene-store-server.test.ts b/apps/editor/lib/scene-store-server.test.ts index cf0d28ef11..ab87e4e756 100644 --- a/apps/editor/lib/scene-store-server.test.ts +++ b/apps/editor/lib/scene-store-server.test.ts @@ -1,4 +1,15 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' + +// bun's mock.module poisons the module registry for EVERY test file that +// runs after this one in the same process — capture the real modules and +// restore them when this file finishes, or route tests downstream get a +// stub facade without saveScene/loadStoredScene (night-5 CI failure). +const realOperations = await import('@pascal-app/mcp/operations') +const realStorage = await import('@pascal-app/mcp/storage') +afterAll(() => { + mock.module('@pascal-app/mcp/operations', () => realOperations) + mock.module('@pascal-app/mcp/storage', () => realStorage) +}) describe('getSceneStore', () => { beforeEach(() => { From 345c0b783086593235675b44801f1230849dfc3b Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:42:54 -0400 Subject: [PATCH 5/6] test: inject real store/operations via relative source imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other test files' mock.module stubs on '@pascal-app/mcp/*' stick for later dynamic imports on linux — three CI runs starved the route fixture of saveScene/loadStoredScene while macOS passed. The fixture now builds a real SqliteSceneStore + facade from relative source paths (immune to subpath mocks) and injects them via a test-only setter. Co-Authored-By: Claude Fable 5 --- apps/editor/lib/api-put-empty-guard.test.ts | 21 +++++++++++++++------ apps/editor/lib/scene-store-server.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/editor/lib/api-put-empty-guard.test.ts b/apps/editor/lib/api-put-empty-guard.test.ts index 94dc4f5167..eccdf65492 100644 --- a/apps/editor/lib/api-put-empty-guard.test.ts +++ b/apps/editor/lib/api-put-empty-guard.test.ts @@ -49,11 +49,20 @@ beforeAll(async () => { const storeServer = await import('./scene-store-server') storeServer.__resetSceneStoreForTests() - // Seed through the raw STORE, not SceneOperations: CI's module graph can - // resolve '@pascal-app/mcp/operations' to a build predating saveScene - // (turbo cache), while the store's save() is the stable primitive the - // operations layer itself delegates to. - const store = await storeServer.getSceneStore() + // Build REAL store+operations from relative SOURCE imports and inject + // them: '@pascal-app/mcp/*' subpaths may be mock.module'd by other test + // files in the same process (the stubs stick for later dynamic imports + // on linux), which starved this fixture of saveScene/loadStoredScene in + // CI three runs straight. + const { SqliteSceneStore } = await import( + '../../../packages/mcp/src/storage/sqlite-scene-store' + ) + const { createSceneOperations } = await import( + '../../../packages/mcp/src/operations/scene-operations' + ) + const store = new SqliteSceneStore({ env: process.env }) + const operations = createSceneOperations({ store }) + storeServer.__setSceneStoreForTests(store, operations) await store.save({ id: SCENE_ID, name: 'Wipe guard fixture', @@ -68,7 +77,7 @@ beforeAll(async () => { afterAll(async () => { const storeServer = await import('./scene-store-server') const store = await storeServer.getSceneStore() - ;(store as { close?: () => void }).close?.() + ;(store as unknown as { close?: () => void }).close?.() storeServer.__resetSceneStoreForTests() restoreEnv() rmSync(tempDir, { recursive: true, force: true }) diff --git a/apps/editor/lib/scene-store-server.ts b/apps/editor/lib/scene-store-server.ts index 796381f097..ca0c6fda19 100644 --- a/apps/editor/lib/scene-store-server.ts +++ b/apps/editor/lib/scene-store-server.ts @@ -42,3 +42,14 @@ export function __resetSceneStoreForTests(): void { cachedStore = null cachedOperations = null } + +/** + * Test-only injection: other test files in the same bun process may have + * mock.module'd the '@pascal-app/mcp/*' subpaths (the mocks stick for + * later dynamic imports on some platforms), so route tests inject REAL + * instances built from relative source imports instead. + */ +export function __setSceneStoreForTests(store: SceneStore, operations: SceneOperations): void { + cachedStore = Promise.resolve(store) + cachedOperations = Promise.resolve(operations) +} From d2200a8fbfabb0787bea073f37e9bba771bd94ca Mon Sep 17 00:00:00 2001 From: Julien Brissonneau Date: Wed, 19 Aug 2026 01:48:33 -0400 Subject: [PATCH 6/6] style: biome format Co-Authored-By: Claude Fable 5 --- apps/editor/lib/api-put-empty-guard.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/editor/lib/api-put-empty-guard.test.ts b/apps/editor/lib/api-put-empty-guard.test.ts index eccdf65492..aee2ee8e82 100644 --- a/apps/editor/lib/api-put-empty-guard.test.ts +++ b/apps/editor/lib/api-put-empty-guard.test.ts @@ -54,9 +54,7 @@ beforeAll(async () => { // files in the same process (the stubs stick for later dynamic imports // on linux), which starved this fixture of saveScene/loadStoredScene in // CI three runs straight. - const { SqliteSceneStore } = await import( - '../../../packages/mcp/src/storage/sqlite-scene-store' - ) + const { SqliteSceneStore } = await import('../../../packages/mcp/src/storage/sqlite-scene-store') const { createSceneOperations } = await import( '../../../packages/mcp/src/operations/scene-operations' )