feat: compile revisions from area config snapshots
This commit is contained in:
@@ -12,13 +12,16 @@ const {
|
||||
} = require('./native-road');
|
||||
const { LAYER_REGISTRY, manifestForArea, validatePublishedLayers } = require('./layer-manifest');
|
||||
const { loadOrGenerate, runtime } = require('../native-traffic-signals');
|
||||
const { readAreaConfigSnapshot } = require('./road-revisions');
|
||||
|
||||
function compileInput(input) {
|
||||
validateInput(input);
|
||||
const options = input.areaConfigSnapshotFile ? readAreaConfigSnapshot(input.areaConfigSnapshotFile) : input.options;
|
||||
validateOptions(options);
|
||||
const area = {
|
||||
id: input.areaId,
|
||||
input: input.osmFile,
|
||||
nativeRoad: input.options,
|
||||
nativeRoad: options,
|
||||
outputs: {
|
||||
nativeRoadOverrides: input.overridesFile,
|
||||
nativeTrafficSignals: input.trafficSignalsFile,
|
||||
@@ -161,12 +164,18 @@ function validateInput(input) {
|
||||
if (typeof input[key] !== 'string' || input[key].trim() === '')
|
||||
throw new Error(`RoadCompilerInput.${key} must be a non-empty string`);
|
||||
}
|
||||
if (!input.options || typeof input.options !== 'object')
|
||||
throw new Error('RoadCompilerInput.options must be an object');
|
||||
if (typeof input.options.edgeLines !== 'boolean')
|
||||
throw new Error('RoadCompilerInput.options.edgeLines must be a boolean');
|
||||
if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== 'object')
|
||||
if (
|
||||
input.areaConfigSnapshotFile !== undefined &&
|
||||
(typeof input.areaConfigSnapshotFile !== 'string' || !input.areaConfigSnapshotFile)
|
||||
)
|
||||
throw new Error('RoadCompilerInput.areaConfigSnapshotFile must be a non-empty string when present');
|
||||
}
|
||||
|
||||
function validateOptions(options) {
|
||||
if (!options || typeof options !== 'object') throw new Error('RoadCompilerInput.options must be an object');
|
||||
if (typeof options.edgeLines !== 'boolean') throw new Error('RoadCompilerInput.options.edgeLines must be a boolean');
|
||||
if (!options.junctionTemplates || typeof options.junctionTemplates !== 'object')
|
||||
throw new Error('RoadCompilerInput.options.junctionTemplates must be an object');
|
||||
}
|
||||
|
||||
module.exports = { compileInput, validateInput };
|
||||
module.exports = { compileInput, validateInput, validateOptions };
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { emptyEditDocument, validateEditDocument } = require('./native-road-edits');
|
||||
const { emptyEditDocument, loadEditDocument, saveEditDocument, validateEditDocument } = require('./native-road-edits');
|
||||
|
||||
const REVISION_SCHEMA = 'road-workbench-revision/v1';
|
||||
const ACTIVE_STATE_SCHEMA = 'road-workbench-active/v1';
|
||||
const AREA_CONFIG_SNAPSHOT_SCHEMA = 'road-workbench-area-config/v1';
|
||||
const PACKAGE_VERSION = require('../../package.json').version;
|
||||
const GEOMETRY_SCHEMA = 'native-road-package/v1.1';
|
||||
|
||||
@@ -39,6 +40,7 @@ function pathsFor(workspace) {
|
||||
osmDirectory: path.join(workspace, 'osm'),
|
||||
activeDirectory: path.join(workspace, 'active'),
|
||||
activeEdits: path.join(workspace, 'active', 'native-road-edits.json'),
|
||||
activeAreaConfig: path.join(workspace, 'active', 'area-config.snapshot.json'),
|
||||
activeState: path.join(workspace, 'active', 'state.json'),
|
||||
revisionsDirectory: path.join(workspace, 'revisions'),
|
||||
};
|
||||
@@ -68,18 +70,71 @@ function revisionId(revisionsDirectory) {
|
||||
return `rev-${String(Math.max(0, ...numbers) + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
function activeDocument(paths, osmSha256) {
|
||||
if (fs.existsSync(paths.activeEdits)) return validateEditDocument(readJson(paths.activeEdits));
|
||||
const document = emptyEditDocument({ osmSha256, compilerGeometryVersion: GEOMETRY_SCHEMA });
|
||||
function activeDocument(paths, osmSha256, areaConfigSha256) {
|
||||
if (fs.existsSync(paths.activeEdits)) {
|
||||
const document = validateEditDocument(readJson(paths.activeEdits));
|
||||
if (areaConfigSha256 && document.base.areaConfigSha256 !== areaConfigSha256) {
|
||||
document.base.areaConfigSha256 = areaConfigSha256;
|
||||
writeJsonAtomic(paths.activeEdits, document);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
const document = emptyEditDocument({ osmSha256, areaConfigSha256, compilerGeometryVersion: GEOMETRY_SCHEMA });
|
||||
writeJsonAtomic(paths.activeEdits, document);
|
||||
return document;
|
||||
}
|
||||
|
||||
function ensureRevisionStore(workspace) {
|
||||
function writeActiveAreaConfig(paths, options) {
|
||||
if (!options || typeof options !== 'object') throw new Error('Area config options must be an object.');
|
||||
const snapshot = { schema: AREA_CONFIG_SNAPSHOT_SCHEMA, options };
|
||||
const bytes = `${JSON.stringify(snapshot, null, 2)}\n`;
|
||||
writeFileAtomic(paths.activeAreaConfig, bytes);
|
||||
return { bytes, options, digest: sha256(bytes) };
|
||||
}
|
||||
|
||||
function setActiveAreaConfig(workspace, options) {
|
||||
const paths = pathsFor(workspace);
|
||||
const areaConfig = writeActiveAreaConfig(paths, options);
|
||||
if (fs.existsSync(paths.activeEdits)) {
|
||||
const document = loadEditDocument(paths.activeEdits);
|
||||
const saved = saveEditDocument(paths.activeEdits, {
|
||||
...document,
|
||||
base: { ...document.base, areaConfigSha256: areaConfig.digest },
|
||||
});
|
||||
if (fs.existsSync(paths.activeState)) {
|
||||
writeJsonAtomic(paths.activeState, { ...readJson(paths.activeState), documentVersion: saved.documentVersion });
|
||||
}
|
||||
}
|
||||
return { paths, areaConfig };
|
||||
}
|
||||
|
||||
function readAreaConfigSnapshot(file) {
|
||||
if (!fs.existsSync(file)) throw new Error(`Area config snapshot is missing: ${file}`);
|
||||
const snapshot = readJson(file);
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.schema !== AREA_CONFIG_SNAPSHOT_SCHEMA ||
|
||||
!snapshot.options ||
|
||||
typeof snapshot.options !== 'object'
|
||||
)
|
||||
throw new Error(`Invalid area config snapshot: ${file}`);
|
||||
return snapshot.options;
|
||||
}
|
||||
|
||||
function activeAreaConfig(paths) {
|
||||
const bytes = readFile(paths.activeAreaConfig);
|
||||
return { bytes, options: readAreaConfigSnapshot(paths.activeAreaConfig), digest: sha256(bytes) };
|
||||
}
|
||||
|
||||
function ensureRevisionStore(workspace, options) {
|
||||
const paths = pathsFor(workspace);
|
||||
const osm = storeOsm(paths);
|
||||
const edits = activeDocument(paths, osm.digest);
|
||||
const snapshot = fs.existsSync(paths.activeAreaConfig)
|
||||
? activeAreaConfig(paths)
|
||||
: writeActiveAreaConfig(paths, options);
|
||||
const edits = activeDocument(paths, osm.digest, snapshot.digest);
|
||||
if (fs.existsSync(path.join(paths.revisionsDirectory, 'rev-0001', 'manifest.json'))) {
|
||||
backfillAreaConfigSnapshots(paths, snapshot);
|
||||
const baseline = readRevision(workspace, 'rev-0001');
|
||||
const active = fs.existsSync(paths.activeState)
|
||||
? readJson(paths.activeState)
|
||||
@@ -94,6 +149,7 @@ function ensureRevisionStore(workspace) {
|
||||
const baseline = createRevision(paths, {
|
||||
id: 'rev-0001',
|
||||
directEdits: edits,
|
||||
areaConfig: snapshot,
|
||||
label: 'Import baseline',
|
||||
});
|
||||
writeJsonAtomic(paths.activeState, {
|
||||
@@ -104,14 +160,44 @@ function ensureRevisionStore(workspace) {
|
||||
return { paths, active: readJson(paths.activeState), baseline };
|
||||
}
|
||||
|
||||
function backfillAreaConfigSnapshots(paths, areaConfig) {
|
||||
if (!fs.existsSync(paths.revisionsDirectory)) return;
|
||||
for (const entry of fs.readdirSync(paths.revisionsDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^rev-\d{4}$/.test(entry.name)) continue;
|
||||
const directory = path.join(paths.revisionsDirectory, entry.name);
|
||||
const manifestFile = path.join(directory, 'manifest.json');
|
||||
if (!fs.existsSync(manifestFile)) continue;
|
||||
const manifest = readJson(manifestFile);
|
||||
if (manifest.source?.areaConfigFile && manifest.source?.areaConfigSha256) continue;
|
||||
const directEditsFile = path.join(directory, 'native-road-edits.json');
|
||||
const directEdits = validateEditDocument(readJson(directEditsFile));
|
||||
directEdits.base.areaConfigSha256 = areaConfig.digest;
|
||||
writeJsonAtomic(directEditsFile, directEdits);
|
||||
writeFileAtomic(path.join(directory, 'area-config.snapshot.json'), areaConfig.bytes);
|
||||
manifest.source = {
|
||||
...manifest.source,
|
||||
areaConfigFile: 'area-config.snapshot.json',
|
||||
areaConfigSha256: areaConfig.digest,
|
||||
};
|
||||
manifest.digests = {
|
||||
...manifest.digests,
|
||||
directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`),
|
||||
areaConfigSnapshot: areaConfig.digest,
|
||||
};
|
||||
writeJsonAtomic(manifestFile, manifest);
|
||||
}
|
||||
}
|
||||
|
||||
function createCheckpoint(workspace, label) {
|
||||
if (typeof label !== 'string' || !label.trim())
|
||||
throw new Error('Revision checkpoint label must be a non-empty string.');
|
||||
const initialized = ensureRevisionStore(workspace);
|
||||
const directEdits = validateEditDocument(readJson(initialized.paths.activeEdits));
|
||||
const areaConfig = activeAreaConfig(initialized.paths);
|
||||
const revision = createRevision(initialized.paths, {
|
||||
id: revisionId(initialized.paths.revisionsDirectory),
|
||||
directEdits,
|
||||
areaConfig,
|
||||
label: label.trim(),
|
||||
parentRevisionId: initialized.active.activeRevisionId,
|
||||
});
|
||||
@@ -123,7 +209,7 @@ function createCheckpoint(workspace, label) {
|
||||
return revision;
|
||||
}
|
||||
|
||||
function createRevision(paths, { id, directEdits, label, parentRevisionId = undefined }) {
|
||||
function createRevision(paths, { id, directEdits, areaConfig, label, parentRevisionId = undefined }) {
|
||||
assertRevisionId(id);
|
||||
const osm = storeOsm(paths);
|
||||
const overrides = readFile(paths.overrides);
|
||||
@@ -142,6 +228,7 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde
|
||||
writeFileAtomic(path.join(staging, files.nativeRoadOverrides), overrides);
|
||||
writeJsonAtomic(path.join(staging, files.directEdits), directEdits);
|
||||
writeFileAtomic(path.join(staging, files.trafficSignals), trafficSignals);
|
||||
writeFileAtomic(path.join(staging, 'area-config.snapshot.json'), areaConfig.bytes);
|
||||
const manifest = {
|
||||
schema: REVISION_SCHEMA,
|
||||
id,
|
||||
@@ -151,12 +238,15 @@ function createRevision(paths, { id, directEdits, label, parentRevisionId = unde
|
||||
source: {
|
||||
osmFile: path.relative(paths.workspace, osm.file),
|
||||
osmSha256: osm.digest,
|
||||
areaConfigFile: 'area-config.snapshot.json',
|
||||
areaConfigSha256: areaConfig.digest,
|
||||
},
|
||||
documents: files,
|
||||
digests: {
|
||||
nativeRoadOverrides: sha256(overrides),
|
||||
directEdits: sha256(`${JSON.stringify(directEdits, null, 2)}\n`),
|
||||
trafficSignals: sha256(trafficSignals),
|
||||
areaConfigSnapshot: areaConfig.digest,
|
||||
},
|
||||
compiler: { packageVersion: PACKAGE_VERSION, geometrySchema: GEOMETRY_SCHEMA },
|
||||
};
|
||||
@@ -180,6 +270,11 @@ function readRevision(workspace, id) {
|
||||
const osm = readFile(osmFile);
|
||||
if (sha256(osm) !== manifest.source.osmSha256)
|
||||
throw new Error(`Revision ${id} OSM digest does not match its manifest.`);
|
||||
const areaConfigFile = path.resolve(directory, manifest.source.areaConfigFile);
|
||||
if (!isWithin(directory, areaConfigFile)) throw new Error(`Revision ${id} area config path escapes the revision.`);
|
||||
const areaConfigBytes = readFile(areaConfigFile);
|
||||
if (sha256(areaConfigBytes) !== manifest.source.areaConfigSha256)
|
||||
throw new Error(`Revision ${id} area config digest does not match its manifest.`);
|
||||
const documents = Object.fromEntries(
|
||||
Object.entries(manifest.documents).map(([name, file]) => {
|
||||
const document = path.resolve(directory, file);
|
||||
@@ -190,7 +285,7 @@ function readRevision(workspace, id) {
|
||||
return [name, JSON.parse(bytes.toString('utf8'))];
|
||||
}),
|
||||
);
|
||||
return { manifest, osm: osm.toString('utf8'), ...documents };
|
||||
return { manifest, osm: osm.toString('utf8'), areaConfig: readAreaConfigSnapshot(areaConfigFile), ...documents };
|
||||
}
|
||||
|
||||
function assertRevisionId(id) {
|
||||
@@ -209,8 +304,11 @@ function readJson(file) {
|
||||
module.exports = {
|
||||
REVISION_SCHEMA,
|
||||
ACTIVE_STATE_SCHEMA,
|
||||
AREA_CONFIG_SNAPSHOT_SCHEMA,
|
||||
sha256,
|
||||
ensureRevisionStore,
|
||||
setActiveAreaConfig,
|
||||
readAreaConfigSnapshot,
|
||||
createCheckpoint,
|
||||
readRevision,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user