feat: add project recovery and resource governance
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user