feat: add project recovery and resource governance
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
48
src/facade/resourcePolicy.ts
Normal file
48
src/facade/resourcePolicy.ts
Normal 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 }
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user