feat: persist direct edits with revision rebase
This commit is contained in:
@@ -6,7 +6,7 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { compileInput } = require('../src/compile/compiler');
|
const { compileInput } = require('../src/compile/compiler');
|
||||||
const { ensureRevisionStore } = require('../src/compile/road-revisions');
|
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 workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'workbench-edit-api-'));
|
||||||
const input = {
|
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`);
|
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 });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
console.log('workbench edit API tests passed');
|
console.log('workbench edit API tests passed');
|
||||||
|
|||||||
@@ -15,9 +15,14 @@ const { generate, validateDocument, runtime } = require('../src/native-traffic-s
|
|||||||
const { convertGeoJson } = require('../src/reference/gaode');
|
const { convertGeoJson } = require('../src/reference/gaode');
|
||||||
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
|
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
|
||||||
const { compileInput } = require('../src/compile/compiler');
|
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 { 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({
|
function startWorkbench({
|
||||||
area = null,
|
area = null,
|
||||||
@@ -90,8 +95,44 @@ function handle(request, response, session) {
|
|||||||
})
|
})
|
||||||
.then((value) => sendJson(response, 200, value))
|
.then((value) => sendJson(response, 200, value))
|
||||||
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
.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')
|
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')
|
if (request.method === 'POST' && url.pathname === '/api/import')
|
||||||
return readUpload(request, session)
|
return readUpload(request, session)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
@@ -190,7 +231,7 @@ function handle(request, response, session) {
|
|||||||
function state(area, junctionReference = null, debug = false) {
|
function state(area, junctionReference = null, debug = false) {
|
||||||
// This is the read entry point for both a fresh import and a reopened legacy
|
// 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.
|
// 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 nativeDir = area.outputs.nativeRoadDir;
|
||||||
const osm2streetsRoadSurface = area.outputs.geojsonDir
|
const osm2streetsRoadSurface = area.outputs.geojsonDir
|
||||||
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
|
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
|
||||||
@@ -206,6 +247,8 @@ function state(area, junctionReference = null, debug = false) {
|
|||||||
const compiled = readCompiled(area);
|
const compiled = readCompiled(area);
|
||||||
return {
|
return {
|
||||||
areaId: area.id,
|
areaId: area.id,
|
||||||
|
activeRevisionId: revisionStore.active.activeRevisionId,
|
||||||
|
documentVersion: revisionStore.active.documentVersion,
|
||||||
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
|
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
|
||||||
compiled,
|
compiled,
|
||||||
overrides: loadOverrides(area.outputs.nativeRoadOverrides),
|
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) {
|
function editState(area) {
|
||||||
const workspace = path.dirname(area.input);
|
const workspace = path.dirname(area.input);
|
||||||
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
|
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
|
// 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
|
// own payload with a stable index so the map can label them "#1, #2, ..." and
|
||||||
// the inspector can offer a ready-to-paste cluster配置.
|
// 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.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
response.end(`${JSON.stringify(value)}\n`);
|
response.end(`${JSON.stringify(value)}\n`);
|
||||||
}
|
}
|
||||||
module.exports = { startWorkbench, readMultipart, editState, editPreview };
|
module.exports = { startWorkbench, readMultipart, editState, editPreview, saveEdits, rebaseEdits };
|
||||||
|
|||||||
Reference in New Issue
Block a user