feat: add project recovery and resource governance

This commit is contained in:
2026-08-03 00:13:13 -04:00
parent 63446d5700
commit 6bfbdf5057
12 changed files with 334 additions and 39 deletions

View File

@@ -3,8 +3,11 @@ export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
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, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'

View File

@@ -712,7 +712,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
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() } },
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
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 },
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), 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,27 +1,31 @@
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, ProjectSummary } from './types'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
import { assessResourceQuota, planResourceSweep, type ResourceSweepRecord } from './resourcePolicy'
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, 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: 'load-checkpoint'; documentId: string; version?: number }
| { id: number; type: '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 }
| { id: number; type: 'sweep-resources' }
| { id: number; type: 'dispose' }
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: 'loaded' | 'checkpoint-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: 'resources-swept'; report: ProjectResourceSweepReport }
| { id: number; ok: true; type: 'disposed' }
| { id: number; ok: false; error: string }
@@ -64,20 +68,14 @@ const initialize = async (): Promise<PersistenceCapabilities> => {
capabilities = { mode: 'sqlite-memory', sqliteWasm: true, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: error instanceof Error ? `OPFS initialization failed: ${error.message}` : 'OPFS initialization failed.' }
}
database.exec('PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);')
database.exec('BEGIN;')
try {
for (const migration of PROJECT_SCHEMA_MIGRATIONS) {
const applied = database.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [migration.version], returnValue: 'resultRows' }) as unknown[]
if (applied.length === 0) {
database.exec(migration.sql)
database.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [migration.version, Date.now()] })
}
}
database.exec('COMMIT;')
} catch (error) {
database.exec('ROLLBACK;')
throw error
}
runProjectSchemaMigrations({
begin: () => { database?.exec('BEGIN;') },
isApplied: (version) => (database?.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [version], returnValue: 'resultRows' }) as unknown[]).length > 0,
execute: (sql) => { database?.exec(sql) },
markApplied: (version, appliedAt) => { database?.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [version, appliedAt] }) },
commit: () => { database?.exec('COMMIT;') },
rollback: () => { database?.exec('ROLLBACK;') },
}, PROJECT_SCHEMA_MIGRATIONS)
return capabilities
}
@@ -95,6 +93,8 @@ const saveDocument = (document: DocumentSnapshot) => {
for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] })
database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] })
for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] })
database.exec({ sql: 'INSERT INTO document_checkpoints(document_id, version, snapshot_json, created_at) VALUES(?, ?, ?, ?) ON CONFLICT(document_id, version) DO UPDATE SET snapshot_json=excluded.snapshot_json, created_at=excluded.created_at', bind: [document.id, document.version, JSON.stringify(document), now] })
database.exec({ sql: 'DELETE FROM document_checkpoints WHERE document_id = ? AND version NOT IN (SELECT version FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC LIMIT 5)', bind: [document.id, document.id] })
database.exec('COMMIT;')
} catch (error) {
database.exec('ROLLBACK;')
@@ -158,10 +158,19 @@ const recoveryReport = (documentId: string): ProjectRecoveryReport => {
const integrityRows = database.exec({ sql: 'PRAGMA integrity_check', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
const integrityValue = String(integrityRows[0]?.integrity_check ?? '')
const documents = database.exec({ sql: 'SELECT version, dirty FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
const checkpoints = database.exec({ sql: 'SELECT version, created_at FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
const warnings: string[] = []
if (!documents[0]) warnings.push('No saved document exists for this ID.')
if (integrityValue !== 'ok') warnings.push(`SQLite integrity check returned: ${integrityValue || 'no result'}.`)
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, warnings }
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, checkpoints: checkpoints.map((checkpoint) => ({ version: Number(checkpoint.version), createdAt: Number(checkpoint.created_at) })), warnings }
}
const loadCheckpoint = (documentId: string, version?: number): DocumentSnapshot | null => {
if (!database) throw new Error('Persistence database is not initialized.')
const rows = version === undefined
? database.exec({ sql: 'SELECT snapshot_json FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC LIMIT 1', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
: database.exec({ sql: 'SELECT snapshot_json FROM document_checkpoints WHERE document_id = ? AND version = ?', bind: [documentId, version], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
return rows[0]?.snapshot_json ? JSON.parse(String(rows[0].snapshot_json)) as DocumentSnapshot : null
}
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
@@ -173,7 +182,20 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<Proje
transientResources.set(hash, { resource, bytes: new Uint8Array(bytes.slice(0)) })
return { ...resource }
}
const existingRows = 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 directory = await getAssetsDirectory()
if (existingRows[0]) {
try {
await directory.getFileHandle(hash)
database.exec({ sql: 'UPDATE resources SET ref_count = ref_count + 1, updated_at = ? WHERE hash = ?', bind: [Date.now(), hash] })
const existing = existingRows[0]
return { hash, byteLength: Number(existing.byte_length), mediaType: String(existing.media_type), refCount: Number(existing.ref_count) + 1 }
} catch (error) {
if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error
}
}
const quota = assessResourceQuota(navigator.storage.estimate ? await navigator.storage.estimate() : {}, bytes.byteLength)
if (!quota.allowed) throw new DOMException(quota.reason, 'QuotaExceededError')
const handle = await directory.getFileHandle(hash, { create: true })
const writable = await handle.createWritable()
await writable.write(bytes)
@@ -185,6 +207,44 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<Proje
return { hash, byteLength: Number(row.byte_length), mediaType: String(row.media_type), refCount: Number(row.ref_count) }
}
type IterableDirectoryHandle = FileSystemDirectoryHandle & { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }
const sweepResources = async (): Promise<ProjectResourceSweepReport> => {
if (!database) throw new Error('Persistence database is not initialized.')
if (!capabilities.opfs) return { inspectedRecords: transientResources.size, inspectedFiles: transientResources.size, deletedRecords: 0, deletedFiles: 0, reclaimedBytes: 0, missingFiles: [], warnings: ['SQLite memory mode has no separately enumerable orphan files.'] }
const rows = database.exec({ sql: 'SELECT hash, byte_length, ref_count FROM resources', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
const records: ResourceSweepRecord[] = rows.map((row) => ({ hash: String(row.hash), byteLength: Number(row.byte_length), refCount: Number(row.ref_count) }))
const directory = await getAssetsDirectory() as IterableDirectoryHandle
const fileSizes = new Map<string, number>()
for await (const [name, handle] of directory.entries()) {
if (handle.kind !== 'file') continue
try { fileSizes.set(name, (await (handle as FileSystemFileHandle).getFile()).size) } catch { fileSizes.set(name, 0) }
}
const plan = planResourceSweep(records, fileSizes.keys())
let deletedFiles = 0
let reclaimedBytes = 0
const warnings: string[] = []
for (const hash of plan.deleteFileHashes) {
try {
await directory.removeEntry(hash)
deletedFiles += 1
reclaimedBytes += fileSizes.get(hash) ?? 0
} catch (error) {
warnings.push(`Unable to delete orphan resource ${hash}: ${error instanceof Error ? error.message : String(error)}`)
}
}
database.exec('BEGIN;')
try {
for (const hash of plan.deleteRecordHashes) database.exec({ sql: 'DELETE FROM resources WHERE hash = ?', bind: [hash] })
database.exec('COMMIT;')
} catch (error) {
database.exec('ROLLBACK;')
throw error
}
if (plan.missingFileHashes.length > 0) warnings.push(`${plan.missingFileHashes.length} referenced resource file(s) are missing from OPFS.`)
return { inspectedRecords: records.length, inspectedFiles: fileSizes.size, deletedRecords: plan.deleteRecordHashes.length, deletedFiles, reclaimedBytes, missingFiles: plan.missingFileHashes, warnings }
}
const getResource = async (hash: string): Promise<ArrayBuffer | null> => {
if (!capabilities.opfs) {
const resource = transientResources.get(hash)
@@ -228,10 +288,12 @@ const handle = async (request: PersistenceRequest): Promise<PersistenceResponse>
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 === 'load-checkpoint') return { id: request.id, ok: true, type: 'checkpoint-loaded', document: loadCheckpoint(request.documentId, request.version) }
if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(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' } }
if (request.type === 'sweep-resources') return { id: request.id, ok: true, type: 'resources-swept', report: await sweepResources() }
throw new Error('Unknown persistence request.')
} catch (error) {
return { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) }

View File

@@ -1,4 +1,42 @@
export const PROJECT_SCHEMA_VERSION = 4
export const PROJECT_SCHEMA_VERSION = 5
export type ProjectSchemaMigration = { version: number; sql: string }
export type ProjectMigrationTransaction = {
begin(): void
isApplied(version: number): boolean
execute(sql: string): void
markApplied(version: number, appliedAt: number): void
commit(): void
rollback(): void
}
export const runProjectSchemaMigrations = (
transaction: ProjectMigrationTransaction,
migrations: readonly ProjectSchemaMigration[] = PROJECT_SCHEMA_MIGRATIONS,
appliedAt = Date.now(),
) => {
const versions = new Set<number>()
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.version) || migration.version < 1 || versions.has(migration.version)) throw new RangeError(`Invalid or duplicate project schema migration version: ${migration.version}.`)
versions.add(migration.version)
}
transaction.begin()
const applied: number[] = []
try {
for (const migration of [...migrations].sort((left, right) => left.version - right.version)) {
if (transaction.isApplied(migration.version)) continue
transaction.execute(migration.sql)
transaction.markApplied(migration.version, appliedAt)
applied.push(migration.version)
}
transaction.commit()
return applied
} catch (error) {
transaction.rollback()
throw error
}
}
export const PROJECT_SCHEMA_SQL = `
PRAGMA foreign_keys = ON;
@@ -82,6 +120,18 @@ CREATE TABLE IF NOT EXISTS resources (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS document_checkpoints (
document_id TEXT NOT NULL,
version INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (document_id, version),
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkpoints_document_created
ON document_checkpoints(document_id, created_at DESC);
`
export const PROJECT_SCHEMA_MIGRATIONS = [
@@ -89,4 +139,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [
{ version: 2, sql: `ALTER TABLE documents ADD COLUMN recompute_json TEXT NOT NULL DEFAULT '{"generation":0,"status":"idle","objectStates":{},"dirtyObjects":[],"order":[],"errors":[]}';` },
{ version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' },
{ version: 4, sql: 'ALTER TABLE objects ADD COLUMN sketch_json TEXT;' },
{ version: 5, sql: 'CREATE TABLE IF NOT EXISTS document_checkpoints (document_id TEXT NOT NULL, version INTEGER NOT NULL, snapshot_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (document_id, version), FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS checkpoints_document_created ON document_checkpoints(document_id, created_at DESC);' },
] as const

View File

@@ -1,9 +1,9 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
import { cloneSketch } from './sketcher'
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 }
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { 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' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { 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' | 'checkpoint-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: 'resources-swept'; report: ProjectResourceSweepReport } | { 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 clonePropertyValue = (value: PropertyValue): PropertyValue => {
@@ -22,11 +22,13 @@ export interface ProjectPersistenceClient {
list(): Promise<ProjectSummary[]>
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
loadCheckpoint(documentId: string, version?: number): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null>
release(hash: string): Promise<void>
sweep(): Promise<ProjectResourceSweepReport>
}
dispose(): Promise<void>
}
@@ -84,6 +86,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
private currentCapabilities = unavailable
private initialized: Promise<PersistenceCapabilities> | null = null
private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
private readonly fallbackCheckpoints = new Map<string, Map<number, { 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()
@@ -133,6 +136,10 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
const snapshot = cloneDocument(document)
const persistedAt = Date.now()
this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt })
const checkpoints = this.fallbackCheckpoints.get(snapshot.id) ?? new Map()
checkpoints.set(snapshot.version, { document: cloneDocument(snapshot), savedAt: persistedAt })
const retained = [...checkpoints.entries()].sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0]).slice(0, 5)
this.fallbackCheckpoints.set(snapshot.id, new Map(retained))
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode }
this.announceSaved(result.documentId, result.documentVersion)
return result
@@ -159,13 +166,29 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
await this.initialize()
if (!this.worker) {
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 checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])].map(([version, value]) => ({ version, createdAt: value.savedAt })).sort((left, right) => right.createdAt - left.createdAt || right.version - left.version)
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, checkpoints, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
}
const response = await this.request({ type: 'recovery-report', documentId })
if (!response.ok || response.type !== 'recovery-report') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.report
}
async loadCheckpoint(documentId: string, version?: number) {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) {
const checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])]
const selected = version === undefined
? checkpoints.sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0])[0]?.[1]
: this.fallbackCheckpoints.get(documentId)?.get(version)
return selected ? cloneDocument(selected.document) : null
}
const response = await this.request({ type: 'load-checkpoint', documentId, version })
if (!response.ok || response.type !== 'checkpoint-loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.document
}
readonly resource = {
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
@@ -195,6 +218,13 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
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)
})),
sweep: () => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
await this.initialize()
if (!this.worker) return { inspectedRecords: this.fallbackResources.size, inspectedFiles: this.fallbackResources.size, deletedRecords: 0, deletedFiles: 0, reclaimedBytes: 0, missingFiles: [], warnings: ['Transient resource storage has no separately enumerable orphan files.'] }
const response = await this.request({ type: 'sweep-resources' })
if (!response.ok || response.type !== 'resources-swept') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.report
})),
}
async dispose() {

View File

@@ -0,0 +1,48 @@
export type ResourceQuotaAssessment = {
allowed: boolean
known: boolean
usage: number | null
quota: number | null
incomingBytes: number
reservedBytes: number
availableBytes: number | null
reason?: string
}
export const assessResourceQuota = (
estimate: { usage?: number; quota?: number },
incomingBytes: number,
reserveRatio = 0.05,
): ResourceQuotaAssessment => {
if (!Number.isSafeInteger(incomingBytes) || incomingBytes < 0) throw new RangeError('Resource byte length must be a non-negative safe integer.')
if (!Number.isFinite(reserveRatio) || reserveRatio < 0 || reserveRatio >= 1) throw new RangeError('Resource reserve ratio must be between 0 and 1.')
const usage = typeof estimate.usage === 'number' && Number.isFinite(estimate.usage) && estimate.usage >= 0 ? estimate.usage : null
const quota = typeof estimate.quota === 'number' && Number.isFinite(estimate.quota) && estimate.quota >= 0 ? estimate.quota : null
if (usage === null || quota === null) return { allowed: true, known: false, usage, quota, incomingBytes, reservedBytes: 0, availableBytes: null, reason: 'Browser storage quota is unavailable; write errors remain authoritative.' }
const reservedBytes = Math.ceil(quota * reserveRatio)
const availableBytes = Math.max(0, quota - usage - reservedBytes)
const allowed = incomingBytes <= availableBytes
return { allowed, known: true, usage, quota, incomingBytes, reservedBytes, availableBytes, reason: allowed ? undefined : `Resource requires ${incomingBytes} bytes but only ${availableBytes} bytes remain after the storage reserve.` }
}
export type ResourceSweepRecord = { hash: string; byteLength: number; refCount: number }
export type ResourceSweepPlan = {
deleteFileHashes: string[]
deleteRecordHashes: string[]
missingFileHashes: string[]
}
export const planResourceSweep = (records: ResourceSweepRecord[], storedHashes: Iterable<string>): ResourceSweepPlan => {
const recordByHash = new Map<string, ResourceSweepRecord>()
for (const record of records) {
if (!record.hash || !Number.isSafeInteger(record.byteLength) || record.byteLength < 0 || !Number.isSafeInteger(record.refCount) || record.refCount < 0) throw new RangeError('Resource sweep records require a hash and non-negative integer sizes/counts.')
if (recordByHash.has(record.hash)) throw new RangeError(`Duplicate resource sweep record: ${record.hash}.`)
recordByHash.set(record.hash, record)
}
const files = new Set(storedHashes)
const deleteRecordHashes = records.filter((record) => record.refCount === 0).map((record) => record.hash).sort()
const deleteFileHashes = [...files].filter((hash) => !recordByHash.has(hash) || recordByHash.get(hash)?.refCount === 0).sort()
const missingFileHashes = records.filter((record) => record.refCount > 0 && !files.has(record.hash)).map((record) => record.hash).sort()
return { deleteFileHashes, deleteRecordHashes, missingFileHashes }
}

View File

@@ -129,6 +129,7 @@ export type ProjectRecoveryReport = {
integrity: 'ok' | 'failed' | 'unavailable'
lastSavedVersion: number | null
dirtyAtLastSave: boolean | null
checkpoints: Array<{ version: number; createdAt: number }>
warnings: string[]
}
@@ -139,6 +140,16 @@ export type ProjectResource = {
refCount: number
}
export type ProjectResourceSweepReport = {
inspectedRecords: number
inspectedFiles: number
deletedRecords: number
deletedFiles: number
reclaimedBytes: number
missingFiles: string[]
warnings: string[]
}
export type GeometryCapabilities = {
provider: 'Bitbybit OCCT'
version: '1.1.1'
@@ -459,6 +470,7 @@ export interface BitBybitWebCadFacade {
list(): Promise<ProjectSummary[]>
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
loadCheckpoint(documentId: string, version?: number): Promise<DocumentSnapshot | null>
recovery(documentId: string): Promise<ProjectRecoveryReport>
fcstd: {
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
@@ -467,6 +479,7 @@ export interface BitBybitWebCadFacade {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null>
release(hash: string): Promise<void>
sweep(): Promise<ProjectResourceSweepReport>
}
}
readonly geometry: {