From f3f763f94c0a4e402da5b3128f7020fce5120290 Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 27 Aug 2026 12:52:40 +0800 Subject: [PATCH] feat: persist direct edits with revision rebase --- test/workbench-edit-api.js | 17 +++++- workbench/server.js | 111 +++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/test/workbench-edit-api.js b/test/workbench-edit-api.js index 8380b4d..c7a710c 100644 --- a/test/workbench-edit-api.js +++ b/test/workbench-edit-api.js @@ -6,7 +6,7 @@ const os = require('os'); const path = require('path'); const { compileInput } = require('../src/compile/compiler'); const { ensureRevisionStore } = require('../src/compile/road-revisions'); -const { editState, editPreview } = require('../workbench/server'); +const { editPreview, editState, rebaseEdits, saveEdits } = require('../workbench/server'); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'workbench-edit-api-')); const input = { @@ -57,5 +57,20 @@ for (const tracked of trackedFiles) { assert.equal(fs.statSync(tracked.file).mtimeMs, tracked.mtimeMs, `${tracked.file} mtime unchanged by preview`); } +const saved = saveEdits(area, { expectedDocumentVersion: 0, document: state.document }); +assert.equal(saved.documentVersion, 1); +const beforeConflict = fs.readFileSync(initialized.paths.activeEdits); +assert.throws( + () => saveEdits(area, { expectedDocumentVersion: 0, document: state.document }), + (error) => error.statusCode === 409 && error.current.documentVersion === 1, +); +assert.deepEqual(fs.readFileSync(initialized.paths.activeEdits), beforeConflict, '409 does not write the document'); + +const checkpoint = require('../src/compile/road-revisions').createCheckpoint(workspace, 'Preview checkpoint'); +assert.equal(checkpoint.manifest.id, 'rev-0002'); +const rebased = rebaseEdits(area, checkpoint.manifest.id); +assert.equal(rebased.counts.exact, 0); +assert.deepEqual(rebased.constraints, []); + fs.rmSync(workspace, { recursive: true, force: true }); console.log('workbench edit API tests passed'); diff --git a/workbench/server.js b/workbench/server.js index cef6092..5a24367 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -15,9 +15,14 @@ const { generate, validateDocument, runtime } = require('../src/native-traffic-s const { convertGeoJson } = require('../src/reference/gaode'); const { exportNativeRoadPackage } = require('../src/export/native-road-package'); const { compileInput } = require('../src/compile/compiler'); -const { ensureRevisionStore, setActiveAreaConfig } = require('../src/compile/road-revisions'); +const { + createCheckpoint, + ensureRevisionStore, + readRevision, + setActiveAreaConfig, +} = require('../src/compile/road-revisions'); const { GEOMETRY_VERSION, resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver'); -const { loadEditDocument, validateEditDocument } = require('../src/compile/native-road-edits'); +const { loadEditDocument, saveEditDocument, validateEditDocument } = require('../src/compile/native-road-edits'); function startWorkbench({ area = null, @@ -90,8 +95,44 @@ function handle(request, response, session) { }) .then((value) => sendJson(response, 200, value)) .catch((error) => sendJson(response, 400, { ok: false, error: error.message })); + if (request.method === 'POST' && url.pathname === '/api/edits') + return readBody(request) + .then((body) => { + if (!area) throw new Error('请先导入 OSM 文件。'); + return saveEdits(area, body); + }) + .then((value) => sendJson(response, 200, value)) + .catch((error) => + sendJson(response, error.statusCode || 400, { + ok: false, + error: error.message, + ...(error.current ? { current: error.current } : {}), + }), + ); + if (request.method === 'POST' && url.pathname === '/api/revisions') + return readBody(request) + .then((body) => { + if (!area) throw new Error('请先导入 OSM 文件。'); + const revision = createCheckpoint(path.dirname(area.input), body.label); + return { ok: true, revision: revision.manifest, ...editState(area) }; + }) + .then((value) => sendJson(response, 200, value)) + .catch((error) => sendJson(response, 400, { ok: false, error: error.message })); + const rebase = /^\/api\/revisions\/(rev-\d{4})\/rebase$/.exec(url.pathname); + if (request.method === 'POST' && rebase) + return Promise.resolve() + .then(() => { + if (!area) throw new Error('请先导入 OSM 文件。'); + return rebaseEdits(area, rebase[1]); + }) + .then((value) => sendJson(response, 200, value)) + .catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === 'GET' && url.pathname === '/api/session') - return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null }); + return sendJson(response, 200, { + active: Boolean(session.area), + areaId: session.area?.id || null, + ...(session.area ? activeRevisionFields(session.area) : {}), + }); if (request.method === 'POST' && url.pathname === '/api/import') return readUpload(request, session) .then((result) => @@ -190,7 +231,7 @@ function handle(request, response, session) { function state(area, junctionReference = null, debug = false) { // This is the read entry point for both a fresh import and a reopened legacy // workspace. Initialization only adds the v2 layout; it never rewrites v1 files. - ensureRevisionStore(path.dirname(area.input), area.nativeRoad); + const revisionStore = ensureRevisionStore(path.dirname(area.input), area.nativeRoad); const nativeDir = area.outputs.nativeRoadDir; const osm2streetsRoadSurface = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, 'road_surface.geojson') @@ -206,6 +247,8 @@ function state(area, junctionReference = null, debug = false) { const compiled = readCompiled(area); return { areaId: area.id, + activeRevisionId: revisionStore.active.activeRevisionId, + documentVersion: revisionStore.active.documentVersion, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), @@ -232,6 +275,11 @@ function state(area, junctionReference = null, debug = false) { }; } +function activeRevisionFields(area) { + const store = ensureRevisionStore(path.dirname(area.input), area.nativeRoad); + return { activeRevisionId: store.active.activeRevisionId, documentVersion: store.active.documentVersion }; +} + function editState(area) { const workspace = path.dirname(area.input); const initialized = ensureRevisionStore(workspace, area.nativeRoad); @@ -308,6 +356,59 @@ function editPreview(area, body = {}) { }, }; } + +function saveEdits(area, body = {}) { + const workspace = path.dirname(area.input); + const initialized = ensureRevisionStore(workspace, area.nativeRoad); + const current = loadEditDocument(initialized.paths.activeEdits); + if (!Number.isInteger(body.expectedDocumentVersion)) throw new Error('expectedDocumentVersion must be an integer.'); + if (body.expectedDocumentVersion !== current.documentVersion) { + const error = new Error('编辑文档版本已变化,请刷新后重试。'); + error.statusCode = 409; + error.current = { documentVersion: current.documentVersion }; + throw error; + } + const candidate = body.document + ? { ...body.document, documentVersion: current.documentVersion } + : { + ...current, + ...(Array.isArray(body.constraints) ? { constraints: body.constraints } : {}), + ...(Array.isArray(body.operations) ? { operations: body.operations } : {}), + documentVersion: current.documentVersion, + }; + const saved = saveEditDocument(initialized.paths.activeEdits, candidate); + writeJsonAtomic(initialized.paths.activeState, { + ...initialized.active, + documentVersion: saved.documentVersion, + }); + return { + ok: true, + document: saved, + documentVersion: saved.documentVersion, + activeRevisionId: initialized.active.activeRevisionId, + }; +} + +function rebaseEdits(area, revisionId) { + const workspace = path.dirname(area.input); + const revision = readRevision(workspace, revisionId); + const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides)); + const resolution = resolveDirectEditConstraints(model, revision.directEdits, { + revisionId, + compilerGeometryVersion: GEOMETRY_VERSION, + }); + const counts = Object.fromEntries( + ['exact', 'recheck', 'pending', 'conflicted', 'stale'].map((status) => [status, 0]), + ); + for (const state of resolution.constraintStates) counts[state.status] = (counts[state.status] || 0) + 1; + return { + ok: true, + revisionId, + counts, + constraints: resolution.constraintStates, + diagnostics: resolution.diagnostics, + }; +} // The compiler reports candidates as advisory diagnostics. Lift them into their // own payload with a stable index so the map can label them "#1, #2, ..." and // the inspector can offer a ready-to-paste cluster配置. @@ -525,4 +626,4 @@ function sendJson(response, status, value) { response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); response.end(`${JSON.stringify(value)}\n`); } -module.exports = { startWorkbench, readMultipart, editState, editPreview }; +module.exports = { startWorkbench, readMultipart, editState, editPreview, saveEdits, rebaseEdits };