feat(platform): add replaceable geozone data layer
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||
export { validateIntakeBatch } from "./intake-batch.mjs";
|
||||
export {
|
||||
DATA_PRODUCT_GEOMETRY_TYPES,
|
||||
DATA_PRODUCT_MAX_GEOMETRY_BYTES,
|
||||
DATA_PRODUCT_MAX_GEOMETRY_VERTICES,
|
||||
isBoundedGeoJsonGeometry,
|
||||
validateGeoJsonGeometry,
|
||||
} from "./geometry.mjs";
|
||||
export {
|
||||
DATA_PRODUCT_HISTORY_SCHEMA_VERSION,
|
||||
DATA_PRODUCT_PATCH_SCHEMA_VERSION,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const DATA_PRODUCT_PATCH_SCHEMA_VERSION = "nodedc.data-product.patch/v1";
|
||||
export const DATA_PRODUCT_HISTORY_SCHEMA_VERSION = "nodedc.data-product.history/v1";
|
||||
|
||||
import { SECRET_LIKE_KEY, SECRET_LIKE_VALUE } from "./sensitive-field-policy.mjs";
|
||||
import { validateGeoJsonGeometry } from "./geometry.mjs";
|
||||
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
@@ -30,22 +31,33 @@ export function validateDataProductPublish(value, { maxFacts = 5000, maxAttribut
|
||||
if (!isPlainObject(value.batch)) {
|
||||
errors.push("batch_must_be_object");
|
||||
} else {
|
||||
rejectUnknownKeys(value.batch, new Set(["runId", "sequence", "idempotencyKey"]), "batch", errors);
|
||||
rejectUnknownKeys(value.batch, new Set(["runId", "sequence", "idempotencyKey", "mode", "generationAt"]), "batch", errors);
|
||||
requiredIdentifier(value.batch.runId, "batch.runId", errors);
|
||||
requiredIdentifier(value.batch.idempotencyKey, "batch.idempotencyKey", errors);
|
||||
if (!Number.isInteger(value.batch.sequence) || value.batch.sequence < 0 || value.batch.sequence > MAX_BATCH_SEQUENCE) {
|
||||
errors.push("batch.sequence_must_be_integer_0_to_2147483647");
|
||||
}
|
||||
const mode = value.batch.mode === undefined ? "upsert" : value.batch.mode;
|
||||
if (!new Set(["upsert", "replace"]).has(mode)) errors.push("batch.mode_invalid");
|
||||
if (mode === "replace") requiredIsoTimestamp(value.batch.generationAt, "batch.generationAt", errors);
|
||||
if (mode === "upsert" && value.batch.generationAt !== undefined) errors.push("batch.generationAt_forbidden_for_upsert");
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.facts) || value.facts.length === 0) {
|
||||
errors.push("facts_must_be_nonempty_array");
|
||||
if (!Array.isArray(value.facts)) {
|
||||
errors.push("facts_must_be_array");
|
||||
} else if (value.facts.length === 0 && value.batch?.mode !== "replace") {
|
||||
errors.push("facts_must_be_nonempty_array_for_upsert");
|
||||
} else if (value.facts.length > maxFacts) {
|
||||
errors.push("facts_limit_exceeded");
|
||||
} else {
|
||||
const entityKeys = new Set();
|
||||
value.facts.forEach((fact, index) => {
|
||||
validateFact(fact, `facts[${index}]`, errors, { maxAttributesBytes: attributesCeiling });
|
||||
if (value.batch?.mode === "replace"
|
||||
&& !Number.isNaN(Date.parse(fact?.observedAt))
|
||||
&& Date.parse(fact.observedAt) !== Date.parse(value.batch.generationAt)) {
|
||||
errors.push(`facts[${index}].observedAt_must_equal_generationAt`);
|
||||
}
|
||||
if (!isPlainObject(fact) || typeof fact.sourceId !== "string" || typeof fact.semanticType !== "string") return;
|
||||
const entityKey = `${fact.sourceId}\u0000${fact.semanticType}`;
|
||||
if (entityKeys.has(entityKey)) errors.push("facts_duplicate_entity_key");
|
||||
@@ -143,12 +155,18 @@ export function validateDataProductPatch(value) {
|
||||
errors.push("operations_must_be_nonempty_array");
|
||||
} else {
|
||||
value.operations.forEach((operation, index) => {
|
||||
if (!isPlainObject(operation) || operation.op !== "upsert") {
|
||||
errors.push(`operations[${index}].op_must_be_upsert`);
|
||||
if (!isPlainObject(operation) || !new Set(["upsert", "remove"]).has(operation.op)) {
|
||||
errors.push(`operations[${index}].op_invalid`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(operation, new Set(["op", "fact"]), `operations[${index}]`, errors);
|
||||
validateCanonicalFact(operation.fact, `operations[${index}].fact`, errors);
|
||||
if (operation.op === "upsert") {
|
||||
rejectUnknownKeys(operation, new Set(["op", "fact"]), `operations[${index}]`, errors);
|
||||
validateCanonicalFact(operation.fact, `operations[${index}].fact`, errors);
|
||||
} else {
|
||||
rejectUnknownKeys(operation, new Set(["op", "sourceId", "semanticType"]), `operations[${index}]`, errors);
|
||||
requiredIdentifier(operation.sourceId, `operations[${index}].sourceId`, errors);
|
||||
requiredIdentifier(operation.semanticType, `operations[${index}].semanticType`, errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("patch_must_not_contain_secret_material");
|
||||
@@ -189,7 +207,7 @@ function validateFact(value, path, errors, { maxAttributesBytes, canonical = fal
|
||||
errors.push(`${path}.attributes_size_exceeded`);
|
||||
}
|
||||
}
|
||||
if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
if (value.geometry !== undefined) validateGeoJsonGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
}
|
||||
|
||||
function validateCanonicalFact(value, path, errors, { allowBucketStart = false } = {}) {
|
||||
@@ -198,21 +216,6 @@ function validateCanonicalFact(value, path, errors, { allowBucketStart = false }
|
||||
requiredIsoTimestamp(value.receivedAt, `${path}.receivedAt`, errors);
|
||||
}
|
||||
|
||||
function validatePointGeometry(value, path, errors) {
|
||||
if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
|
||||
errors.push(`${path}_must_be_geojson_point`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors);
|
||||
if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) {
|
||||
errors.push(`${path}_coordinates_must_be_finite_numbers`);
|
||||
return;
|
||||
}
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`);
|
||||
if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`);
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowed, path, errors) {
|
||||
if (!isPlainObject(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
const GEOMETRY_TYPES = new Set(["Point", "LineString", "Polygon", "MultiPolygon"]);
|
||||
const MAX_GEOMETRY_BYTES = 192 * 1024;
|
||||
const MAX_GEOMETRY_VERTICES = 10_000;
|
||||
|
||||
/**
|
||||
* Validate the bounded GeoJSON geometry subset accepted by provider-neutral
|
||||
* Data Products. GeometryCollections and arbitrary feature properties are
|
||||
* deliberately excluded: a fact owns exactly one geometry and its attributes
|
||||
* stay under the fact field policy.
|
||||
*/
|
||||
export function validateGeoJsonGeometry(value, path, errors, { allowedTypes = GEOMETRY_TYPES } = {}) {
|
||||
if (!isPlainObject(value) || !allowedTypes.has(value.type) || !Array.isArray(value.coordinates)) {
|
||||
errors.push(`${path}_must_be_bounded_geojson_geometry`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors);
|
||||
if (serializedByteLength(value) > MAX_GEOMETRY_BYTES) {
|
||||
errors.push(`${path}_size_exceeded`);
|
||||
return;
|
||||
}
|
||||
|
||||
const vertices = [];
|
||||
if (value.type === "Point") {
|
||||
validatePosition(value.coordinates, path, errors, vertices);
|
||||
} else if (value.type === "LineString") {
|
||||
validateLineString(value.coordinates, path, errors, vertices);
|
||||
} else if (value.type === "Polygon") {
|
||||
validatePolygon(value.coordinates, path, errors, vertices);
|
||||
} else if (value.type === "MultiPolygon") {
|
||||
if (!value.coordinates.length) errors.push(`${path}.coordinates_must_be_nonempty`);
|
||||
value.coordinates.forEach((polygon, index) => validatePolygon(polygon, `${path}.coordinates[${index}]`, errors, vertices));
|
||||
}
|
||||
if (vertices.length > MAX_GEOMETRY_VERTICES) errors.push(`${path}_vertex_limit_exceeded`);
|
||||
}
|
||||
|
||||
export function isBoundedGeoJsonGeometry(value, allowedTypes = GEOMETRY_TYPES) {
|
||||
const errors = [];
|
||||
validateGeoJsonGeometry(value, "geometry", errors, { allowedTypes });
|
||||
return errors.length === 0;
|
||||
}
|
||||
|
||||
function validatePolygon(value, path, errors, vertices) {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
errors.push(`${path}_polygon_rings_required`);
|
||||
return;
|
||||
}
|
||||
value.forEach((ring, index) => {
|
||||
const ringPath = `${path}[${index}]`;
|
||||
if (!Array.isArray(ring) || ring.length < 4) {
|
||||
errors.push(`${ringPath}_linear_ring_requires_four_positions`);
|
||||
return;
|
||||
}
|
||||
ring.forEach((position, positionIndex) => validatePosition(position, `${ringPath}[${positionIndex}]`, errors, vertices));
|
||||
if (!samePosition(ring[0], ring.at(-1))) errors.push(`${ringPath}_linear_ring_must_be_closed`);
|
||||
});
|
||||
}
|
||||
|
||||
function validateLineString(value, path, errors, vertices) {
|
||||
if (!Array.isArray(value) || value.length < 2) {
|
||||
errors.push(`${path}_line_string_requires_two_positions`);
|
||||
return;
|
||||
}
|
||||
value.forEach((position, index) => validatePosition(position, `${path}[${index}]`, errors, vertices));
|
||||
}
|
||||
|
||||
function validatePosition(value, path, errors, vertices) {
|
||||
if (!Array.isArray(value) || value.length !== 2 || !value.every(Number.isFinite)) {
|
||||
errors.push(`${path}_position_invalid`);
|
||||
return;
|
||||
}
|
||||
const [longitude, latitude] = value;
|
||||
if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`);
|
||||
if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`);
|
||||
vertices.push(value);
|
||||
}
|
||||
|
||||
function samePosition(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === 2 && right.length === 2
|
||||
&& Object.is(left[0], right[0]) && Object.is(left[1], right[1]);
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowed, path, errors) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function serializedByteLength(value) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value));
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export const DATA_PRODUCT_GEOMETRY_TYPES = Object.freeze([...GEOMETRY_TYPES]);
|
||||
export const DATA_PRODUCT_MAX_GEOMETRY_BYTES = MAX_GEOMETRY_BYTES;
|
||||
export const DATA_PRODUCT_MAX_GEOMETRY_VERTICES = MAX_GEOMETRY_VERTICES;
|
||||
@@ -2,6 +2,18 @@ import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||
|
||||
export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||
export { validateIntakeBatch } from "./intake-batch.mjs";
|
||||
export {
|
||||
DATA_PRODUCT_GEOMETRY_TYPES,
|
||||
DATA_PRODUCT_MAX_GEOMETRY_BYTES,
|
||||
DATA_PRODUCT_MAX_GEOMETRY_VERTICES,
|
||||
isBoundedGeoJsonGeometry,
|
||||
validateGeoJsonGeometry,
|
||||
} from "./geometry.mjs";
|
||||
export {
|
||||
ZONE_SOURCE_ADAPTERS,
|
||||
ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||
normalizeZoneSourceGeneration,
|
||||
} from "./zone-source.mjs";
|
||||
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1";
|
||||
|
||||
import {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SECRET_LIKE_REFERENCE,
|
||||
SECRET_LIKE_VALUE,
|
||||
} from "./sensitive-field-policy.mjs";
|
||||
import { validateGeoJsonGeometry } from "./geometry.mjs";
|
||||
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
@@ -16,7 +17,7 @@ export function validateIntakeBatch(value) {
|
||||
rejectUnknownKeys(value, new Set(["schemaVersion", "source", "contract", "batch", "raw", "facts"]), "intakeBatch", errors);
|
||||
rejectUnknownKeys(value?.source, new Set(["providerId", "tenantId", "connectionId"]), "source", errors);
|
||||
rejectUnknownKeys(value?.contract, new Set(["dataProductId", "ontologyRevision", "version"]), "contract", errors);
|
||||
rejectUnknownKeys(value?.batch, new Set(["runId", "sequence", "idempotencyKey", "receivedAt"]), "batch", errors);
|
||||
rejectUnknownKeys(value?.batch, new Set(["runId", "sequence", "idempotencyKey", "receivedAt", "mode", "generationAt"]), "batch", errors);
|
||||
requiredIdentifier(value?.source?.providerId, "source.providerId", errors);
|
||||
requiredIdentifier(value?.source?.tenantId, "source.tenantId", errors);
|
||||
requiredIdentifier(value?.source?.connectionId, "source.connectionId", errors);
|
||||
@@ -32,11 +33,24 @@ export function validateIntakeBatch(value) {
|
||||
errors.push("batch.sequence_must_be_integer_0_to_2147483647");
|
||||
}
|
||||
requiredIsoTimestamp(value?.batch?.receivedAt, "batch.receivedAt", errors);
|
||||
const mode = value?.batch?.mode === undefined ? "upsert" : value.batch.mode;
|
||||
if (!new Set(["upsert", "replace"]).has(mode)) errors.push("batch.mode_invalid");
|
||||
if (mode === "replace") requiredIsoTimestamp(value?.batch?.generationAt, "batch.generationAt", errors);
|
||||
if (mode === "upsert" && value?.batch?.generationAt !== undefined) errors.push("batch.generationAt_forbidden_for_upsert");
|
||||
|
||||
if (!Array.isArray(value?.facts) || value.facts.length === 0) {
|
||||
errors.push("facts_must_be_nonempty_array");
|
||||
if (!Array.isArray(value?.facts)) {
|
||||
errors.push("facts_must_be_array");
|
||||
} else if (value.facts.length === 0 && mode !== "replace") {
|
||||
errors.push("facts_must_be_nonempty_array_for_upsert");
|
||||
} else {
|
||||
value.facts.forEach((fact, index) => validateFact(fact, `facts[${index}]`, errors));
|
||||
value.facts.forEach((fact, index) => {
|
||||
validateFact(fact, `facts[${index}]`, errors);
|
||||
if (mode === "replace"
|
||||
&& !Number.isNaN(Date.parse(fact?.observedAt))
|
||||
&& Date.parse(fact.observedAt) !== Date.parse(value.batch.generationAt)) {
|
||||
errors.push(`facts[${index}].observedAt_must_equal_generationAt`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (value?.raw !== undefined) validateRawEnvelope(value.raw, errors);
|
||||
@@ -80,22 +94,7 @@ function validateFact(value, path, errors) {
|
||||
errors.push(`${path}.attributes_size_exceeded`);
|
||||
}
|
||||
}
|
||||
if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
}
|
||||
|
||||
function validatePointGeometry(value, path, errors) {
|
||||
if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
|
||||
errors.push(`${path}_must_be_geojson_point`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors);
|
||||
if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) {
|
||||
errors.push(`${path}_coordinates_must_be_finite_numbers`);
|
||||
return;
|
||||
}
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`);
|
||||
if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`);
|
||||
if (value.geometry !== undefined) validateGeoJsonGeometry(value.geometry, `${path}.geometry`, errors);
|
||||
}
|
||||
|
||||
function validateRawEnvelope(value, errors) {
|
||||
|
||||
@@ -20,7 +20,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt
|
||||
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
|
||||
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
||||
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
||||
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]);
|
||||
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]);
|
||||
const L2_STEP_KINDS = new Set([
|
||||
"collection_trigger",
|
||||
"provider_request",
|
||||
@@ -84,6 +84,10 @@ const CAPABILITY_KEYS = new Set([
|
||||
]);
|
||||
const REQUEST_KEYS = new Set(["method", "baseUrl", "path", "query", "response"]);
|
||||
const RESPONSE_KEYS = new Set(["collectionPaths", "pagination"]);
|
||||
const OFFSET_PAGINATION_KEYS = new Set([
|
||||
"mode", "limitParameter", "offsetParameter", "pageSize", "itemsPath", "totalPath",
|
||||
"maxPages", "maxItems", "maxResponseBytes",
|
||||
]);
|
||||
const ENTITY_SCOPE_KEYS = new Set(["mode", "refresh", "businessEntityFilter"]);
|
||||
const FIELD_POLICY_KEYS = new Set([
|
||||
"id",
|
||||
@@ -137,7 +141,9 @@ const EXPRESSION_KEYS = new Set([
|
||||
"strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive",
|
||||
"omitIfMissing", "omitIfInvalid", "minimum", "maximum",
|
||||
]);
|
||||
const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
|
||||
const GEOMETRY_KEYS = new Set([
|
||||
"type", "longitude", "latitude", "strategy", "paths", "allowedTypes", "omitIfInvalid",
|
||||
]);
|
||||
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
|
||||
const TEMPLATE_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
@@ -496,12 +502,50 @@ function validateCapability(value, path, errors) {
|
||||
} else {
|
||||
rejectUnknownKeys(value.request.response, RESPONSE_KEYS, `${path}.request.response`, errors);
|
||||
requiredCollectionPathArray(value.request.response.collectionPaths, `${path}.request.response.collectionPaths`, errors);
|
||||
if (value.request.response.pagination !== "single_bounded_response") errors.push(`${path}.request.response.pagination_invalid`);
|
||||
validateResponsePagination(value.request.response.pagination, value.request.query, `${path}.request.response.pagination`, errors);
|
||||
}
|
||||
}
|
||||
validateCredentialVisibleScope(value.entityScope, `${path}.entityScope`, errors);
|
||||
}
|
||||
|
||||
function validateResponsePagination(value, query, path, errors) {
|
||||
if (value === "single_bounded_response") return;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`${path}_invalid`);
|
||||
return;
|
||||
}
|
||||
rejectUnknownKeys(value, OFFSET_PAGINATION_KEYS, path, errors);
|
||||
if (value.mode !== "offset") errors.push(`${path}.mode_must_be_offset`);
|
||||
for (const field of ["limitParameter", "offsetParameter"]) {
|
||||
if (typeof value[field] !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value[field])) {
|
||||
errors.push(`${path}.${field}_invalid`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(value.pageSize) || value.pageSize < 1 || value.pageSize > 500) {
|
||||
errors.push(`${path}.pageSize_invalid`);
|
||||
}
|
||||
if (!Number.isInteger(value.maxPages) || value.maxPages < 1 || value.maxPages > 1000) {
|
||||
errors.push(`${path}.maxPages_invalid`);
|
||||
}
|
||||
if (!Number.isInteger(value.maxItems) || value.maxItems < 1 || value.maxItems > 5000) {
|
||||
errors.push(`${path}.maxItems_invalid`);
|
||||
}
|
||||
if (!Number.isInteger(value.maxResponseBytes) || value.maxResponseBytes < 1024 || value.maxResponseBytes > 32 * 1024 * 1024) {
|
||||
errors.push(`${path}.maxResponseBytes_invalid`);
|
||||
}
|
||||
for (const field of ["itemsPath", "totalPath"]) {
|
||||
if (!isCanonicalSourcePath(value[field])) errors.push(`${path}.${field}_invalid`);
|
||||
}
|
||||
if (isPlainObject(query)) {
|
||||
if (query[value.limitParameter] !== value.pageSize) errors.push(`${path}.pageSize_must_match_static_query_limit`);
|
||||
if (query[value.offsetParameter] !== 0) errors.push(`${path}.offset_query_must_start_at_zero`);
|
||||
}
|
||||
if (Number.isInteger(value.maxPages) && Number.isInteger(value.pageSize)
|
||||
&& Number.isInteger(value.maxItems) && value.maxPages * value.pageSize < value.maxItems) {
|
||||
errors.push(`${path}.maxPages_cannot_cover_maxItems`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFieldPolicy(value, path, errors) {
|
||||
rejectUnknownKeys(value, FIELD_POLICY_KEYS, path, errors);
|
||||
requiredIdentifier(value.id, `${path}.id`, errors);
|
||||
@@ -619,10 +663,26 @@ function validateMappingContract(value, path, errors) {
|
||||
errors.push(`${path}.fact.geometry_must_be_object`);
|
||||
} else if (isPlainObject(value.fact.geometry)) {
|
||||
rejectUnknownKeys(value.fact.geometry, GEOMETRY_KEYS, `${path}.fact.geometry`, errors);
|
||||
if (value.fact.geometry.type !== "Point") errors.push(`${path}.fact.geometry.type_must_be_Point`);
|
||||
validateExpression(value.fact.geometry.longitude, `${path}.fact.geometry.longitude`, errors);
|
||||
validateExpression(value.fact.geometry.latitude, `${path}.fact.geometry.latitude`, errors);
|
||||
if (value.fact.geometry.omitIfInvalid !== true) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_true`);
|
||||
if (value.fact.geometry.type === "Point") {
|
||||
validateExpression(value.fact.geometry.longitude, `${path}.fact.geometry.longitude`, errors);
|
||||
validateExpression(value.fact.geometry.latitude, `${path}.fact.geometry.latitude`, errors);
|
||||
if (value.fact.geometry.omitIfInvalid !== true) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_true`);
|
||||
if (value.fact.geometry.strategy !== undefined || value.fact.geometry.paths !== undefined || value.fact.geometry.allowedTypes !== undefined) {
|
||||
errors.push(`${path}.fact.geometry.point_shape_invalid`);
|
||||
}
|
||||
} else if (value.fact.geometry.type === "GeoJSON") {
|
||||
if (value.fact.geometry.strategy !== "gelios_geozone_v1") errors.push(`${path}.fact.geometry.strategy_invalid`);
|
||||
requiredSourcePathArray(value.fact.geometry.paths, `${path}.fact.geometry.paths`, errors);
|
||||
if (!sameSet(value.fact.geometry.allowedTypes, ["Polygon", "MultiPolygon"])) {
|
||||
errors.push(`${path}.fact.geometry.allowedTypes_must_be_polygon_or_multipolygon`);
|
||||
}
|
||||
if (value.fact.geometry.omitIfInvalid !== false) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_false`);
|
||||
if (value.fact.geometry.longitude !== undefined || value.fact.geometry.latitude !== undefined) {
|
||||
errors.push(`${path}.fact.geometry.geojson_shape_invalid`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${path}.fact.geometry.type_invalid`);
|
||||
}
|
||||
}
|
||||
if (value.fact.attributes !== undefined && !isPlainObject(value.fact.attributes)) {
|
||||
errors.push(`${path}.fact.attributes_must_be_object`);
|
||||
@@ -773,7 +833,13 @@ function validateMappingAgainstProduct(mapping, product, errors) {
|
||||
for (const [field, contract] of Object.entries(contracts)) {
|
||||
if (!isPlainObject(contract)) continue;
|
||||
if (field === "geometry") {
|
||||
if (contract.type !== "point") errors.push(`${path}.fact.geometry_contract_must_be_point`);
|
||||
if (contract.type === "point" && mapping.fact?.geometry?.type !== "Point") {
|
||||
errors.push(`${path}.fact.geometry_contract_must_be_point`);
|
||||
}
|
||||
if (contract.type === "geometry" && mapping.fact?.geometry?.type !== "GeoJSON") {
|
||||
errors.push(`${path}.fact.geometry_contract_must_be_geojson`);
|
||||
}
|
||||
if (!new Set(["point", "geometry"]).has(contract.type)) errors.push(`${path}.fact.geometry_contract_invalid`);
|
||||
if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) {
|
||||
errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`);
|
||||
}
|
||||
@@ -805,7 +871,7 @@ function validateFieldContract(value, path, errors) {
|
||||
if (value.enum !== undefined) {
|
||||
if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) {
|
||||
errors.push(`${path}.enum_must_be_nonempty_unique_array`);
|
||||
} else if (new Set(["point", "string_array"]).has(value.type)
|
||||
} else if (new Set(["point", "geometry", "string_array"]).has(value.type)
|
||||
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
|
||||
errors.push(`${path}.enum_value_type_invalid`);
|
||||
}
|
||||
@@ -880,6 +946,11 @@ function fieldContractValueMatchesType(value, type) {
|
||||
&& value.coordinates.length === 2
|
||||
&& value.coordinates.every(Number.isFinite);
|
||||
}
|
||||
if (type === "geometry") {
|
||||
return isPlainObject(value)
|
||||
&& new Set(["Point", "LineString", "Polygon", "MultiPolygon"]).has(value.type)
|
||||
&& Array.isArray(value.coordinates);
|
||||
}
|
||||
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { isBoundedGeoJsonGeometry } from "./geometry.mjs";
|
||||
|
||||
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v1";
|
||||
export const ZONE_SOURCE_ADAPTERS = Object.freeze({
|
||||
"gelios-rest-v1": Object.freeze({ sourceKind: "live_api", sourceRevision: "gelios-rest-v1" }),
|
||||
"mmap-snapshot-v1": Object.freeze({ sourceKind: "versioned_snapshot", sourceRevision: "mmap-snapshot-v1" }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert one already-complete source generation into map.zone facts. Fetching,
|
||||
* credentials and page iteration stay in Engine; this function is the common
|
||||
* adapter boundary used by both live REST and immutable snapshot sources.
|
||||
*/
|
||||
export function normalizeZoneSourceGeneration(value, {
|
||||
identityCrosswalk = {},
|
||||
maxZones = 5000,
|
||||
maxBytes = 16 * 1024 * 1024,
|
||||
circleSegments = 64,
|
||||
} = {}) {
|
||||
if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_GENERATION_SCHEMA_VERSION) {
|
||||
throw zoneSourceError("zone_source_generation_schema_invalid");
|
||||
}
|
||||
const adapter = ZONE_SOURCE_ADAPTERS[value.adapterId];
|
||||
if (!adapter) throw zoneSourceError("zone_source_adapter_unsupported");
|
||||
if (!isPlainObject(identityCrosswalk)) throw zoneSourceError("zone_source_identity_crosswalk_invalid");
|
||||
if (value.complete !== true) throw zoneSourceError("zone_source_generation_incomplete");
|
||||
const generatedAt = iso(value.generatedAt, "zone_source_generated_at_invalid");
|
||||
if (!Array.isArray(value.zones) || value.zones.length > maxZones) {
|
||||
throw zoneSourceError("zone_source_zone_limit_exceeded");
|
||||
}
|
||||
if (byteLength(value) > maxBytes) throw zoneSourceError("zone_source_generation_bytes_exceeded");
|
||||
if (!Number.isInteger(circleSegments) || circleSegments < 16 || circleSegments > 256) {
|
||||
throw zoneSourceError("zone_source_circle_segments_invalid");
|
||||
}
|
||||
|
||||
const facts = [];
|
||||
const sourceIds = new Set();
|
||||
for (const [index, rawZone] of value.zones.entries()) {
|
||||
const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId);
|
||||
if (sourceIds.has(zone.sourceId)) throw zoneSourceError("zone_source_identity_duplicate");
|
||||
sourceIds.add(zone.sourceId);
|
||||
facts.push({
|
||||
sourceId: zone.sourceId,
|
||||
semanticType: "map.zone",
|
||||
observedAt: generatedAt,
|
||||
geometry: zone.geometry,
|
||||
attributes: compact({
|
||||
area_square_meters: finiteNonNegative(first(rawZone.surfaceArea, rawZone.surface_area)),
|
||||
description: optionalString(first(rawZone.description, rawZone.descr)),
|
||||
display_name: requiredString(first(rawZone.name, rawZone.display_name), "zone_source_name_required"),
|
||||
geometry_kind: zone.geometryKind,
|
||||
max_speed_kph: finiteNonNegative(first(rawZone.maxPermissibleSpeed, rawZone.max_permissible_speed)),
|
||||
perimeter_meters: finiteNonNegative(rawZone.perimeter),
|
||||
source_kind: adapter.sourceKind,
|
||||
source_revision: revision(value, adapter),
|
||||
style_color: optionalString(first(rawZone.color, rawZone.style_color)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
||||
const digest = createHash("sha256").update(stableJson({
|
||||
adapterId: value.adapterId,
|
||||
generatedAt,
|
||||
revision: revision(value, adapter),
|
||||
facts,
|
||||
})).digest("hex");
|
||||
return Object.freeze({
|
||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||
adapterId: value.adapterId,
|
||||
sourceKind: adapter.sourceKind,
|
||||
sourceRevision: revision(value, adapter),
|
||||
generatedAt,
|
||||
complete: true,
|
||||
digest,
|
||||
facts: Object.freeze(facts.map(deepFreeze)),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId) {
|
||||
if (!isPlainObject(value)) throw zoneSourceError(`zone_source_zone_${index}_invalid`);
|
||||
const nativeId = resolveNativeId(value, identityCrosswalk, adapterId);
|
||||
const sourceId = nativeId.startsWith("gelios-zone-") ? nativeId : `gelios-zone-${nativeId}`;
|
||||
if (!/^[a-z][a-z0-9._:-]{2,127}$/.test(sourceId)) throw zoneSourceError("zone_source_identity_invalid");
|
||||
const rawType = String(first(value.type, value.geometry_kind, "polygon")).trim().toLowerCase();
|
||||
const geometryKind = rawType === "circle" ? "circle" : rawType === "line" || rawType === "corridor" ? "corridor" : "polygon";
|
||||
const geometry = normalizeGeometry(value, geometryKind, circleSegments);
|
||||
if (!isBoundedGeoJsonGeometry(geometry, new Set(["Polygon", "MultiPolygon"]))) {
|
||||
throw zoneSourceError(`zone_source_zone_${index}_geometry_invalid`);
|
||||
}
|
||||
return { sourceId, geometryKind, geometry };
|
||||
}
|
||||
|
||||
function resolveNativeId(zone, identityCrosswalk, adapterId) {
|
||||
if (adapterId === "mmap-snapshot-v1") {
|
||||
const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key));
|
||||
if (!snapshotKey || !Object.hasOwn(identityCrosswalk, snapshotKey)) {
|
||||
throw zoneSourceError("zone_source_identity_crosswalk_required");
|
||||
}
|
||||
const mapped = identityCrosswalk[snapshotKey];
|
||||
if (mapped === undefined || mapped === null || !String(mapped).trim()) {
|
||||
throw zoneSourceError("zone_source_identity_crosswalk_required");
|
||||
}
|
||||
return String(mapped).trim();
|
||||
}
|
||||
const direct = first(zone.id, zone.geozoneId, zone.zone_id, zone.sourceId);
|
||||
if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim();
|
||||
throw zoneSourceError("zone_source_native_identity_required");
|
||||
}
|
||||
|
||||
function normalizeGeometry(zone, geometryKind, circleSegments) {
|
||||
if (isBoundedGeoJsonGeometry(zone.geometry, new Set(["Polygon", "MultiPolygon"]))) {
|
||||
return structuredClone(zone.geometry);
|
||||
}
|
||||
const points = parseCoordinates(first(zone.points, zone.coords, zone.coordinates));
|
||||
if (geometryKind === "circle") {
|
||||
if (!points.length) throw zoneSourceError("zone_source_circle_center_required");
|
||||
const radius = finiteNonNegative(zone.radius);
|
||||
if (!radius || radius <= 0) throw zoneSourceError("zone_source_circle_radius_required");
|
||||
return circlePolygon(points[0], radius, circleSegments);
|
||||
}
|
||||
if (geometryKind === "corridor") {
|
||||
const linePoints = parseCoordinates(first(zone.line, zone.points, zone.coords, zone.coordinates));
|
||||
const width = finiteNonNegative(first(zone.lineWidthMeters, zone.line_width_meters, zone.width, zone.radius));
|
||||
if (linePoints.length < 2 || !width || width <= 0) {
|
||||
throw zoneSourceError("zone_source_corridor_path_and_width_required");
|
||||
}
|
||||
return corridorPolygon(linePoints, width);
|
||||
}
|
||||
if (points.length < 3) throw zoneSourceError("zone_source_polygon_points_required");
|
||||
const ring = [...points];
|
||||
if (!samePosition(ring[0], ring.at(-1))) ring.push([...ring[0]]);
|
||||
return { type: "Polygon", coordinates: [ring] };
|
||||
}
|
||||
|
||||
function parseCoordinates(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
|
||||
const [latitude, longitude] = entry.split(",").map(Number);
|
||||
return position(longitude, latitude);
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => {
|
||||
if (Array.isArray(entry)) {
|
||||
const [latitude, longitude] = entry.map(Number);
|
||||
return position(longitude, latitude);
|
||||
}
|
||||
if (!isPlainObject(entry)) throw zoneSourceError("zone_source_coordinate_invalid");
|
||||
return position(
|
||||
firstNumber(entry.longitude, entry.lon, entry.lng, entry.x),
|
||||
firstNumber(entry.latitude, entry.lat, entry.y),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function circlePolygon([longitude, latitude], radiusMeters, segments) {
|
||||
const earthRadius = 6_371_008.8;
|
||||
const angularDistance = radiusMeters / earthRadius;
|
||||
const latitudeRad = radians(latitude);
|
||||
const longitudeRad = radians(longitude);
|
||||
const ring = [];
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const bearing = 2 * Math.PI * index / segments;
|
||||
const targetLatitude = Math.asin(
|
||||
Math.sin(latitudeRad) * Math.cos(angularDistance)
|
||||
+ Math.cos(latitudeRad) * Math.sin(angularDistance) * Math.cos(bearing),
|
||||
);
|
||||
const targetLongitude = longitudeRad + Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRad),
|
||||
Math.cos(angularDistance) - Math.sin(latitudeRad) * Math.sin(targetLatitude),
|
||||
);
|
||||
ring.push([degrees(targetLongitude), degrees(targetLatitude)]);
|
||||
}
|
||||
ring[ring.length - 1] = [...ring[0]];
|
||||
return { type: "Polygon", coordinates: [ring] };
|
||||
}
|
||||
|
||||
function corridorPolygon(points, widthMeters) {
|
||||
const halfWidth = widthMeters / 2;
|
||||
const left = [];
|
||||
const right = [];
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const previous = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
const meanLatitude = radians((previous[1] + next[1]) / 2);
|
||||
const dx = (next[0] - previous[0]) * Math.cos(meanLatitude);
|
||||
const dy = next[1] - previous[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!length) throw zoneSourceError("zone_source_corridor_zero_length_segment");
|
||||
const metersPerDegreeLatitude = 111_320;
|
||||
const longitudeOffset = (-dy / length) * halfWidth / (metersPerDegreeLatitude * Math.max(0.01, Math.cos(radians(points[index][1]))));
|
||||
const latitudeOffset = (dx / length) * halfWidth / metersPerDegreeLatitude;
|
||||
left.push([points[index][0] + longitudeOffset, points[index][1] + latitudeOffset]);
|
||||
right.push([points[index][0] - longitudeOffset, points[index][1] - latitudeOffset]);
|
||||
}
|
||||
const ring = [...left, ...right.reverse(), [...left[0]]];
|
||||
return { type: "Polygon", coordinates: [ring] };
|
||||
}
|
||||
|
||||
function revision(value, adapter) {
|
||||
const explicit = optionalString(value.sourceRevision);
|
||||
if (value.adapterId === "mmap-snapshot-v1") {
|
||||
const digest = optionalString(value.snapshotDigest);
|
||||
if (!digest || !/^[a-f0-9]{64}$/.test(digest)) throw zoneSourceError("zone_source_snapshot_digest_required");
|
||||
return `${explicit || adapter.sourceRevision}@sha256:${digest}`;
|
||||
}
|
||||
return explicit || adapter.sourceRevision;
|
||||
}
|
||||
|
||||
function position(longitude, latitude) {
|
||||
if (![longitude, latitude].every(Number.isFinite)
|
||||
|| longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||
throw zoneSourceError("zone_source_coordinate_invalid");
|
||||
}
|
||||
return [longitude, latitude];
|
||||
}
|
||||
|
||||
function first(...values) {
|
||||
return values.find((value) => value !== undefined && value !== null && value !== "");
|
||||
}
|
||||
|
||||
function firstNumber(...values) {
|
||||
const value = first(...values);
|
||||
const number = typeof value === "number" ? value : Number(String(value ?? "").trim());
|
||||
return Number.isFinite(number) ? number : Number.NaN;
|
||||
}
|
||||
|
||||
function finiteNonNegative(value) {
|
||||
const number = firstNumber(value);
|
||||
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
||||
}
|
||||
|
||||
function requiredString(value, code) {
|
||||
const result = optionalString(value);
|
||||
if (!result) throw zoneSourceError(code);
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function iso(value, code) {
|
||||
const date = new Date(String(value ?? ""));
|
||||
if (Number.isNaN(date.getTime())) throw zoneSourceError(code);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function compact(value) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function samePosition(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left[0] === right[0] && left[1] === right[1];
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function byteLength(value) {
|
||||
try { return Buffer.byteLength(JSON.stringify(value)); } catch { return Number.POSITIVE_INFINITY; }
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function radians(value) { return value * Math.PI / 180; }
|
||||
function degrees(value) { return value * 180 / Math.PI; }
|
||||
|
||||
function zoneSourceError(code) {
|
||||
return Object.assign(new Error(code), { code });
|
||||
}
|
||||
Reference in New Issue
Block a user