diff --git a/.gitignore b/.gitignore index 2c84470..5f7a0a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ dist/ .vite/ test-results/ playwright-report/ +e2e/.tmp/ .DS_Store *.log diff --git a/e2e/persistence-opfs.spec.ts b/e2e/persistence-opfs.spec.ts new file mode 100644 index 0000000..022ebbf --- /dev/null +++ b/e2e/persistence-opfs.spec.ts @@ -0,0 +1,139 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); + +/** + * TML-53 范围项(OPFS 适配器):浏览器级验证 OPFS 后端与 IndexedDB 行为一致。 + * + * 存储后端通过 ?storage=opfs 切换(嵌入式宿主透传),数据落 OPFS(真实 Chromium + * 实现):AC1 式「导出 → 清空 OPFS → 导入 → 刷新后最近项目恢复」全链路 + 直接 + * 断言数据位于 OPFS 目录(而非 IndexedDB),以及 AC2 式跨标签页冲突在 + * Web Locks 临界区下同样按 CAS 拒绝并显式解决。 + */ + +const OPFS_ROOT = 'lumora-studio'; + +async function clearOpfs(page: import('@playwright/test').Page): Promise { + await page.evaluate(async (rootName) => { + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(rootName, { recursive: true }); + } catch { + // 目录不存在(首次运行):视为已清空 + } + }, OPFS_ROOT); +} + +/** 直接读取 OPFS 中项目文件列表:证明数据真的落 OPFS 而非 IndexedDB */ +async function opfsProjectFiles(page: import('@playwright/test').Page): Promise { + return page.evaluate(async (rootName) => { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle(rootName); + const projects = await dir.getDirectoryHandle('projects'); + const names: string[] = []; + for await (const [name] of (projects as unknown as { entries(): AsyncIterableIterator<[string, unknown]> }).entries()) { + if (!name.startsWith('.')) names.push(name); + } + return names; + }, OPFS_ROOT); +} + +test('OPFS 后端 AC1:导出→清空→导入完整恢复,数据落 OPFS 且刷新后可重开', async ({ page }) => { + await page.goto('/?storage=opfs'); + await expect(page.getByTestId('studio-empty-hint')).toBeVisible(); + + // 1. 打开示例项目并追加两台摄像机(共 3 台镜头) + await page.getByTestId('open-sample-project').click(); + await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible(); + for (let i = 0; i < 2; i++) { + await page.getByTestId('add-object').click(); + await page.getByTestId('add-摄像机').click(); + } + await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3); + await expect(page.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + + // 数据已写入 OPFS 目录(真实文件系统校验,而非 IndexedDB) + expect(await opfsProjectFiles(page)).toHaveLength(1); + + // 2. 导出工程包并校验内容(含三镜头) + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('project-menu').click(); + await page.getByTestId('project-export').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('示例项目.lumora'); + const tmpDir = join(HERE, '.tmp'); + mkdirSync(tmpDir, { recursive: true }); + const exportPath = join(tmpDir, 'tml90-opfs-export.lumora'); + await download.saveAs(exportPath); + const pkg = JSON.parse(readFileSync(exportPath, 'utf8')) as { + project: { objects: Array<{ type: string }> }; + }; + expect(pkg.project.objects.filter((o) => o.type === 'camera')).toHaveLength(3); + + // 3. 清空本地数据:卸载 Studio → 删除 OPFS 根目录 → 重新挂载 + await page.getByTestId('project-menu').click(); // 收起菜单 + await page.getByTestId('studio-mount-toggle').click(); + await expect(page.getByTestId('studio-placeholder')).toBeVisible(); + await clearOpfs(page); + await page.getByTestId('studio-mount-toggle').click(); + await expect(page.getByTestId('open-sample-project')).toBeVisible(); + await page.getByTestId('project-menu').click(); + await expect(page.getByText('暂无本地项目')).toBeVisible(); + + // 4. 导入工程包:数据与引用完整恢复 + await page.setInputFiles('[data-testid="project-import-input"]', exportPath); + await expect(page.getByTestId('studio-empty-hint')).not.toBeVisible(); + await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3); + await expect(page.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + expect(await opfsProjectFiles(page)).toHaveLength(1); + + // 5. 已持久化(OPFS):刷新后可从最近项目重新打开 + await page.reload(); + await page.getByTestId('project-menu').click(); + await expect(page.getByTestId('recent-project')).toContainText('示例项目'); + await page.locator('[data-testid="recent-project"] .lumora-project-menu__recent-open').click(); + await expect(page.getByTestId('tree-row-sample-cube')).toBeVisible(); + await expect(page.locator('.lumora-tree-row__type--camera')).toHaveCount(3); +}); + +test('OPFS 后端 AC2:跨标签页冲突按 CAS 拒绝,显式「加载较新版本」解决', async ({ context }) => { + // 同一 context 的两个页面共享 OPFS 与 Web Locks:模拟两个标签页编辑同一项目 + const pageA = await context.newPage(); + await pageA.goto('/?storage=opfs'); + await pageA.getByTestId('open-sample-project').click(); + await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + + // A 新增一台摄像机并等待落盘(rev1) + await pageA.getByTestId('add-object').click(); + await pageA.getByTestId('add-摄像机').click(); + await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2); + await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + + // B 打开同 uri 示例项目:对账发现本地已存 rev1 ≠ 打开 rev0 → 立即冲突 + const pageB = await context.newPage(); + await pageB.goto('/?storage=opfs'); + await pageB.getByTestId('open-sample-project').click(); + await expect(pageB.getByTestId('save-state-badge')).toHaveText(/保存失败/, { timeout: 6000 }); + + // B 继续编辑使本地计数追平(rev1):仍冲突,绝不覆盖 A 的较新内容 + await pageB.getByTestId('add-object').click(); + await pageB.getByTestId('add-摄像机').click(); + await expect(pageB.getByTestId('save-state-badge')).toHaveText(/保存失败/, { timeout: 6000 }); + await expect(pageA.getByTestId('save-state-badge')).toHaveText('已保存'); + await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2); + + // B 显式解决「加载较新版本」:内容切换为 A 的已存内容,冲突解除 + await pageB.getByTestId('save-reload').click(); + await expect(pageB.locator('.lumora-tree-row__type--camera')).toHaveCount(2); + await expect(pageB.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + + // 解决后 B 可正常保存(基于已存内容追加,rev2) + await pageB.getByTestId('add-object').click(); + await pageB.getByTestId('add-摄像机').click(); + await expect(pageB.getByTestId('save-state-badge')).toHaveText('已保存', { timeout: 6000 }); + await expect(pageB.locator('.lumora-tree-row__type--camera')).toHaveCount(3); + await expect(pageA.locator('.lumora-tree-row__type--camera')).toHaveCount(2); +}); diff --git a/examples/embedded-host/src/App.tsx b/examples/embedded-host/src/App.tsx index 8a11dec..1484a27 100644 --- a/examples/embedded-host/src/App.tsx +++ b/examples/embedded-host/src/App.tsx @@ -66,6 +66,9 @@ const PLUGINS: PluginDescriptor[] = [mockPlugin, brokenManifestPlugin, brokenEng /** 默认只记录事件摘要;?debug=full 时输出完整 payload(大数据量下会产生 GB 级字符串,仅限调试) */ const DEBUG_FULL = new URLSearchParams(window.location.search).get('debug') === 'full'; +/** 本地存储后端选择:?storage=opfs 使用 OPFS,缺省 IndexedDB(持久化门面可切换,TML-53 范围项) */ +const STORAGE = new URLSearchParams(window.location.search).get('storage') === 'opfs' ? 'opfs' : 'indexeddb'; + export default function App() { const [mounted, setMounted] = useState(true); const [log, setLog] = useState([]); @@ -133,7 +136,7 @@ export default function App() {
{mounted ? ( - + ) : (
Studio 已卸载 —— WebGL 场景、插件贡献项与事件订阅均已释放 diff --git a/packages/studio/src/components/LumoraStudio.tsx b/packages/studio/src/components/LumoraStudio.tsx index 90b7318..5fdd334 100644 --- a/packages/studio/src/components/LumoraStudio.tsx +++ b/packages/studio/src/components/LumoraStudio.tsx @@ -4,6 +4,7 @@ import type { PluginDescriptor, Project } from '@lumora/core'; import { createStudioRuntime } from '../runtime/studio-runtime'; import type { StudioRuntime } from '../runtime/studio-runtime'; import { useSceneEditor } from '../hooks/use-scene-editor'; +import type { StorageBackend } from '../persistence/project-storage'; import { PanelHost } from './panels/PanelHost'; import { Toolbar } from './Toolbar'; import { CommandPalette } from './CommandPalette'; @@ -32,6 +33,8 @@ export interface LumoraStudioProps { onError?: (error: unknown) => void; /** 场景槽位,缺省为内置 3D 场景编辑器视口 */ scene?: (project: Project | null) => ReactNode; + /** 本地存储后端(缺省 indexeddb;opfs = Origin Private File System) */ + storage?: StorageBackend; className?: string; } @@ -45,7 +48,7 @@ export interface LumoraStudioHandle { * - 卸载时释放全部资源:停用插件、移除订阅、销毁事件总线、资源缓存与 WebGL 场景 */ export const LumoraStudio = forwardRef(function LumoraStudio( - { plugins = [], hostVersion, initialProject, onError, scene, className }, + { plugins = [], hostVersion, initialProject, onError, scene, storage, className }, ref, ) { const runtimeRef = useRef(null); @@ -81,7 +84,7 @@ export const LumoraStudio = forwardRef(fu const onErrorRef = onError; const boot = async () => { // 先接入本地持久化与自动保存,再打开初始项目(自动保存脏基线以打开为准) - await runtime.init(); + await runtime.init({ storage }); for (const descriptor of pluginsRef) { if (cancelBootRef.current) return; try { diff --git a/packages/studio/src/components/ProjectMenu.tsx b/packages/studio/src/components/ProjectMenu.tsx index 620a5c7..39facc9 100644 --- a/packages/studio/src/components/ProjectMenu.tsx +++ b/packages/studio/src/components/ProjectMenu.tsx @@ -4,7 +4,7 @@ import { MAX_PACKAGE_TEXT_BYTES, genId } from '@lumora/core'; import type { Project } from '@lumora/core'; import type { StudioRuntime } from '../runtime/studio-runtime'; import type { AutosaveState } from '../persistence/autosave'; -import type { ProjectSummary } from '../persistence/project-store'; +import type { ProjectSummary } from '../persistence/project-storage'; import { showToast } from './editor/toasts'; interface ProjectMenuProps { diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 79b7791..ba30f30 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -10,13 +10,18 @@ export { buildScene, syncScene } from './components/editor/scene-builder'; export { PanelErrorBoundary } from './components/panels/PanelErrorBoundary'; export { createStudioRuntime } from './runtime/studio-runtime'; export type { StudioRuntime, StudioRuntimeOptions } from './runtime/studio-runtime'; -export { ProjectStore, estimateStorage } from './persistence/project-store'; +export { ProjectStore } from './persistence/project-store'; +export { OpfsProjectStore } from './persistence/project-store-opfs'; +export { estimateStorage } from './persistence/project-storage'; export type { DuplicateOutcome, + ProjectStorage, ProjectSummary, + RenameOutcome, SaveOutcome, + StorageBackend, StoredProject, -} from './persistence/project-store'; +} from './persistence/project-storage'; export { ProjectAutosaver, AUTOSAVE_DEBOUNCE_MS } from './persistence/autosave'; export type { AutosaverOptions, AutosaveState } from './persistence/autosave'; export { ProjectPersistence } from './persistence/project-persistence'; diff --git a/packages/studio/src/persistence/autosave.ts b/packages/studio/src/persistence/autosave.ts index fb08575..3e7739e 100644 --- a/packages/studio/src/persistence/autosave.ts +++ b/packages/studio/src/persistence/autosave.ts @@ -22,7 +22,7 @@ */ import type { Project, SceneEditor } from '@lumora/core'; -import type { ProjectStore, SaveFailureCode, SaveOutcome } from './project-store'; +import type { ProjectStorage, SaveFailureCode, SaveOutcome } from './project-storage'; export const AUTOSAVE_DEBOUNCE_MS = 2000; @@ -49,7 +49,7 @@ interface LatchedError { } export class ProjectAutosaver { - private store: ProjectStore | null; + private store: ProjectStorage | null; private currentUri: string | null = null; private lastSavedRevision = 0; /** 各 uri 已确认落盘的 revision(含已切换走的项目:旧项目保存成功也推进基线)。 @@ -74,7 +74,7 @@ export class ProjectAutosaver { constructor( private readonly editor: SceneEditor, - store: ProjectStore | null, + store: ProjectStorage | null, options: AutosaverOptions = {}, ) { this.store = store; @@ -96,7 +96,7 @@ export class ProjectAutosaver { } /** 持久化就绪后接入(init 完成):对当前打开的项目重新对账(冷启动不丢事件)。 */ - setStore(store: ProjectStore | null): void { + setStore(store: ProjectStorage | null): void { this.store = store; const project = this.editor.getProject(); if (this.disposed) return; diff --git a/packages/studio/src/persistence/project-persistence.ts b/packages/studio/src/persistence/project-persistence.ts index db1b3c2..cbddfd8 100644 --- a/packages/studio/src/persistence/project-persistence.ts +++ b/packages/studio/src/persistence/project-persistence.ts @@ -1,7 +1,8 @@ /** * 项目持久化门面(FR-001 / FR-011):StudioRuntime 与 UI 之间的统一入口。 * - * - 本地存储:IndexedDB ProjectStore(新建/重命名/复制/删除/最近项目); + * - 本地存储:IndexedDB ProjectStore 或 OPFS OpfsProjectStore(init 可配置后端, + * 缺省 IndexedDB;新建/重命名/复制/删除/最近项目); * - 自动保存:ProjectAutosaver 随编辑器事件防抖落盘(2 秒),失败保持脏状态; * - 工程包:导出(buildProjectPackage 剥离私有数据 + 配额预检)与导入 * (parseProjectPackage 纯函数解析,校验失败不产生任何副作用 —— 当前项目 @@ -9,7 +10,7 @@ * - 冲突解决:reloadOpenProject(以存储内容为基线重开,显式丢弃未保存变更) * 与 duplicateProject(打开中的项目以编辑器快照为准复制,磁盘记录可能落后)。 * - * 编辑器监听在构造期同步接入:IndexedDB 打开前的冷启动变更(project:changed) + * 编辑器监听在构造期同步接入:存储打开前的冷启动变更(project:changed) * 不会丢失(自动保存先以「仅内存」状态受理,init 完成后重新对账)。 */ @@ -24,8 +25,10 @@ import { import type { MissingAssetWarning, PackageImportError } from '@lumora/core'; import { ProjectAutosaver } from './autosave'; import type { AutosaveState } from './autosave'; -import { ProjectStore, estimateStorage } from './project-store'; -import type { DuplicateOutcome, ProjectSummary, SaveOutcome } from './project-store'; +import { ProjectStore } from './project-store'; +import { OpfsProjectStore } from './project-store-opfs'; +import { estimateStorage } from './project-storage'; +import type { DuplicateOutcome, ProjectStorage, ProjectSummary, SaveOutcome, StorageBackend } from './project-storage'; export interface PersistenceEventMap extends Record { 'save-state': { state: AutosaveState }; @@ -50,7 +53,7 @@ function safeFilename(name: string): string { } export class ProjectPersistence { - private store: ProjectStore | null = null; + private store: ProjectStorage | null = null; private readonly autosaver: ProjectAutosaver; private unsubscribeEditor: { dispose(): void } | null = null; private currentUri: string | null = null; @@ -73,20 +76,27 @@ export class ProjectPersistence { }); } - /** 本地持久化是否可用(IndexedDB 打开失败时静默降级为仅内存编辑) */ + /** 本地持久化是否可用(存储打开失败时静默降级为仅内存编辑) */ get available(): boolean { return this.store !== null; } + /** 实际生效的存储后端(init 前为 null) */ + get backend(): StorageBackend | null { + return this.store?.kind ?? null; + } + /** 打开项目后的当前 uri(重命名/复制等操作据此分流) */ get openUri(): string | null { return this.currentUri; } - /** 初始化:打开存储并接入自动保存。幂等。 */ - async init(options: { debounceMs?: number; dbName?: string } = {}): Promise { + /** 初始化:打开存储并接入自动保存。幂等;storage 缺省为 indexeddb。 */ + async init(options: { debounceMs?: number; dbName?: string; storage?: StorageBackend } = {}): Promise { if (this.disposed || this.store) return; - this.store = await ProjectStore.create(options.dbName); + const backend = options.storage ?? 'indexeddb'; + this.store = + backend === 'opfs' ? await OpfsProjectStore.create(options.dbName) : await ProjectStore.create(options.dbName); this.autosaver.setStore(this.store); if (options.debounceMs !== undefined) this.autosaver.setDebounceMs(options.debounceMs); } diff --git a/packages/studio/src/persistence/project-storage.ts b/packages/studio/src/persistence/project-storage.ts new file mode 100644 index 0000000..7e652be --- /dev/null +++ b/packages/studio/src/persistence/project-storage.ts @@ -0,0 +1,115 @@ +/** + * 项目本地存储(FR-011):IndexedDB 与 OPFS 两个适配器共享的类型与工具。 + * + * 两个适配器以同一套并发安全语义(NFR-003 / AC2)实现 ProjectStorage: + * save(project, expectedStoredRevision) 在同一互斥临界区内完成 + * 「读已存 → 比对期望基线 → 写入」: + * - expectedStoredRevision 为 number 时是 CAS:已存 revision 必须与期望基线 + * 一致才写入。调用方以「上次确认已存的 revision」为期望,任何不一致(更新 + * 或缺失)都判定冲突,绝不静默覆盖 —— 多标签页下的较新保存不得被旧数据覆盖; + * - null 为创建语义:同 uri 已有记录即冲突(新建/复制项目的首存防碰撞); + * - undefined 为无条件写入(显式迁移/测试等不受 CAS 约束的场景)。 + * 冲突不提供自动恢复路径:必须由用户显式解决(加载较新版本 / 另存副本), + * 防止「本地计数追平后覆盖较新内容」的数据丢失。 + * 配额不足(QuotaExceededError)同样以可操作错误返回,调用方(自动保存)保持脏状态。 + */ + +import type { Project } from '@lumora/core'; + +/** 本地存储后端:IndexedDB(默认)或 OPFS(Origin Private File System) */ +export type StorageBackend = 'indexeddb' | 'opfs'; + +export interface ProjectSummary { + uri: string; + name: string; + savedAt: string; + revision: number; + schemaVersion: number; +} + +export interface StoredProject { + uri: string; + savedAt: string; + project: Project; +} + +export type SaveFailureCode = + | 'revision-conflict' + | 'quota-exceeded' + | 'storage-error' + // autosaver 锁存态:恢复快照待处理(flush/排空与打开屏障同样阻断,见 autosave.flush) + | 'recovery-available'; + +export type SaveOutcome = + | { ok: true } + | { ok: false; code: SaveFailureCode; message: string; storedRevision?: number }; + +export type DuplicateOutcome = + | { ok: true; summary: ProjectSummary } + | { ok: false; code: 'not-found' | 'storage-error'; message: string }; + +export type RenameOutcome = + | { ok: true } + | { ok: false; code: 'not-found' | 'storage-error'; message: string }; + +/** 项目本地存储适配器:IndexedDB(ProjectStore)与 OPFS(OpfsProjectStore)的共同契约 */ +export interface ProjectStorage { + readonly kind: StorageBackend; + /** 最近项目列表(按保存时间倒序)。 */ + list(): Promise; + /** 加载项目(返回调用方可自由修改的副本)。 */ + load(uri: string): Promise; + /** 保存项目(CAS,见文件头语义);失败返回类型化错误,绝不静默覆盖较新内容。 */ + save(project: Project, expectedStoredRevision?: number | null): Promise; + /** 删除项目;返回是否真的存在并删除。 */ + remove(uri: string): Promise; + /** 直接重命名已存储项目(仅适用于未打开的项目);失败返回类型化错误。 */ + rename(uri: string, name: string): Promise; + /** 复制项目:新 uri + 名称(缺省「原名 副本」)+ 重置 revision/createdAt。 */ + duplicate(uri: string, name?: string): Promise; + /** 关闭连接(幂等;应用卸载前调用)。 */ + close(): void; +} + +/** 浏览器存储配额估算(不可用时返回 null,调用方跳过配额预检;与具体后端无关) */ +export async function estimateStorage(): Promise<{ usage: number; quota: number } | null> { + const storage = (globalThis as { navigator?: { storage?: { estimate?: () => Promise<{ usage?: number; quota?: number }> } } }) + .navigator?.storage; + if (!storage?.estimate) return null; + try { + const estimate = await storage.estimate(); + if (typeof estimate.quota !== 'number') return null; + return { usage: estimate.usage ?? 0, quota: estimate.quota }; + } catch { + return null; + } +} + +/** 键排序稳定序列化:同 revision 分叉判定需要与键序无关的内容比较 */ +export function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, item) => { + if (item && typeof item === 'object' && !Array.isArray(item)) { + return Object.keys(item as Record) + .sort() + .reduce>((acc, key) => { + acc[key] = (item as Record)[key]; + return acc; + }, {}); + } + return item; + }); +} + +/** 同 revision 幂等重存判定:内容逐字段一致(仅 savedAt 等记录字段可漂移) */ +export function sameProjectContent(a: Project, b: Project): boolean { + return stableStringify(a) === stableStringify(b); +} + +export function isQuotaError(error: unknown): boolean { + const name = error instanceof DOMException ? error.name : (error as { name?: string })?.name; + return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED'; +} + +export function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/studio/src/persistence/project-store-opfs.ts b/packages/studio/src/persistence/project-store-opfs.ts new file mode 100644 index 0000000..75c7e77 --- /dev/null +++ b/packages/studio/src/persistence/project-store-opfs.ts @@ -0,0 +1,347 @@ +/** + * 项目本地存储(FR-011):OPFS(Origin Private File System)适配器。 + * + * 存储结构(根目录名与 IndexedDB 的库名一致,测试隔离/清空数据语义相同): + * - `/projects/`:每项目一个文件,文件名 = encodeURIComponent(uri), + * 内容为 StoredProject 记录 { uri, savedAt, project }(与 IndexedDB 记录同形); + * - `/meta/`:预留键值位(与 IndexedDB 的 meta 对象仓库对齐,暂未使用)。 + * + * 并发安全(NFR-003 / AC2):与 IndexedDB 的「同一事务内读-比-写 + 事务提交为 + * 完成边界」对齐 —— save 的整个临界区(读已存 → 比对期望基线 → 写入)在互斥锁内 + * 执行,提交边界为 writable.close()(数据落盘): + * - 同标签页与跨标签页互斥:优先用 Web Locks(navigator.locks,同源跨标签页互斥), + * 不可用时退化为进程内 promise 链互斥(至少保证同标签页串行); + * - 写入采用「临时文件 + move 覆盖」:配额不足或写入中断时旧记录保持原样, + * 杜绝半写记录(与 IndexedDB 事务的原子提交对齐)。 + * - expectedStoredRevision 的 CAS 语义与 ProjectStore 完全一致(number = 期望 + * 基线、null = 创建语义、undefined = 无条件写入),见 project-storage.ts 文件头。 + * + * 损坏记录(IndexedDB 不可能出现、OPFS 文件可能被外部改动/半写产生): + * - load 视为缺失(返回 null);save 拒绝覆盖(storage-error,可删除后重试); + * - list 跳过;remove 可正常删除(用户的修复路径)。 + * + * 配额不足(QuotaExceededError)同样以可操作错误返回,调用方(自动保存)保持脏状态。 + * OPFS 不可用时 create 返回 null(持久化静默降级,与 ProjectStore 一致)。 + */ + +import type { Project } from '@lumora/core'; +import { genId } from '@lumora/core'; +import type { + DuplicateOutcome, + ProjectStorage, + ProjectSummary, + RenameOutcome, + SaveOutcome, + StoredProject, +} from './project-storage'; +import { failureMessage, isQuotaError, sameProjectContent } from './project-storage'; + +/** OPFS 根目录名(与 IndexedDB 的 PROJECT_STORE_DB 同名,切换后端不混淆命名空间) */ +export const OPFS_STORE_DIR = 'lumora-studio'; +export const PROJECTS_DIR = 'projects'; +export const META_DIR = 'meta'; + +/** + * 适配器实际使用的 OPFS API 子集(最小结构类型):真实浏览器对象在运行期满足, + * 单测用内存 shim 满足 —— 无需完整实现 FileSystemDirectoryHandle 接口。 + */ +export interface OpfsFileHandle { + readonly kind: 'file'; + getFile(): Promise<{ text(): Promise }>; + createWritable(): Promise<{ write(data: string): Promise; close(): Promise }>; + move(destination: OpfsDirectoryHandle, name: string): Promise; +} + +export interface OpfsDirectoryHandle { + readonly kind: 'directory'; + getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; + getFileHandle(name: string, options?: { create?: boolean }): Promise; + removeEntry(name: string, options?: { recursive?: boolean }): Promise; + entries(): AsyncIterableIterator<[string, OpfsFileHandle | OpfsDirectoryHandle]>; +} + +interface OpfsStorage { + getDirectory(): Promise; +} + +/** 互斥锁:Web Locks 优先(跨标签页),不可用时进程内 promise 链退化(同标签页) */ +interface OpfsLockManager { + request(name: string, callback: () => Promise): Promise; +} + +function lockManager(): OpfsLockManager | null { + const locks = (globalThis as unknown as { navigator?: { locks?: OpfsLockManager } }).navigator?.locks; + return locks?.request ? locks : null; +} + +/** 进程内退化互斥:按锁名串行化(同一运行时内所有 OpfsProjectStore 实例共享) */ +const fallbackChains = new Map>(); + +function withFallbackLock(name: string, task: () => Promise): Promise { + const previous = fallbackChains.get(name) ?? Promise.resolve(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const chain = previous.then(() => gate); + fallbackChains.set(name, chain); + return previous.then(async () => { + try { + return await task(); + } finally { + release(); + } + }); +} + +function projectFileName(uri: string): string { + return encodeURIComponent(uri); +} + +function isNotFoundError(error: unknown): boolean { + const name = error instanceof DOMException ? error.name : (error as { name?: string })?.name; + return name === 'NotFoundError'; +} + +export class OpfsProjectStore implements ProjectStorage { + readonly kind = 'opfs' as const; + + private constructor( + private readonly root: OpfsDirectoryHandle, + private readonly projectsDir: OpfsDirectoryHandle, + readonly dbName: string, + ) {} + + /** + * 创建存储;OPFS 不可用或打开失败时返回 null(持久化静默降级)。 + * fs 参数仅供测试注入内存 shim;生产路径读取 navigator.storage.getDirectory()。 + */ + static async create(dbName = OPFS_STORE_DIR, fs?: OpfsDirectoryHandle): Promise { + try { + const storage = (globalThis as unknown as { navigator?: { storage?: OpfsStorage } }).navigator?.storage; + const root = fs ?? (storage?.getDirectory ? await storage.getDirectory() : null); + if (!root) return null; + const rootDir = await root.getDirectoryHandle(dbName, { create: true }); + const projectsDir = await rootDir.getDirectoryHandle(PROJECTS_DIR, { create: true }); + await rootDir.getDirectoryHandle(META_DIR, { create: true }); + return new OpfsProjectStore(rootDir, projectsDir, dbName); + } catch { + return null; + } + } + + /** 删除根目录(测试隔离 / 清空本地数据)。 */ + static async drop(dbName = OPFS_STORE_DIR, fs?: OpfsDirectoryHandle): Promise { + try { + const storage = (globalThis as unknown as { navigator?: { storage?: OpfsStorage } }).navigator?.storage; + const root = fs ?? (storage?.getDirectory ? await storage.getDirectory() : null); + if (!root) return; + await root.removeEntry(dbName, { recursive: true }); + } catch { + // 不存在或不可用:视为已清空 + } + } + + /** 最近项目列表(按保存时间倒序;跳过损坏记录与临时文件)。 */ + async list(): Promise { + return this.withLock(async () => { + const summaries: ProjectSummary[] = []; + for await (const [name, handle] of this.projectsDir.entries()) { + if (handle.kind !== 'file' || name.startsWith('.')) continue; + const record = await this.readRecord(name); + if (!record) continue; + summaries.push({ + uri: record.uri, + name: record.project.name, + savedAt: record.savedAt, + revision: record.project.revision, + schemaVersion: record.project.schemaVersion, + }); + } + return summaries.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0)); + }); + } + + /** 加载项目(返回调用方可自由修改的副本;损坏记录视为缺失)。 */ + async load(uri: string): Promise { + return this.withLock(async () => { + const record = await this.readRecord(projectFileName(uri)); + if (!record) return null; + return record.project ? structuredClone(record.project) : null; + }); + } + + /** + * 保存项目(CAS,NFR-003 / AC2;语义与 ProjectStore 完全一致): + * 互斥临界区内读-比-写,写入以「临时文件 + move 覆盖」原子替换旧记录, + * 提交边界 = writable.close()(落盘)。 + */ + async save(project: Project, expectedStoredRevision?: number | null): Promise { + return this.withLock(async () => { + const name = projectFileName(project.uri); + try { + const record = await this.readRecord(name); + if (record === null) { + return { + ok: false, + code: 'storage-error', + message: '本地项目记录已损坏,拒绝覆盖;可删除该项目后重试', + }; + } + const existing = record ?? undefined; + const storedRevision = existing?.project.revision; + const mismatch = + expectedStoredRevision === null + ? existing !== undefined + : typeof expectedStoredRevision === 'number' && storedRevision !== expectedStoredRevision; + if (mismatch) { + return { + ok: false, + code: 'revision-conflict', + message: + expectedStoredRevision === null + ? '本地已存在同 uri 的项目记录,未覆盖' + : `本地保存内容与期望基线不一致(revision ${storedRevision ?? '无记录'} ≠ ${expectedStoredRevision}),未覆盖`, + ...(storedRevision !== undefined ? { storedRevision } : {}), + }; + } + if (existing) { + // CAS 通过后仍拒绝倒退与分叉(NFR-003):旧 revision 覆盖较新记录、 + // 同 revision 写入不同内容都会让多标签页的计数收敛失效 + if (project.revision < existing.project.revision) { + return { + ok: false, + code: 'revision-conflict', + message: `不能以旧 revision(${project.revision})覆盖较新记录(${existing.project.revision}),未写入`, + storedRevision: existing.project.revision, + }; + } + if (project.revision === existing.project.revision && !sameProjectContent(project, existing.project)) { + return { + ok: false, + code: 'revision-conflict', + message: `同 revision(${project.revision})但内容不同的记录已存在(分叉),未覆盖`, + storedRevision: existing.project.revision, + }; + } + } + await this.writeRecord(name, { + uri: project.uri, + savedAt: new Date().toISOString(), + project: structuredClone(project), + }); + return { ok: true }; + } catch (error) { + if (isQuotaError(error)) { + return { ok: false, code: 'quota-exceeded', message: '本地存储空间不足,保存失败' }; + } + return { ok: false, code: 'storage-error', message: `保存失败:${failureMessage(error)}` }; + } + }); + } + + /** 删除项目;返回是否真的存在并删除(损坏记录同样可删除,作为修复路径)。 */ + async remove(uri: string): Promise { + return this.withLock(async () => { + const name = projectFileName(uri); + const record = await this.readRecord(name); + if (record === undefined) return false; + await this.projectsDir.removeEntry(name); + return true; + }); + } + + /** 直接重命名已存储项目(仅适用于未打开的项目);语义与 ProjectStore 一致。 */ + async rename(uri: string, name: string): Promise { + const project = await this.load(uri); + if (!project) return { ok: false, code: 'not-found', message: '项目不存在' }; + const result = await this.save({ ...project, name, revision: project.revision + 1 }, project.revision); + if (!result.ok) return { ok: false, code: 'storage-error', message: result.message }; + return { ok: true }; + } + + /** 复制项目:新 uri + 名称(缺省「原名 副本」)+ 重置 revision/createdAt;语义与 ProjectStore 一致。 */ + async duplicate(uri: string, name?: string): Promise { + const project = await this.load(uri); + if (!project) return { ok: false, code: 'not-found', message: '项目不存在' }; + const copy: Project = { + ...structuredClone(project), + uri: `lumora://project/${genId('p')}`, + name: name ?? `${project.name} 副本`, + createdAt: new Date().toISOString(), + revision: 0, + }; + const result = await this.save(copy, null); + if (!result.ok) return { ok: false, code: 'storage-error', message: result.message }; + return { + ok: true, + summary: { + uri: copy.uri, + name: copy.name, + savedAt: new Date().toISOString(), + revision: 0, + schemaVersion: copy.schemaVersion, + }, + }; + } + + /** 关闭连接(幂等;OPFS 无连接语义,应用卸载前调用以对齐接口)。 */ + close(): void { + // OPFS 句柄不持有连接:无需释放 + } + + /** 互斥临界区:Web Locks 优先(跨标签页),不可用退化进程内互斥(同标签页) */ + private withLock(task: () => Promise): Promise { + const name = `lumora-opfs:${this.dbName}`; + const locks = lockManager(); + if (locks) return locks.request(name, task); + return withFallbackLock(name, task); + } + + /** + * 读取记录:文件缺失返回 undefined;损坏(无法解析)返回 null; + * 合法记录返回 StoredProject。 + */ + private async readRecord(name: string): Promise { + let handle: OpfsFileHandle; + try { + handle = await this.projectsDir.getFileHandle(name); + } catch (error) { + if (isNotFoundError(error)) return undefined; + throw error; + } + const file = await handle.getFile(); + const text = await file.text(); + try { + return JSON.parse(text) as StoredProject; + } catch { + return null; + } + } + + /** 原子写入:临时文件落盘后 move 覆盖目标名;任一步失败旧记录保持原样。 */ + private async writeRecord(name: string, record: StoredProject): Promise { + const tmpName = `.${name}.tmp`; + const text = JSON.stringify(record); + const tmp = await this.projectsDir.getFileHandle(tmpName, { create: true }); + const writable = await tmp.createWritable(); + try { + await writable.write(text); + await writable.close(); + } catch (error) { + // 写入失败:尽力清理临时文件后上抛(旧记录未被触碰) + try { + await writable.close(); + } catch { + // 写入本身已失败,close 可能再次拒绝;忽略 + } + try { + await this.projectsDir.removeEntry(tmpName); + } catch { + // 清理失败不掩盖原始错误 + } + throw error; + } + await tmp.move(this.projectsDir, name); + } +} diff --git a/packages/studio/src/persistence/project-store.ts b/packages/studio/src/persistence/project-store.ts index 533113b..ab5e8fb 100644 --- a/packages/studio/src/persistence/project-store.ts +++ b/packages/studio/src/persistence/project-store.ts @@ -9,47 +9,21 @@ * 并发安全(NFR-003 / AC2):save(project, expectedStoredRevision) 在同一 * readwrite 事务内完成「读已存 → 比对期望基线 → 写入」,提交前等待事务 * complete(而非仅请求成功),杜绝「声称已保存但事务未提交」的假成功。 - * - expectedStoredRevision 为 number 时是 CAS:已存 revision 必须与期望基线 - * 一致才写入。调用方以「上次确认已存的 revision」为期望,任何不一致(更新 - * 或缺失)都判定冲突,绝不静默覆盖 —— 多标签页下的较新保存不得被旧数据覆盖; - * - null 为创建语义:同 uri 已有记录即冲突(新建/复制项目的首存防碰撞); - * - undefined 为无条件写入(显式迁移/测试等不受 CAS 约束的场景)。 - * 冲突不提供自动恢复路径:必须由用户显式解决(加载较新版本 / 另存副本), - * 防止「本地计数追平后覆盖较新内容」的数据丢失。 + * 语义(CAS 基线 / 创建语义 / 无条件写入 / 防倒退 / 防分叉)见 project-storage.ts 文件头。 * 配额不足(QuotaExceededError)同样以可操作错误返回,调用方(自动保存)保持脏状态。 */ import type { Project } from '@lumora/core'; import { genId } from '@lumora/core'; - -export interface ProjectSummary { - uri: string; - name: string; - savedAt: string; - revision: number; - schemaVersion: number; -} - -export interface StoredProject { - uri: string; - savedAt: string; - project: Project; -} - -export type SaveFailureCode = - | 'revision-conflict' - | 'quota-exceeded' - | 'storage-error' - // autosaver 锁存态:恢复快照待处理(flush/排空与打开屏障同样阻断,见 autosave.flush) - | 'recovery-available'; - -export type SaveOutcome = - | { ok: true } - | { ok: false; code: SaveFailureCode; message: string; storedRevision?: number }; - -export type DuplicateOutcome = - | { ok: true; summary: ProjectSummary } - | { ok: false; code: 'not-found' | 'storage-error'; message: string }; +import type { + DuplicateOutcome, + ProjectStorage, + ProjectSummary, + RenameOutcome, + SaveOutcome, + StoredProject, +} from './project-storage'; +import { failureMessage, isQuotaError, sameProjectContent } from './project-storage'; export const PROJECT_STORE_DB = 'lumora-studio'; export const PROJECTS_STORE = 'projects'; @@ -73,50 +47,9 @@ function transactionDone(transaction: IDBTransaction): Promise { }); } -/** 浏览器存储配额估算(不可用时返回 null,调用方跳过配额预检) */ -export async function estimateStorage(): Promise<{ usage: number; quota: number } | null> { - const storage = (globalThis as { navigator?: { storage?: { estimate?: () => Promise<{ usage?: number; quota?: number }> } } }) - .navigator?.storage; - if (!storage?.estimate) return null; - try { - const estimate = await storage.estimate(); - if (typeof estimate.quota !== 'number') return null; - return { usage: estimate.usage ?? 0, quota: estimate.quota }; - } catch { - return null; - } -} - -/** 键排序稳定序列化:同 revision 分叉判定需要与键序无关的内容比较 */ -function stableStringify(value: unknown): string { - return JSON.stringify(value, (_key, item) => { - if (item && typeof item === 'object' && !Array.isArray(item)) { - return Object.keys(item as Record) - .sort() - .reduce>((acc, key) => { - acc[key] = (item as Record)[key]; - return acc; - }, {}); - } - return item; - }); -} - -/** 同 revision 幂等重存判定:内容逐字段一致(仅 savedAt 等记录字段可漂移) */ -function sameProjectContent(a: Project, b: Project): boolean { - return stableStringify(a) === stableStringify(b); -} - -function isQuotaError(error: unknown): boolean { - const name = error instanceof DOMException ? error.name : (error as { name?: string })?.name; - return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED'; -} - -function failureMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} +export class ProjectStore implements ProjectStorage { + readonly kind = 'indexeddb' as const; -export class ProjectStore { private constructor( private readonly db: IDBDatabase, readonly dbName: string, @@ -257,7 +190,7 @@ export class ProjectStore { /** 直接重命名已存储项目(仅适用于未打开的项目;打开中的重命名走编辑器提交)。 * 以加载到的 revision 为 CAS 期望:读-改-写间被其他写入推进时拒绝,防倒退。 */ - async rename(uri: string, name: string): Promise<{ ok: true } | { ok: false; code: 'not-found' | 'storage-error'; message: string }> { + async rename(uri: string, name: string): Promise { const project = await this.load(uri); if (!project) return { ok: false, code: 'not-found', message: '项目不存在' }; const result = await this.save({ ...project, name, revision: project.revision + 1 }, project.revision); diff --git a/packages/studio/src/runtime/studio-runtime.ts b/packages/studio/src/runtime/studio-runtime.ts index ccb1c11..95b9be4 100644 --- a/packages/studio/src/runtime/studio-runtime.ts +++ b/packages/studio/src/runtime/studio-runtime.ts @@ -1,6 +1,7 @@ import { PluginHost, SceneEditor } from '@lumora/core'; import type { EventMap, Project } from '@lumora/core'; import { ProjectPersistence } from '../persistence/project-persistence'; +import type { StorageBackend } from '../persistence/project-storage'; export interface StudioRuntimeOptions { hostVersion?: string; @@ -19,13 +20,16 @@ export interface StudioRuntime { /** 核心场景编辑器:项目数据、选择、视口状态与历史栈(撤销/重做)的唯一持有者 */ editor: SceneEditor; /** - * 项目持久化门面:IndexedDB 本地存储(最近项目/重命名/复制/删除)、 + * 项目持久化门面:IndexedDB 或 OPFS 本地存储(最近项目/重命名/复制/删除)、 * 2 秒防抖自动保存与 `.lumora` 工程包导入导出。init() 后生效; - * IndexedDB 不可用时静默降级(available = false,仅内存编辑)。 + * 存储不可用时静默降级(available = false,仅内存编辑)。 */ persistence: ProjectPersistence; - /** 初始化本地存储并接入自动保存(幂等)。 */ - init(options?: { debounceMs?: number; dbName?: string }): Promise; + /** + * 初始化本地存储并接入自动保存(幂等)。 + * options.storage 选择存储后端(缺省 indexeddb;opfs = Origin Private File System)。 + */ + init(options?: { debounceMs?: number; dbName?: string; storage?: StorageBackend }): Promise; /** * 打开/切换项目(可等待的类型化切换屏障):替换编辑器前先稳定排空当前项目的 * 未保存变更(flushPending 稳定排空)。落盘失败时返回 { ok: false } 且不触碰 diff --git a/packages/studio/test/opfs-fs-shim.ts b/packages/studio/test/opfs-fs-shim.ts new file mode 100644 index 0000000..65ac530 --- /dev/null +++ b/packages/studio/test/opfs-fs-shim.ts @@ -0,0 +1,125 @@ +/** + * OPFS 适配器单测用的内存文件系统 shim:只实现 OpfsProjectStore 实际使用的 + * API 子集(getFileHandle / getDirectoryHandle / removeEntry / entries / + * createWritable / move / getFile),行为对齐 File System Access 规范 + * (getFileHandle 缺省 create:false 时缺失抛 NotFoundError;move 覆盖目标名)。 + * failNextWrite 钩子用于注入配额不足等写入失败,验证原子写保护。 + */ + +import type { OpfsDirectoryHandle, OpfsFileHandle } from '../src/persistence/project-store-opfs'; + +class NotFoundError extends Error { + readonly name = 'NotFoundError'; +} + +export class MemWritable { + private closed = false; + + constructor( + private readonly file: MemFileHandle, + private readonly dir: MemDirectoryHandle, + ) {} + + async write(data: string): Promise { + if (this.closed) throw new Error('writable 已关闭'); + const failure = this.dir.takeWriteFailure(); + if (failure) throw failure; + this.file.text = data; + } + + async close(): Promise { + this.closed = true; + } +} + +export class MemFileHandle implements OpfsFileHandle { + readonly kind = 'file' as const; + text = ''; + + constructor( + public name: string, + private parent: MemDirectoryHandle, + ) {} + + async getFile(): Promise<{ text(): Promise }> { + return { + text: async () => this.text, + }; + } + + async createWritable(): Promise { + return new MemWritable(this, this.parent); + } + + /** move:从当前父目录摘除,写入目标目录(目标名已存在则覆盖) */ + async move(destination: MemDirectoryHandle, name: string): Promise { + this.parent.children.delete(this.name); + this.parent = destination; + this.name = name; + destination.children.set(name, this); + } +} + +export class MemDirectoryHandle implements OpfsDirectoryHandle { + readonly kind = 'directory' as const; + readonly children = new Map(); + /** 一次性写入失败钩子(配额注入):取走后清空 */ + private writeFailure: Error | null = null; + + constructor(readonly name: string) {} + + failNextWrite(error: Error): void { + this.writeFailure = error; + } + + takeWriteFailure(): Error | null { + const failure = this.writeFailure; + this.writeFailure = null; + return failure; + } + + async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { + const existing = this.children.get(name); + if (existing) { + if (existing.kind !== 'directory') throw new Error('TypeMismatchError'); + return existing as MemDirectoryHandle; + } + if (!options?.create) throw new NotFoundError(`目录不存在:${name}`); + const dir = new MemDirectoryHandle(name); + this.children.set(name, dir); + return dir; + } + + async getFileHandle(name: string, options?: { create?: boolean }): Promise { + const existing = this.children.get(name); + if (existing) { + if (existing.kind !== 'file') throw new Error('TypeMismatchError'); + return existing as MemFileHandle; + } + if (!options?.create) throw new NotFoundError(`文件不存在:${name}`); + const file = new MemFileHandle(name, this); + this.children.set(name, file); + return file; + } + + async removeEntry(name: string): Promise { + if (!this.children.delete(name)) throw new NotFoundError(`条目不存在:${name}`); + } + + async *entries(): AsyncIterableIterator<[string, MemFileHandle | MemDirectoryHandle]> { + for (const [name, entry] of this.children) { + yield [name, entry]; + } + } +} + +/** 便捷构造:目录直接包含若干「文件名 → 文本内容」的只读文件 */ +export function memDirWithFiles(name: string, files: Record): MemDirectoryHandle { + const dir = new MemDirectoryHandle(name); + for (const [fileName, text] of Object.entries(files)) { + const handle = new MemFileHandle(fileName, dir); + handle.text = text; + dir.children.set(fileName, handle); + } + return dir; +} diff --git a/packages/studio/test/project-persistence-opfs.test.ts b/packages/studio/test/project-persistence-opfs.test.ts new file mode 100644 index 0000000..b826da4 --- /dev/null +++ b/packages/studio/test/project-persistence-opfs.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createGroupObject } from '@lumora/core'; +import { createStudioRuntime } from '../src/runtime/studio-runtime'; +import type { StudioRuntime } from '../src/runtime/studio-runtime'; +import { ProjectStore } from '../src/persistence/project-store'; +import { OpfsProjectStore } from '../src/persistence/project-store-opfs'; +import { MemDirectoryHandle } from './opfs-fs-shim'; + +const DB = 'lumora-test-persist-opfs'; + +async function settle(ms = 40): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +const openRuntimes: StudioRuntime[] = []; +const openStores: ProjectStore[] = []; + +async function makeRuntime(storage?: 'indexeddb' | 'opfs') { + const runtime = createStudioRuntime(); + openRuntimes.push(runtime); + await runtime.init({ debounceMs: 10, dbName: DB, storage }); + return runtime; +} + +beforeEach(async () => { + // 同一测试内多次 init(多运行时模拟多标签页)共享同一 OPFS 根 + const root = new MemDirectoryHandle('root'); + vi.stubGlobal( + 'navigator', + Object.create(navigator, { + storage: { + value: { getDirectory: async () => root }, + configurable: true, + }, + }), + ); + await OpfsProjectStore.drop(DB); + await ProjectStore.drop(DB); +}); + +afterEach(async () => { + for (const runtime of openRuntimes) await runtime.dispose(); + openRuntimes.length = 0; + for (const store of openStores) store.close(); + openStores.length = 0; + await OpfsProjectStore.drop(DB); + await ProjectStore.drop(DB); + vi.unstubAllGlobals(); +}); + +describe('ProjectPersistence:OPFS 后端(可配置切换,行为与 IndexedDB 一致)', () => { + it('init({ storage: opfs }) 后可用且 backend 报告 opfs;缺省为 indexeddb', async () => { + const opfsRuntime = await makeRuntime('opfs'); + expect(opfsRuntime.persistence.available).toBe(true); + expect(opfsRuntime.persistence.backend).toBe('opfs'); + + const idbRuntime = await makeRuntime(); + expect(idbRuntime.persistence.available).toBe(true); + expect(idbRuntime.persistence.backend).toBe('indexeddb'); + }); + + it('新建项目 → 打开 → 自动保存 → 出现在最近项目列表(OPFS 落盘)', async () => { + const runtime = await makeRuntime('opfs'); + expect(await runtime.persistence.listRecent()).toEqual([]); + + const project = runtime.persistence.createProject('OPFS 项目'); + runtime.openProject(project); + runtime.editor.addObject(createGroupObject()); + await settle(60); + + const recent = await runtime.persistence.listRecent(); + expect(recent).toHaveLength(1); + expect(recent[0]).toMatchObject({ uri: project.uri, name: 'OPFS 项目' }); + // 重新打开同后端:数据从 OPFS 完整恢复 + const reopened = await makeRuntime('opfs'); + const loaded = await reopened.persistence.loadProject(project.uri); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.project.objects).toHaveLength(2); + expect(loaded.project.revision).toBeGreaterThanOrEqual(1); + }); + + it('重命名 / 复制 / 删除在 OPFS 后端工作', async () => { + const runtime = await makeRuntime('opfs'); + const project = runtime.persistence.createProject('原名'); + runtime.openProject(project); + await settle(60); + + // 打开中的项目重命名走编辑器提交(一步历史 + revision 递增 + 落盘) + const renamed = await runtime.persistence.renameProject(project.uri, '新名'); + expect(renamed.ok).toBe(true); + await settle(60); + expect((await runtime.persistence.loadProject(project.uri))!.project.name).toBe('新名'); + + // 复制 + const duplicate = await runtime.persistence.duplicateProject(project.uri); + expect(duplicate.ok).toBe(true); + if (!duplicate.ok) return; + expect(duplicate.summary.name).toBe('新名 副本'); + expect(await runtime.persistence.hasLocal(duplicate.summary.uri)).toBe(true); + + // 删除副本 + expect(await runtime.persistence.deleteProject(duplicate.summary.uri)).toBe(true); + expect(await runtime.persistence.hasLocal(duplicate.summary.uri)).toBe(false); + }); + + it('切换后端不共享数据:IndexedDB 的既有记录在 OPFS 模式不可见,反之亦然', async () => { + const idbRuntime = await makeRuntime(); + const project = idbRuntime.persistence.createProject('IDB 项目'); + idbRuntime.openProject(project); + await settle(60); + expect(await idbRuntime.persistence.listRecent()).toHaveLength(1); + + const opfsRuntime = await makeRuntime('opfs'); + expect(await opfsRuntime.persistence.listRecent()).toEqual([]); + + // 反向:OPFS 写入后 IndexedDB 侧仍不可见 + const opfsProject = opfsRuntime.persistence.createProject('OPFS 项目'); + opfsRuntime.openProject(opfsProject); + await settle(60); + expect(await idbRuntime.persistence.listRecent()).toHaveLength(1); + }); + + it('revision 冲突在 OPFS 后端同样锁存并可通过「加载较新版本」解决(AC2)', async () => { + const runtime = await makeRuntime('opfs'); + const states: string[] = []; + runtime.persistence.events.on('save-state', ({ state }) => states.push(state.status)); + const project = runtime.persistence.createProject('冲突项目'); + runtime.openProject(project); + runtime.editor.addObject(createGroupObject()); + await settle(60); // rev1 已存 + + // 模拟另一标签页(另一 OPFS 连接)写入了较新内容(rev5,无条件写入) + const store = await OpfsProjectStore.create(DB); + expect(store).not.toBeNull(); + if (!store) return; + expect((await store.save({ ...project, name: '较新内容', revision: 5 })).ok).toBe(true); + + // 本地再编辑 → 保存失败(期望基线 1 ≠ 已存 5) + runtime.editor.addObject(createGroupObject()); + await settle(60); + expect(states).toContain('error'); + + // 显式解决「加载较新版本」:以存储内容为基线重开,冲突解除 + const reloaded = await runtime.persistence.reloadOpenProject(); + expect(reloaded.ok).toBe(true); + if (!reloaded.ok) return; + expect(runtime.editor.getProject()!.name).toBe('较新内容'); + expect(runtime.editor.getProject()!.revision).toBe(5); + + // 冲突解除:后续编辑可正常保存为 rev6(不覆盖较新内容) + runtime.editor.addObject(createGroupObject()); + await settle(60); + const final = await store.load(project.uri); + expect(final!.revision).toBe(6); + expect(final!.name).toBe('较新内容'); + store.close(); + }); + + it('OPFS 不可用时 init 静默降级:available false、backend null(仅内存编辑)', async () => { + vi.stubGlobal( + 'navigator', + Object.create(navigator, { + storage: { value: {}, configurable: true }, + }), + ); + const runtime = await makeRuntime('opfs'); + expect(runtime.persistence.available).toBe(false); + expect(runtime.persistence.backend).toBeNull(); + expect(await runtime.persistence.listRecent()).toEqual([]); + }); +}); diff --git a/packages/studio/test/project-store-opfs.test.ts b/packages/studio/test/project-store-opfs.test.ts new file mode 100644 index 0000000..38ea501 --- /dev/null +++ b/packages/studio/test/project-store-opfs.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createBlankProject } from '@lumora/core'; +import { OpfsProjectStore } from '../src/persistence/project-store-opfs'; +import { MemDirectoryHandle } from './opfs-fs-shim'; + +const DB = 'lumora-test-opfs'; + +function project(uri: string, name: string, revision: number) { + return { ...createBlankProject(uri, name), revision }; +} + +/** 把内存根目录挂到 navigator.storage.getDirectory(生产代码的注入点) */ +function stubNavigatorWithRoot(root: MemDirectoryHandle): void { + vi.stubGlobal( + 'navigator', + Object.create(navigator, { + storage: { + value: { getDirectory: async () => root }, + configurable: true, + }, + }), + ); +} + +beforeEach(async () => { + stubNavigatorWithRoot(new MemDirectoryHandle('root')); + await OpfsProjectStore.drop(DB); +}); + +afterEach(async () => { + await OpfsProjectStore.drop(DB); + vi.unstubAllGlobals(); +}); + +describe('OpfsProjectStore:OPFS 持久化(FR-011,行为与 IndexedDB 一致)', () => { + it('保存后可重新打开读取(跨连接持久化)', async () => { + const store = await OpfsProjectStore.create(DB); + expect(store).not.toBeNull(); + if (!store) return; + const saved = project('lumora://project/a', '持久化项目', 3); + expect((await store.save(saved)).ok).toBe(true); + store.close(); + + const reopened = await OpfsProjectStore.create(DB); + expect(reopened).not.toBeNull(); + if (!reopened) return; + const loaded = await reopened.load('lumora://project/a'); + expect(loaded).toEqual(saved); + // load 返回可自由修改的副本,不得影响存储中的记录 + loaded!.name = '被调用方修改'; + expect((await reopened.load('lumora://project/a'))!.name).toBe('持久化项目'); + reopened.close(); + }); + + it('list 按保存时间倒序返回摘要(最近项目列表)', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + await store.save(project('lumora://project/old', '旧项目', 1)); + await new Promise((r) => setTimeout(r, 5)); + await store.save(project('lumora://project/new', '新项目', 2)); + const summaries = await store.list(); + expect(summaries.map((s) => s.uri)).toEqual(['lumora://project/new', 'lumora://project/old']); + expect(summaries[0]).toMatchObject({ name: '新项目', revision: 2, schemaVersion: 2 }); + store.close(); + }); + + it('防倒退(NFR-003):期望基线落后于已存 revision 时拒绝写入且不覆盖', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const newer = project('lumora://project/a', '较新', 5); + expect((await store.save(newer)).ok).toBe(true); + + // 调用方期望基线 = 3(打开时读到 rev3):已存 5 ≠ 3 → 拒绝 + const stale = project('lumora://project/a', '较旧', 3); + const result = await store.save(stale, 3); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'revision-conflict') return; + expect(result.storedRevision).toBe(5); + expect(result.message).toContain('5'); + + // 存储内容未被旧数据覆盖(名称仍是较新的保存内容) + expect((await store.load('lumora://project/a'))!.name).toBe('较新'); + store.close(); + }); + + it('CAS 按期望基线校验:本地 revision 追平已存也不能覆盖(评审阻断项回归)', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const newer = project('lumora://project/a', '较新', 5); + expect((await store.save(newer)).ok).toBe(true); + + // 旧实现漏洞场景:客户端本地编辑把 revision 追平到 5(A 追平 B 的已存), + // 但期望基线仍是打开时的 3 —— 按 revision 大小比较会误判「已追平可覆盖」; + // CAS 必须按期望基线拒绝,杜绝 A 覆盖 B 的保存内容 + const caughtUp = project('lumora://project/a', '本地追平', 5); + const result = await store.save(caughtUp, 3); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'revision-conflict') return; + expect(result.storedRevision).toBe(5); + expect((await store.load('lumora://project/a'))!.name).toBe('较新'); + + // 期望基线匹配(= 已存 5)时允许写入 + const fresh = project('lumora://project/a', '新内容', 6); + expect((await store.save(fresh, 5)).ok).toBe(true); + expect((await store.load('lumora://project/a'))!.name).toBe('新内容'); + store.close(); + }); + + it('create-only(expected null):同 uri 已存在时拒绝创建,不覆盖已有记录', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const first = project('lumora://project/a', '首个', 0); + expect((await store.save(first, null)).ok).toBe(true); + + const second = project('lumora://project/a', '重复创建', 0); + const result = await store.save(second, null); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'revision-conflict') return; + expect((await store.load('lumora://project/a'))!.name).toBe('首个'); + store.close(); + }); + + it('同 revision 幂等重存(自动保存抖动)允许且刷新 savedAt', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const same = project('lumora://project/a', '同名', 7); + expect((await store.save(same, null)).ok).toBe(true); + await new Promise((r) => setTimeout(r, 5)); + // 期望基线 = 已存 7 → 匹配;内容逐字节一致(自动保存抖动重发同一内容)→ 允许重存 + const jitter = { ...same }; + expect((await store.save(jitter, 7)).ok).toBe(true); + expect((await store.load('lumora://project/a'))!.name).toBe('同名'); + store.close(); + }); + + it('同 revision 内容分叉拒绝(评审阻断项回归:禁止同 revision 分叉覆盖)', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const first = project('lumora://project/a', '分叉前', 7); + expect((await store.save(first, null)).ok).toBe(true); + // 同 revision 7 但内容不同(改场景名)→ 视为分叉,拒绝且不覆盖 + const fork = { ...first, scenes: [{ ...first.scenes[0]!, name: '被改名的场景' }] }; + const result = await store.save(fork, 7); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'revision-conflict') return; + expect(result.storedRevision).toBe(7); + expect((await store.load('lumora://project/a'))!.name).toBe('分叉前'); + store.close(); + }); + + it('remove 删除项目,重复删除返回 false', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + await store.save(project('lumora://project/a', '待删除', 1)); + expect(await store.remove('lumora://project/a')).toBe(true); + expect(await store.load('lumora://project/a')).toBeNull(); + expect(await store.remove('lumora://project/a')).toBe(false); + store.close(); + }); + + it('rename 仅作用于已存储项目;不存在时返回 not-found', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + await store.save(project('lumora://project/a', '原名', 1)); + const result = await store.rename('lumora://project/a', '新名'); + expect(result.ok).toBe(true); + const loaded = await store.load('lumora://project/a'); + expect(loaded!.name).toBe('新名'); + // 重命名也是一次变更:revision 递增 + expect(loaded!.revision).toBe(2); + + const missing = await store.rename('lumora://project/nope', 'X'); + expect(missing.ok).toBe(false); + if (missing.ok) return; + expect(missing.code).toBe('not-found'); + store.close(); + }); + + it('duplicate 生成新 uri、副本名与重置后的 revision', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + await store.save(project('lumora://project/a', '源项目', 4)); + const result = await store.duplicate('lumora://project/a'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.summary.uri).not.toBe('lumora://project/a'); + expect(result.summary.name).toBe('源项目 副本'); + expect(result.summary.revision).toBe(0); + const copy = await store.load(result.summary.uri); + expect(copy).not.toBeNull(); + expect(copy!.name).toBe('源项目 副本'); + expect(copy!.revision).toBe(0); + expect((await store.list()).map((s) => s.uri).sort()).toEqual(['lumora://project/a', result.summary.uri]); + store.close(); + }); + + it('duplicate 不存在的项目返回 not-found', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const result = await store.duplicate('lumora://project/nope'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('not-found'); + store.close(); + }); + + it('跨实例并发(模拟跨标签页):旧基线保存被互斥临界区后的较新记录拒绝', async () => { + const storeA = await OpfsProjectStore.create(DB); + const storeB = await OpfsProjectStore.create(DB); + if (!storeA || !storeB) return; + // A 保存 rev5 + const newer = project('lumora://project/a', 'A 的较新内容', 5); + expect((await storeA.save(newer)).ok).toBe(true); + + // B 持有打开时的旧基线 3:互斥临界区内重读发现已存 5 ≠ 3 → 冲突,不覆盖 + const stale = project('lumora://project/a', 'B 的旧内容', 3); + const result = await storeB.save(stale, 3); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'revision-conflict') return; + expect(result.storedRevision).toBe(5); + expect((await storeA.load('lumora://project/a'))!.name).toBe('A 的较新内容'); + + // B 重新打开(读到 rev5)后以新基线保存成功 + const fresh = project('lumora://project/a', 'B 基于较新内容', 6); + expect((await storeB.save(fresh, 5)).ok).toBe(true); + expect((await storeA.load('lumora://project/a'))!.name).toBe('B 基于较新内容'); + storeA.close(); + storeB.close(); + }); + + it('损坏记录:load 视为缺失、save 拒绝覆盖、remove 可删除(修复路径)', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + // 直接向存储目录写入非法 JSON(模拟外部改动/半写) + const root = (await navigator.storage.getDirectory()) as MemDirectoryHandle; + const rootDir = await root.getDirectoryHandle(DB); + const projectsDir = await rootDir.getDirectoryHandle('projects'); + await projectsDir.getFileHandle('lumora%3A%2F%2Fproject%2Fbroken', { create: true }); + + expect(await store.load('lumora://project/broken')).toBeNull(); + // 创建语义与 CAS 都拒绝覆盖损坏记录 + const result = await store.save(project('lumora://project/broken', '试图覆盖', 1), null); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'storage-error') return; + expect(result.message).toContain('损坏'); + // remove 删除损坏文件(用户修复路径) + expect(await store.remove('lumora://project/broken')).toBe(true); + expect((await store.list()).map((s) => s.uri)).toEqual([]); + store.close(); + }); + + it('配额不足:写入失败返回 quota-exceeded,旧记录保持原样且无临时文件残留', async () => { + const store = await OpfsProjectStore.create(DB); + if (!store) return; + const saved = project('lumora://project/a', '已落盘内容', 2); + expect((await store.save(saved)).ok).toBe(true); + + // 注入写入失败(QuotaExceededError)后重存同 uri 内容 + const root = (await navigator.storage.getDirectory()) as MemDirectoryHandle; + const rootDir = await root.getDirectoryHandle(DB); + const projectsDir = await rootDir.getDirectoryHandle('projects'); + projectsDir.failNextWrite(new DOMException('磁盘配额不足', 'QuotaExceededError')); + + const result = await store.save(project('lumora://project/a', '放不下的内容', 3), 2); + expect(result.ok).toBe(false); + if (result.ok || result.code !== 'quota-exceeded') return; + // 原子写保护:旧记录未被半写覆盖 + expect((await store.load('lumora://project/a'))!.name).toBe('已落盘内容'); + // 临时文件被清理:list 不受影响 + expect((await store.list()).map((s) => s.uri)).toEqual(['lumora://project/a']); + const names: string[] = []; + for await (const [name] of projectsDir.entries()) names.push(name); + expect(names.filter((n) => n.endsWith('.tmp'))).toEqual([]); + store.close(); + }); + + it('OPFS 不可用时 create 返回 null(持久化静默降级)', async () => { + vi.stubGlobal( + 'navigator', + Object.create(navigator, { + storage: { value: {}, configurable: true }, + }), + ); + expect(await OpfsProjectStore.create(DB)).toBeNull(); + }); +});