feat: expose persisted project summaries

This commit is contained in:
2026-08-02 21:22:32 -04:00
parent ce64381677
commit 2a0b14b26d
10 changed files with 71 additions and 18 deletions

View File

@@ -330,7 +330,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver不是 `planegcs` WASM高级约束、拖拽、外部几何和 B-spline 未完成 |
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型未知类型为 proxyPython 类型为 blocked脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
| P2-04 Document load | `app.document.load()` Project 快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态并保留 Undo 边界Projects 页面调用 Facade API | 保存→修改→加载后属性恢复、选择清空、任务清空测试通过 | FCStd 导入仍是只读 metadata/proxy跨文档 ShapeHandle 和恢复后自动几何重算仍待完成 |
| P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()`选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态并保留 Undo 边界Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空测试通过 | FCStd 导入仍是只读 metadata/proxy跨文档 ShapeHandle 和恢复后自动几何重算仍待完成 |
| P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 的浏览器中使用内存资源表,保留 SHA-256 身份和引用计数;恢复报告区分数据库完整但目标文档缺失的 `unavailable`;销毁 Worker 时清空临时资源 | Worker 类型检查、生产构建通过 | 内存资源会随刷新丢失;配额回收、持久资源校验和大文件压力仍待实现 |
| PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 |
| P1-02 命令准确性 | manifest 仍可完整展示Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor命令状态同时检查工作台和对象类型选择谓词其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |

View File

@@ -1595,7 +1595,7 @@ P0 基线/治理
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
| Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS |
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 |
| Document load/recovery transaction | `PASS` | `app.document.load(documentId)` 通过 Project Facade 恢复 SQLite/内存快照,取消旧重算、释放缓存 Shape、清空选择/任务并提交可撤销的活动文档Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页已接入默认本地快照打开流程 |
| Document list/load/recovery transaction | `PASS` | `project.list()` 从 SQLite/内存快照返回 documentId、版本、对象数、dirty/readOnly 和更新时间;`app.document.load(documentId)` 通过 Project Facade 恢复快照,取消旧重算、释放缓存 Shape、清空选择/任务并提交可撤销的活动文档Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页按真实 ID 打开 |
| P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh |
| P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 |

View File

@@ -54,7 +54,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade'
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId
@@ -503,13 +503,20 @@ function ProjectCard({ project, index, onClick }: { project: typeof projects[num
}
function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice, facade }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
const openSavedProject = async () => {
const loaded = await facade.app.document.load('doc-pump-housing')
const [savedProjects, setSavedProjects] = useState<ProjectSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
void facade.project.list().then((items) => { if (!cancelled) setSavedProjects(items) }).catch((error: unknown) => { if (!cancelled) showNotice(`Project list unavailable: ${error instanceof Error ? error.message : String(error)}`) }).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [facade, showNotice])
const openSavedProject = async (documentId: string) => {
const loaded = await facade.app.document.load(documentId)
if (!loaded) { showNotice('No saved local snapshot found'); return }
showNotice(`Loaded ${loaded.label}`)
onOpenWorkspace()
}
return <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{projects.map((project, index) => <button className="project-list-row" key={project.name} onClick={() => void openSavedProject()}><div className={`list-thumbnail preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.name}</strong><span>{project.path}</span></div><span className="list-count">{project.objects} objects</span><span className="list-modified">{project.modified}</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge><span className="row-action" title="Open project"><ArrowRight size={16} /></span></button>)}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>3.4 GB of 10 GB used · Last backup just now</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
return <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{loading ? <div className="project-list-empty">Loading local projects</div> : savedProjects.length === 0 ? <div className="project-list-empty">No saved local projects</div> : savedProjects.map((project, index) => { const state = project.readOnly ? 'Read only' : project.dirty ? 'Unsaved' : 'Saved'; return <button className="project-list-row" key={project.documentId} onClick={() => void openSavedProject(project.documentId)}><div className={`list-thumbnail preview-${index % 4}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.label}</strong><span>Local / {project.documentId}</span></div><span className="list-count">{project.objectCount} objects</span><span className="list-modified">{new Date(project.updatedAt).toLocaleDateString()}</span><Badge tone={state === 'Unsaved' ? 'amber' : state === 'Read only' ? 'muted' : 'green'}>{state}</Badge><span className="row-action" title="Open project"><ArrowRight size={16} /></span></button> })}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>{savedProjects.length} saved project{savedProjects.length === 1 ? '' : 's'} · browser-managed capacity</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
}
function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {

View File

@@ -3,7 +3,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
export { ThreeViewportAdapter } from './threeViewport'
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'

View File

@@ -579,7 +579,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,

View File

@@ -1,9 +1,10 @@
import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema'
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource } from './types'
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSummary } from './types'
type PersistenceRequest =
| { id: number; type: 'initialize' }
| { id: number; type: 'list-projects' }
| { id: number; type: 'save-document'; document: DocumentSnapshot }
| { id: number; type: 'load-document'; documentId: string }
| { id: number; type: 'recovery-report'; documentId: string }
@@ -14,6 +15,7 @@ type PersistenceRequest =
type PersistenceResponse =
| { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities }
| { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] }
| { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] }
| { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null }
| { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport }
@@ -101,6 +103,21 @@ const saveDocument = (document: DocumentSnapshot) => {
return { documentId: document.id, documentVersion: document.version, persistedAt: now, mode: capabilities.mode }
}
const listProjects = (): ProjectSummary[] => {
if (!database) throw new Error('Persistence database is not initialized.')
const rows = database.exec({
sql: `SELECT documents.id AS document_id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at, COUNT(objects.id) AS object_count
FROM documents
JOIN projects ON projects.id = documents.project_id
LEFT JOIN objects ON objects.document_id = documents.id
GROUP BY documents.id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at
ORDER BY projects.updated_at DESC`,
rowMode: 'object',
returnValue: 'resultRows',
}) as Array<Record<string, string | number>>
return rows.map((row) => ({ documentId: String(row.document_id), label: String(row.label), documentVersion: Number(row.version), objectCount: Number(row.object_count), dirty: Boolean(row.dirty), readOnly: Boolean(row.read_only), updatedAt: Number(row.updated_at) }))
}
const loadDocument = (documentId: string): DocumentSnapshot | null => {
if (!database) throw new Error('Persistence database is not initialized.')
const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
@@ -208,6 +225,7 @@ const handle = async (request: PersistenceRequest): Promise<PersistenceResponse>
if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() }
if (request.type === 'dispose') { database?.close(); database = undefined; transientResources.clear(); return { id: request.id, ok: true, type: 'disposed' } }
await initialize()
if (request.type === 'list-projects') return { id: request.id, ok: true, type: 'projects-listed', projects: listProjects() }
if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } }
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) }

View File

@@ -1,9 +1,9 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, Unsubscribe } from './types'
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, Unsubscribe } from './types'
import { cloneSketch } from './sketcher'
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
@@ -12,6 +12,7 @@ export interface ProjectPersistenceClient {
initialize(): Promise<PersistenceCapabilities>
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
list(): Promise<ProjectSummary[]>
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
@@ -75,7 +76,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
private nextRequestId = 0
private currentCapabilities = unavailable
private initialized: Promise<PersistenceCapabilities> | null = null
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
private readonly writeQueue = new PersistenceWriteQueue()
@@ -109,13 +110,23 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
return this.initialized
}
async list() {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) return [...this.fallbackSnapshots.values()].map(({ document, savedAt }) => ({ documentId: document.id, label: document.label, documentVersion: document.version, objectCount: document.objects.length, dirty: document.dirty, readOnly: document.readOnly, updatedAt: savedAt })).sort((left, right) => right.updatedAt - left.updatedAt)
const response = await this.request({ type: 'list-projects' })
if (!response.ok || response.type !== 'projects-listed') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.projects.map((project) => ({ ...project }))
}
save(document: DocumentSnapshot) {
return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
if (!this.worker) {
const snapshot = cloneDocument(document)
this.fallbackSnapshots.set(snapshot.id, snapshot)
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode }
const persistedAt = Date.now()
this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt })
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode }
this.announceSaved(result.documentId, result.documentVersion)
return result
}
@@ -130,7 +141,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
async load(documentId: string) {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null }
if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot.document) : null }
const response = await this.request({ type: 'load-document', documentId })
if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.document
@@ -140,7 +151,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) {
const snapshot = this.fallbackSnapshots.get(documentId)
const snapshot = this.fallbackSnapshots.get(documentId)?.document
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
}
const response = await this.request({ type: 'recovery-report', documentId })

View File

@@ -93,6 +93,16 @@ export type ProjectSaveResult = {
mode: PersistenceCapabilities['mode']
}
export type ProjectSummary = {
documentId: string
label: string
documentVersion: number
objectCount: number
dirty: boolean
readOnly: boolean
updatedAt: number
}
export type ProjectRecoveryReport = {
documentId: string
mode: PersistenceCapabilities['mode']
@@ -393,6 +403,7 @@ export interface BitBybitWebCadFacade {
readonly project: {
capabilities(): PersistenceCapabilities
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
list(): Promise<ProjectSummary[]>
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>

View File

@@ -169,6 +169,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.project-list-row { width: 100%; min-height: 72px; display: grid; grid-template-columns: 58px minmax(180px, 1fr) 100px 105px 95px 32px; gap: 14px; align-items: center; padding: 7px 14px 7px 8px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; color: inherit; text-align: left; cursor: pointer; }
.project-list-row:last-child { border-bottom: 0; }
.project-list-row:hover { background: var(--bg-hover); }
.project-list-empty { min-height: 112px; display: grid; place-items: center; color: var(--text-muted); font-size: 12px; }
.list-thumbnail { width: 58px; height: 56px; border-radius: 3px; }
.list-thumbnail .preview-shape { width: 42px; height: 28px; }
.list-main strong, .list-main span { display: block; }

View File

@@ -672,6 +672,11 @@ test('project persistence remains behind the facade contract', async () => {
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 })
const saved = await facade.project.save()
assert.equal(saved.documentId, 'doc-pump-housing')
const projects = await facade.project.list()
assert.equal(projects.length, 1)
const { updatedAt, ...summary } = projects[0]
assert.ok(updatedAt > 0)
assert.deepEqual(summary, { documentId: 'doc-pump-housing', label: 'Pump Housing', documentVersion: saved.documentVersion, objectCount: 7, dirty: true, readOnly: false })
const loaded = await facade.project.load('doc-pump-housing')
assert.equal(loaded?.label, 'Pump Housing')
assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)