feat(lidar): add dataset gateway boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 11:21:22 +03:00
parent 3333e9ac0f
commit f57d64bf52
14 changed files with 1314 additions and 17 deletions
@@ -0,0 +1,243 @@
export type DatasetRepresentationId =
| "native-scan"
| "normalized-scan"
| "rolling-local-map";
export interface DatasetGatewayCatalog {
storage: {
configured: boolean;
admitted: boolean;
status: "ready" | "blocked-storage-policy";
requiredWindowsRoot: string;
requiredWslRoot: string;
};
source: {
sourceId: string;
displayName: string;
role: string;
license: string;
format: string;
frameSemantics: "one-lidar-revolution";
platforms: string[];
superclasses: string[];
validationArchiveGb: number;
admissionStatus: "ready-for-download" | "blocked-storage-policy";
};
representations: Array<{
id: DatasetRepresentationId;
title: string;
purpose: string;
accumulation: boolean;
}>;
pipeline: Array<{
stage: string;
requires: string[];
produces: string;
}>;
currentInput: {
representation: "vendor-mapped-increment";
nativeScan: false;
perPointTime: false;
ringOrLine: false;
admittedForPatchworkpp: false;
reason: string;
};
nextAction: string;
}
export class DatasetGatewayContractError extends Error {}
type DatasetFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_ID = /^[a-z0-9][a-z0-9._:/-]{0,159}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new DatasetGatewayContractError(`${label}: ожидался объект`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new DatasetGatewayContractError(`${label}: ожидался массив`);
}
return value;
}
function string(value: unknown, label: string, safe = false): string {
if (
typeof value !== "string"
|| !value
|| (safe && !SAFE_ID.test(value))
) {
throw new DatasetGatewayContractError(`${label}: некорректная строка`);
}
return value;
}
function strings(value: unknown, label: string): string[] {
return array(value, label).map((item, index) =>
string(item, `${label}[${index}]`, true)
);
}
function displayStrings(value: unknown, label: string): string[] {
return array(value, label).map((item, index) =>
string(item, `${label}[${index}]`)
);
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new DatasetGatewayContractError(`${label}: ожидался boolean`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new DatasetGatewayContractError(`${label}: некорректное число`);
}
return value;
}
export function parseDatasetGatewayCatalog(
value: unknown,
): DatasetGatewayCatalog {
const source = record(value, "Dataset Gateway");
if (
source.schema_version !== "missioncore.dataset-gateway-catalog/v1"
|| source.access !== "read-only"
) {
throw new DatasetGatewayContractError("Dataset Gateway contract несовместим");
}
const storage = record(source.storage, "storage");
const storageStatus = storage.status;
if (storageStatus !== "ready" && storageStatus !== "blocked-storage-policy") {
throw new DatasetGatewayContractError("storage.status: неизвестное значение");
}
if (storage.path_exposed !== false) {
throw new DatasetGatewayContractError("Dataset Gateway раскрыл локальный путь");
}
const sources = array(source.sources, "sources");
if (sources.length !== 1) {
throw new DatasetGatewayContractError("Ожидался один первичный dataset source");
}
const dataset = record(sources[0], "sources[0]");
const download = record(dataset.download, "source.download");
const admission = record(dataset.admission, "source.admission");
if (download.automatic !== false) {
throw new DatasetGatewayContractError("Большой dataset нельзя загружать автоматически");
}
const admissionStatus = admission.status;
if (
admissionStatus !== "ready-for-download"
&& admissionStatus !== "blocked-storage-policy"
) {
throw new DatasetGatewayContractError("source admission status неизвестен");
}
const representations = array(
source.representations,
"representations",
).map((value, index) => {
const item = record(value, `representations[${index}]`);
const id = item.id;
if (
id !== "native-scan"
&& id !== "normalized-scan"
&& id !== "rolling-local-map"
) {
throw new DatasetGatewayContractError("Неизвестная LiDAR representation");
}
const normalizedId: DatasetRepresentationId = id;
return {
id: normalizedId,
title: string(item.title, "representation.title"),
purpose: string(item.purpose, "representation.purpose", true),
accumulation: boolean(item.accumulation, "representation.accumulation"),
};
});
const pipeline = array(source.pipeline, "pipeline").map((value, index) => {
const item = record(value, `pipeline[${index}]`);
return {
stage: string(item.stage, "pipeline.stage", true),
requires: strings(item.requires, "pipeline.requires"),
produces: string(item.produces, "pipeline.produces", true),
};
});
const inputs = array(source.known_inputs, "known_inputs");
const currentInput = record(inputs[0], "known_inputs[0]");
if (
currentInput.representation !== "vendor-mapped-increment"
|| currentInput.native_scan !== false
|| currentInput.per_point_time !== false
|| currentInput.ring_or_line !== false
|| currentInput.admitted_for_patchworkpp !== false
) {
throw new DatasetGatewayContractError("Vendor-map boundary завышен");
}
if (dataset.frame_semantics !== "one-lidar-revolution") {
throw new DatasetGatewayContractError("GOOSE frame semantics несовместима");
}
return {
storage: {
configured: boolean(storage.configured, "storage.configured"),
admitted: boolean(storage.admitted, "storage.admitted"),
status: storageStatus,
requiredWindowsRoot: string(
storage.required_windows_root,
"storage.required_windows_root",
),
requiredWslRoot: string(storage.required_wsl_root, "storage.required_wsl_root"),
},
source: {
sourceId: string(dataset.source_id, "source_id", true),
displayName: string(dataset.display_name, "display_name"),
role: string(dataset.role, "role", true),
license: string(dataset.license, "license"),
format: string(dataset.format, "format", true),
frameSemantics: "one-lidar-revolution",
platforms: displayStrings(dataset.platforms, "platforms"),
superclasses: strings(dataset.superclasses, "superclasses"),
validationArchiveGb: number(
download.validation_archive_gb,
"validation_archive_gb",
),
admissionStatus,
},
representations,
pipeline,
currentInput: {
representation: "vendor-mapped-increment",
nativeScan: false,
perPointTime: false,
ringOrLine: false,
admittedForPatchworkpp: false,
reason: string(currentInput.reason, "known_inputs.reason", true),
},
nextAction: string(source.next_action, "next_action", true),
};
}
async function responseJson(response: Response): Promise<unknown> {
if (!response.ok) {
throw new Error(`Dataset Gateway HTTP ${response.status}`);
}
return response.json();
}
export async function fetchDatasetGatewayCatalog(
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
): Promise<DatasetGatewayCatalog> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/lidar/dataset-gateway", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseDatasetGatewayCatalog(await responseJson(response));
}