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

@@ -2,13 +2,14 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { strToU8, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
@@ -109,12 +110,31 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', ()
})
test('project schema is versioned and covers the FreeCAD document graph', () => {
assert.equal(PROJECT_SCHEMA_VERSION, 4)
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4])
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
assert.equal(PROJECT_SCHEMA_VERSION, 5)
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5])
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
})
test('project migrations are ordered, skip applied versions and roll back atomically', () => {
const calls: string[] = []
const applied = new Set([1])
const transaction = {
begin: () => { calls.push('begin') },
isApplied: (version: number) => applied.has(version),
execute: (sql: string) => { calls.push(`execute:${sql}`); if (sql === 'broken') throw new Error('migration failed') },
markApplied: (version: number) => { calls.push(`mark:${version}`); applied.add(version) },
commit: () => { calls.push('commit') },
rollback: () => { calls.push('rollback') },
}
assert.deepEqual(runProjectSchemaMigrations(transaction, [{ version: 2, sql: 'second' }, { version: 1, sql: 'first' }, { version: 3, sql: 'third' }], 123), [2, 3])
assert.deepEqual(calls, ['begin', 'execute:second', 'mark:2', 'execute:third', 'mark:3', 'commit'])
calls.length = 0
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 4, sql: 'fourth' }, { version: 5, sql: 'broken' }], 124), /migration failed/)
assert.deepEqual(calls, ['begin', 'execute:fourth', 'mark:4', 'execute:broken', 'rollback'])
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 6, sql: 'one' }, { version: 6, sql: 'duplicate' }]), /duplicate/)
})
test('quantity expressions convert units and reject incompatible dimensions', () => {
const result = evaluateQuantityExpression('1 in + 2 mm')
assert.equal(result.value.dimension, 'length')
@@ -737,6 +757,28 @@ test('persistence writes are serialized and continue after a failed write', asyn
assert.equal(results[2].status, 'fulfilled')
})
test('persistence write queue preserves all 1000 transaction slots under repeated failures', async () => {
const queue = new PersistenceWriteQueue()
const order: number[] = []
let inFlight = 0
let maximumInFlight = 0
const writes = Array.from({ length: 1000 }, (_, index) => queue.run(async () => {
inFlight += 1
maximumInFlight = Math.max(maximumInFlight, inFlight)
await Promise.resolve()
order.push(index)
inFlight -= 1
if (index % 137 === 0) throw new Error(`expected failure ${index}`)
return index
}))
const results = await Promise.allSettled(writes)
await queue.drain()
assert.equal(maximumInFlight, 1)
assert.deepEqual(order, Array.from({ length: 1000 }, (_, index) => index))
assert.equal(results.filter((result) => result.status === 'rejected').length, 8)
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 992)
})
test('project persistence broadcasts saved document versions across clients', async () => {
const writer = createSqliteProjectPersistence()
const observer = createSqliteProjectPersistence()
@@ -1006,6 +1048,21 @@ test('recovery report identifies missing snapshots in the transient fallback sto
assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning)))
})
test('project recovery checkpoints retain the latest five document versions', async () => {
const persistence = createSqliteProjectPersistence()
const base = createMockFacade().app.document.getActive()
for (let version = 1; version <= 7; version += 1) await persistence.save({ ...base, version, label: `Checkpoint ${version}` })
const recovery = await persistence.recovery(base.id)
assert.deepEqual(recovery.checkpoints.map((checkpoint) => checkpoint.version), [7, 6, 5, 4, 3])
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
assert.equal(await persistence.loadCheckpoint(base.id, 2), null)
assert.equal((await persistence.loadCheckpoint(base.id))?.version, 7)
const restored = await persistence.loadCheckpoint(base.id, 4)
if (restored) restored.label = 'mutated clone'
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
await persistence.dispose()
})
test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => {
const documentXml = `<?xml version="1.0" encoding="UTF-8"?>
<Document SchemaVersion="4">
@@ -1073,6 +1130,28 @@ test('project resources use content identity and reference counting', async () =
assert.equal(await facade.project.resource.get(first.hash), null)
})
test('resource quota policy reserves capacity and sweep planning is conservative', () => {
assert.deepEqual(assessResourceQuota({ usage: 600, quota: 1000 }, 300, 0.05), { allowed: true, known: true, usage: 600, quota: 1000, incomingBytes: 300, reservedBytes: 50, availableBytes: 350, reason: undefined })
const denied = assessResourceQuota({ usage: 600, quota: 1000 }, 351, 0.05)
assert.equal(denied.allowed, false)
assert.match(denied.reason || '', /only 350 bytes/)
assert.equal(assessResourceQuota({}, 10).known, false)
assert.deepEqual(planResourceSweep([
{ hash: 'active', byteLength: 10, refCount: 2 },
{ hash: 'zero', byteLength: 20, refCount: 0 },
{ hash: 'missing', byteLength: 30, refCount: 1 },
], ['active', 'zero', 'orphan']), { deleteFileHashes: ['orphan', 'zero'], deleteRecordHashes: ['zero'], missingFileHashes: ['missing'] })
})
test('resource sweep is exposed through the project facade in fallback mode', async () => {
const facade = createMockFacade()
await facade.project.resource.put(new Uint8Array([1, 2, 3]), 'application/octet-stream')
const report = await facade.project.resource.sweep()
assert.equal(report.inspectedRecords, 1)
assert.equal(report.deletedFiles, 0)
assert.match(report.warnings[0], /no separately enumerable orphan files/)
})
test('Part Design feature tasks commit a document object and remain undoable', () => {
const facade = createMockFacade()
const before = facade.app.document.getActive()