P2-03: manage hashed OPFS project resources
This commit is contained in:
@@ -1591,6 +1591,7 @@ P0 基线/治理
|
||||
| P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker |
|
||||
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 |
|
||||
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 |
|
||||
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
|
||||
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 |
|
||||
|
||||
@@ -1606,6 +1607,7 @@ P0 基线/治理
|
||||
| SQLite WASM Worker | `PASS` | 构建产物包含 `persistenceWorker`、SQLite Worker、OPFS proxy 和 `sqlite3.wasm` |
|
||||
| OPFS VFS | `PASS` | Chrome `crossOriginIsolated=true`、`navigator.storage.getDirectory()` 可用 |
|
||||
| 数据库创建 | `PASS` | 保存文档后 OPFS 根目录出现 `bitbybit-project.sqlite3` |
|
||||
| OPFS 资源对象 | `PASS` | Worker 写入/读取 5 字节资源,重复写入复用 hash,释放两次后资源目录为空 |
|
||||
| Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 |
|
||||
| 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 |
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ export { createMockFacade } from './mockFacade'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectSaveResult, TaskSnapshot } from './types'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types'
|
||||
|
||||
@@ -128,7 +128,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), 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(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId) },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities } from './types'
|
||||
import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities, ProjectResource } from './types'
|
||||
|
||||
type PersistenceRequest =
|
||||
| { id: number; type: 'initialize' }
|
||||
| { id: number; type: 'save-document'; document: DocumentSnapshot }
|
||||
| { id: number; type: 'load-document'; 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 }
|
||||
| { id: number; type: 'dispose' }
|
||||
|
||||
type PersistenceResponse =
|
||||
| { 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: '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 }
|
||||
|
||||
@@ -23,10 +29,24 @@ type WorkerScope = {
|
||||
const workerScope = globalThis as unknown as WorkerScope
|
||||
let sqlite3: Sqlite3Static | undefined
|
||||
let database: Database | undefined
|
||||
let assetsDirectory: FileSystemDirectoryHandle | undefined
|
||||
let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' }
|
||||
|
||||
const cloneTree = (tree: ModelTreeItem[]) => tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined }))
|
||||
|
||||
const getAssetsDirectory = async () => {
|
||||
if (assetsDirectory) return assetsDirectory
|
||||
if (!('storage' in navigator) || !navigator.storage.getDirectory) throw new Error('OPFS is unavailable for resource storage.')
|
||||
const root = await navigator.storage.getDirectory()
|
||||
assetsDirectory = await root.getDirectoryHandle('bitbybit-assets', { create: true })
|
||||
return assetsDirectory
|
||||
}
|
||||
|
||||
const hashBytes = async (bytes: ArrayBuffer) => {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const initialize = async (): Promise<PersistenceCapabilities> => {
|
||||
if (database) return capabilities
|
||||
sqlite3 = await sqlite3InitModule()
|
||||
@@ -92,13 +112,56 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
|
||||
}
|
||||
}
|
||||
|
||||
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
const hash = await hashBytes(bytes)
|
||||
const directory = await getAssetsDirectory()
|
||||
const handle = await directory.getFileHandle(hash, { create: true })
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(bytes)
|
||||
await writable.close()
|
||||
const now = Date.now()
|
||||
database.exec({ sql: 'INSERT INTO resources(hash, path, byte_length, media_type, ref_count, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?) ON CONFLICT(hash) DO UPDATE SET ref_count=resources.ref_count + 1, updated_at=excluded.updated_at', bind: [hash, `bitbybit-assets/${hash}`, bytes.byteLength, mediaType, now, now] })
|
||||
const resources = database.exec({ sql: 'SELECT hash, byte_length, media_type, ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const row = resources[0]
|
||||
return { hash, byteLength: Number(row.byte_length), mediaType: String(row.media_type), refCount: Number(row.ref_count) }
|
||||
}
|
||||
|
||||
const getResource = async (hash: string): Promise<ArrayBuffer | null> => {
|
||||
const directory = await getAssetsDirectory()
|
||||
try {
|
||||
const handle = await directory.getFileHandle(hash)
|
||||
return (await handle.getFile()).arrayBuffer()
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'NotFoundError') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const releaseResource = async (hash: string) => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
const resources = database.exec({ sql: 'SELECT ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const row = resources[0]
|
||||
if (!row) return
|
||||
if (Number(row.ref_count) > 1) {
|
||||
database.exec({ sql: 'UPDATE resources SET ref_count = ref_count - 1, updated_at = ? WHERE hash = ?', bind: [Date.now(), hash] })
|
||||
return
|
||||
}
|
||||
database.exec({ sql: 'DELETE FROM resources WHERE hash = ?', bind: [hash] })
|
||||
try { await (await getAssetsDirectory()).removeEntry(hash) } catch (error) { if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error }
|
||||
}
|
||||
|
||||
const handle = async (request: PersistenceRequest): Promise<PersistenceResponse> => {
|
||||
try {
|
||||
if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() }
|
||||
if (request.type === 'dispose') { database?.close(); database = undefined; return { id: request.id, ok: true, type: 'disposed' } }
|
||||
await initialize()
|
||||
if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } }
|
||||
return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
|
||||
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
|
||||
if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) }
|
||||
if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) }
|
||||
if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } }
|
||||
throw new Error('Unknown persistence request.')
|
||||
} catch (error) {
|
||||
return { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectSaveResult } from './types'
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types'
|
||||
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string }
|
||||
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: 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: 'disposed' } | { id: number; ok: false; error: string }
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; 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'; 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: '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 })) })
|
||||
@@ -12,6 +12,11 @@ export interface ProjectPersistenceClient {
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
resource: {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
release(hash: string): Promise<void>
|
||||
}
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -36,6 +41,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private currentCapabilities = unavailable
|
||||
private initialized: Promise<PersistenceCapabilities> | null = null
|
||||
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
|
||||
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()
|
||||
|
||||
@@ -75,6 +81,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
return response.document
|
||||
}
|
||||
|
||||
readonly resource = {
|
||||
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => {
|
||||
await this.initialize()
|
||||
if (!this.worker) {
|
||||
const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||
const existing = this.fallbackResources.get(hash)
|
||||
const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 }
|
||||
this.fallbackResources.set(hash, { resource, bytes: new Uint8Array(bytes) })
|
||||
return { ...resource }
|
||||
}
|
||||
const payload = bytes.slice()
|
||||
const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer])
|
||||
if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.resource
|
||||
}),
|
||||
get: async (hash: string) => {
|
||||
await this.writeQueue.drain()
|
||||
await this.initialize()
|
||||
if (!this.worker) { const resource = this.fallbackResources.get(hash); return resource ? new Uint8Array(resource.bytes) : null }
|
||||
const response = await this.request({ type: 'get-resource', hash })
|
||||
if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.bytes ? new Uint8Array(response.bytes) : null
|
||||
},
|
||||
release: (hash: string) => this.writeQueue.run(async () => {
|
||||
await this.initialize()
|
||||
if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return }
|
||||
const response = await this.request({ type: 'release-resource', hash })
|
||||
if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
}),
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.writeQueue.drain()
|
||||
if (!this.worker) return
|
||||
@@ -82,12 +119,12 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
this.worker.terminate()
|
||||
}
|
||||
|
||||
private request(input: WorkerInput) {
|
||||
private request(input: WorkerInput, transfer: Transferable[] = []) {
|
||||
if (!this.worker) return Promise.reject<WorkerResponse>(new Error(this.currentCapabilities.reason))
|
||||
const id = ++this.nextRequestId
|
||||
return new Promise<WorkerResponse>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.worker?.postMessage({ ...input, id } satisfies WorkerRequest)
|
||||
this.worker?.postMessage({ ...input, id } satisfies WorkerRequest, transfer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ export type ProjectSaveResult = {
|
||||
mode: PersistenceCapabilities['mode']
|
||||
}
|
||||
|
||||
export type ProjectResource = {
|
||||
hash: string
|
||||
byteLength: number
|
||||
mediaType: string
|
||||
refCount: number
|
||||
}
|
||||
|
||||
export type CommandState = {
|
||||
id: string
|
||||
status: 'hidden' | 'disabled' | 'enabled' | 'active'
|
||||
@@ -142,6 +149,11 @@ export interface BitBybitWebCadFacade {
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
resource: {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
release(hash: string): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly viewport: {
|
||||
createAdapter(): BitBybitViewportAdapter
|
||||
|
||||
@@ -112,6 +112,21 @@ test('project persistence remains behind the facade contract', async () => {
|
||||
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
|
||||
})
|
||||
|
||||
test('project resources use content identity and reference counting', async () => {
|
||||
const facade = createMockFacade()
|
||||
const bytes = new Uint8Array([1, 2, 3, 5, 8])
|
||||
const first = await facade.project.resource.put(bytes, 'application/octet-stream')
|
||||
const second = await facade.project.resource.put(bytes, 'application/octet-stream')
|
||||
assert.equal(first.hash, second.hash)
|
||||
assert.equal(second.refCount, 2)
|
||||
bytes[0] = 99
|
||||
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
||||
await facade.project.resource.release(first.hash)
|
||||
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
||||
await facade.project.resource.release(first.hash)
|
||||
assert.equal(await facade.project.resource.get(first.hash), null)
|
||||
})
|
||||
|
||||
test('Part Design feature tasks commit a document object and remain undoable', () => {
|
||||
const facade = createMockFacade()
|
||||
const before = facade.app.document.getActive()
|
||||
|
||||
Reference in New Issue
Block a user