feat(platform): add replaceable geozone data layer

This commit is contained in:
Codex
2026-07-20 21:48:27 +03:00
parent 8a7465cf0e
commit 3446f3edc2
31 changed files with 1342 additions and 128 deletions
@@ -9,6 +9,6 @@
"./providers/*": "./providers/*/index.mjs"
},
"scripts": {
"check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/provider-package.test.mjs && node test/engine-private-extension.test.mjs"
"check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/provider-package.test.mjs && node test/zone-source.test.mjs && node test/engine-private-extension.test.mjs"
}
}
@@ -0,0 +1,13 @@
# Gelios provider package v6
Adds the bounded, offset-paged `gelios.geozone` read capability and maps a
complete accepted source generation into the provider-neutral
`map.zones.current.v1` Data Product. The runtime publisher must use replace
mode: a partial provider page set never reaches Publish, while a complete newer
generation atomically upserts present zones and emits remove patches for zones
that disappeared.
`gelios-rest-v1` and `mmap-snapshot-v1` are source adapters, not Foundry
bindings. Both must preserve the native Gelios zone id (or use an explicit
crosswalk) and produce the same `map.zone` facts. Foundry only consumes the
provider-neutral product.
@@ -0,0 +1,9 @@
export {
GELIOS_PROVIDER_PACKAGE_ID,
GELIOS_PROVIDER_PACKAGE_VERSION,
GELIOS_ZONES_DATA_PRODUCT_ID,
GELIOS_ZONES_DATA_PRODUCT_VERSION,
GELIOS_ZONES_ONTOLOGY_REVISION,
GELIOS_ZONE_SOURCE_ID_PREFIX,
geliosProviderPackageV6,
} from "./package.mjs";
@@ -0,0 +1,192 @@
import { geliosProviderPackageV5 } from "../v5/package.mjs";
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v6";
export const GELIOS_PROVIDER_PACKAGE_VERSION = "6.0.0";
export const GELIOS_ZONES_DATA_PRODUCT_ID = "map.zones.current.v1";
export const GELIOS_ZONES_DATA_PRODUCT_VERSION = "1.0.0";
export const GELIOS_ZONES_ONTOLOGY_REVISION = "ontology.map.zone.v1";
export const GELIOS_ZONE_SOURCE_ID_PREFIX = "gelios-zone-";
const ZONES_CAPABILITY_ID = "gelios.geozones.current.read";
const ZONES_FIELD_POLICY_ID = "gelios.geozones.current.fields.v1";
const ZONES_PROFILE_ID = "gelios.geozones.current.realtime.v1";
const ZONES_MAPPING_ID = "gelios.geozones.to.map.zones.current.v1";
const ZONES_TEMPLATE_ID = "gelios.geozones.current.l2.v1";
const value = structuredClone(geliosProviderPackageV5);
const authModeId = value.authModes[0].id;
const zonesFields = [
"area_square_meters",
"description",
"display_name",
"geometry",
"geometry_kind",
"max_speed_kph",
"perimeter_meters",
"source_kind",
"source_revision",
"style_color",
];
value.id = GELIOS_PROVIDER_PACKAGE_ID;
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
value.manifest = {
...value.manifest,
id: "gelios.provider.manifest.v6",
version: GELIOS_PROVIDER_PACKAGE_VERSION,
capabilityIds: [...value.manifest.capabilityIds, ZONES_CAPABILITY_ID],
fieldPolicyIds: [...value.manifest.fieldPolicyIds, ZONES_FIELD_POLICY_ID],
collectionProfileIds: [...value.manifest.collectionProfileIds, ZONES_PROFILE_ID],
dataProductIds: [...value.manifest.dataProductIds, GELIOS_ZONES_DATA_PRODUCT_ID],
mappingContractIds: [...value.manifest.mappingContractIds, ZONES_MAPPING_ID],
l2TemplateIds: [...value.manifest.l2TemplateIds, ZONES_TEMPLATE_ID],
};
value.capabilities.push({
id: ZONES_CAPABILITY_ID,
classification: "read",
status: "implemented",
authModeId,
request: {
method: "GET",
baseUrl: "https://api.geliospro.com",
path: "/api/v1/geozones",
query: { pl: 100, po: 0 },
response: {
collectionPaths: ["items"],
pagination: {
mode: "offset",
limitParameter: "pl",
offsetParameter: "po",
pageSize: 100,
itemsPath: "items",
totalPath: "paginationMetadata.totalCount",
maxPages: 50,
maxItems: 5000,
maxResponseBytes: 16 * 1024 * 1024,
},
},
},
entityScope: {
mode: "all_visible_to_credential",
refresh: "each_collection_run",
businessEntityFilter: "forbidden",
},
});
value.fieldPolicies.push({
id: ZONES_FIELD_POLICY_ID,
version: GELIOS_ZONES_DATA_PRODUCT_VERSION,
dataProductId: GELIOS_ZONES_DATA_PRODUCT_ID,
targetFields: [...zonesFields],
unknownSourceFields: "drop",
dynamicSourceFields: "drop_until_classified",
restrictedSourcePaths: ["accessToken", "authorization", "refreshToken", "userToken"],
});
value.collectionProfiles.push({
id: ZONES_PROFILE_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
mode: "realtime",
schedule: { intervalMs: 3_600_000 },
capabilityIds: [ZONES_CAPABILITY_ID],
dataProductId: GELIOS_ZONES_DATA_PRODUCT_ID,
mappingContractId: ZONES_MAPPING_ID,
fieldPolicyId: ZONES_FIELD_POLICY_ID,
l2TemplateId: ZONES_TEMPLATE_ID,
entityScope: {
mode: "all_visible_to_credential",
refresh: "each_collection_run",
businessEntityFilter: "forbidden",
},
batching: { maxFacts: 5000 },
cardinality: { maxCurrentEntities: 5000, onExceed: "require_partitioned_data_product" },
retry: { maxAttempts: 4, backoff: "exponential_with_jitter" },
});
value.dataProducts.push({
id: GELIOS_ZONES_DATA_PRODUCT_ID,
version: GELIOS_ZONES_DATA_PRODUCT_VERSION,
ontologyRevision: GELIOS_ZONES_ONTOLOGY_REVISION,
deliveryMode: "snapshot+patch",
semanticTypes: ["map.zone"],
fields: [...zonesFields],
fieldContracts: {
area_square_meters: { type: "number", required: false, minimum: 0 },
description: { type: "string", required: false },
display_name: { type: "string", required: true },
geometry: { type: "geometry", required: true },
geometry_kind: { type: "string", required: true, enum: ["circle", "corridor", "polygon"] },
max_speed_kph: { type: "number", required: false, minimum: 0 },
perimeter_meters: { type: "number", required: false, minimum: 0 },
source_kind: { type: "string", required: true, enum: ["live_api", "versioned_snapshot"] },
source_revision: { type: "string", required: true },
style_color: { type: "string", required: false },
},
history: { mode: "none", retentionDays: 1 },
});
value.mappingContracts.push({
schemaVersion: "nodedc.semantic-mapping/v1",
id: ZONES_MAPPING_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
sourceCapabilityId: ZONES_CAPABILITY_ID,
fieldPolicyId: ZONES_FIELD_POLICY_ID,
target: {
dataProductId: GELIOS_ZONES_DATA_PRODUCT_ID,
version: GELIOS_ZONES_DATA_PRODUCT_VERSION,
ontologyRevision: GELIOS_ZONES_ONTOLOGY_REVISION,
semanticType: "map.zone",
},
derivations: {
geometry_kind: {
kind: "ordered_rules",
rules: ["type_circle.circle", "type_line.corridor", "otherwise.polygon"],
default: "polygon",
},
},
fact: {
sourceId: { strategy: "first_non_empty", paths: ["id"], coerce: "string", prefix: GELIOS_ZONE_SOURCE_ID_PREFIX },
semanticType: { constant: "map.zone" },
observedAt: { strategy: "first_non_empty", paths: ["updatedAt"], coerce: "unix_or_iso_timestamp", fallback: "collection_received_at" },
geometry: {
type: "GeoJSON",
strategy: "gelios_geozone_v1",
paths: ["type", "points", "line", "radius"],
allowedTypes: ["Polygon", "MultiPolygon"],
omitIfInvalid: false,
},
attributes: {
area_square_meters: { strategy: "first_non_empty", paths: ["surfaceArea", "surface_area"], coerce: "number", minimum: 0, omitIfInvalid: true },
description: { strategy: "first_non_empty", paths: ["description", "descr"], coerce: "string", omitIfMissing: true },
display_name: { strategy: "first_non_empty", paths: ["name"], coerce: "string" },
geometry_kind: { derive: "geometry_kind" },
max_speed_kph: { strategy: "first_non_empty", paths: ["maxPermissibleSpeed", "max_permissible_speed"], coerce: "number", minimum: 0, omitIfInvalid: true },
perimeter_meters: { strategy: "first_non_empty", paths: ["perimeter"], coerce: "number", minimum: 0, omitIfInvalid: true },
source_kind: { constant: "live_api" },
source_revision: { constant: "gelios-rest-v1" },
style_color: { strategy: "first_non_empty", paths: ["color"], coerce: "string", omitIfMissing: true },
},
},
});
value.l2Templates.push({
schemaVersion: "nodedc.l2-template/v1",
id: ZONES_TEMPLATE_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
runtime: "ndc_l2",
instanceMode: "one_connection_per_workflow",
connectionParameters: ["tenant_id", "connection_id", "collection_profile_id", "provider_credential_ref"],
credentialBindings: structuredClone(value.l2Templates[0].credentialBindings),
steps: [
{ id: "collection.trigger", kind: "collection_trigger", collectionProfileDriven: true },
{ id: "provider.fetch-geozones", kind: "provider_request", capabilityId: ZONES_CAPABILITY_ID },
{ id: "provider.extract-geozones", kind: "extract_items", capabilityId: ZONES_CAPABILITY_ID },
{ id: "ontology.map", kind: "semantic_mapping", mappingContractId: ZONES_MAPPING_ID },
{ id: "data-product.publish", kind: "data_product_publish", dataProductId: GELIOS_ZONES_DATA_PRODUCT_ID, nodeType: "n8n-nodes-ndc.ndcDataProductPublish" },
],
invariants: structuredClone(value.l2Templates[0].invariants),
});
export const geliosProviderPackageV6 = deepFreeze(value);
function deepFreeze(input) {
if (!input || typeof input !== "object" || Object.isFrozen(input)) return input;
Object.freeze(input);
for (const child of Object.values(input)) deepFreeze(child);
return input;
}
@@ -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 });
}
@@ -72,6 +72,32 @@ assert.equal(validateDataProductPublish({
...publish,
facts: [{ ...fact, geometry: { type: "Point", coordinates: [37.6173, 90.0001] } }],
}).errors.includes("facts[0].geometry.latitude_out_of_range"), true);
const zoneGenerationAt = "2026-07-15T10:10:00.000Z";
const zoneFact = {
sourceId: "gelios-zone-42",
semanticType: "map.zone",
observedAt: zoneGenerationAt,
attributes: { display_name: "Zone 42" },
geometry: {
type: "Polygon",
coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.60, 55.74]]],
},
};
const replacement = {
...publish,
batch: { ...publish.batch, mode: "replace", generationAt: zoneGenerationAt },
facts: [zoneFact],
};
assert.equal(validateDataProductPublish(replacement).ok, true);
assert.equal(validateDataProductPublish({ ...replacement, facts: [] }).ok, true);
assert.equal(validateDataProductPublish({
...replacement,
facts: [{ ...zoneFact, observedAt: "2026-07-15T10:09:59.000Z" }],
}).errors.includes("facts[0].observedAt_must_equal_generationAt"), true);
assert.equal(validateDataProductPublish({
...replacement,
facts: [{ ...zoneFact, geometry: { ...zoneFact.geometry, coordinates: [[[37.60, 55.74], [37.62, 55.74], [37.62, 55.76], [37.61, 55.75]]] } }],
}).errors.includes("facts[0].geometry[0]_linear_ring_must_be_closed"), true);
const canonicalFact = { ...fact, receivedAt: "2026-07-15T10:00:01.000Z" };
const snapshot = {
@@ -147,7 +173,11 @@ assert.equal(validateDataProductPatch(patch).ok, true);
assert.equal(validateDataProductPatch({
...patch,
operations: [{ op: "delete", fact: canonicalFact }],
}).errors.includes("operations[0].op_must_be_upsert"), true);
}).errors.includes("operations[0].op_invalid"), true);
assert.equal(validateDataProductPatch({
...patch,
operations: [{ op: "remove", sourceId: "gelios-zone-42", semanticType: "map.zone" }],
}).ok, true);
assert.equal(validateDataProductPatch({
...patch,
operations: [{ op: "upsert", fact: { ...canonicalFact, attributes: attributesWithSerializedSize((64 * 1024) + 1) } }],
@@ -18,6 +18,7 @@ import { geliosProviderPackageV2 } from "../providers/gelios/v2/index.mjs";
import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs";
import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs";
import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs";
import { geliosProviderPackageV6 } from "../providers/gelios/v6/index.mjs";
import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
const expectedFields = [
@@ -41,6 +42,24 @@ assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { ok: true, e
assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] });
const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.zones.current.v1");
const registeredZonesProduct = JSON.parse(await readFile(new URL(
"../../../services/external-data-plane/definitions/map.zones.current.v1.json",
import.meta.url,
), "utf8"));
assert.deepEqual(zonesProduct, registeredZonesProduct);
assert.deepEqual(zonesProduct.semanticTypes, ["map.zone"]);
assert.equal(zonesProduct.fieldContracts.geometry.type, "geometry");
const zonesCapability = geliosProviderPackageV6.capabilities.find((capability) => capability.id === "gelios.geozones.current.read");
assert.equal(zonesCapability.request.query.pl, 100);
assert.equal(zonesCapability.request.query.po, 0);
assert.equal(zonesCapability.request.response.pagination.maxItems, 5000);
assert.equal(zonesCapability.request.response.pagination.totalPath, "paginationMetadata.totalCount");
const zonesMapping = geliosProviderPackageV6.mappingContracts.find((mapping) => mapping.id === "gelios.geozones.to.map.zones.current.v1");
assert.deepEqual(zonesMapping.fact.observedAt.paths, ["updatedAt"]);
assert.equal(zonesMapping.fact.observedAt.fallback, "collection_received_at");
const strictProduct = geliosProviderPackageV5.dataProducts[0];
const registeredStrictProduct = JSON.parse(await readFile(new URL(
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import {
ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
normalizeZoneSourceGeneration,
validateDataProductPublish,
} from "../src/index.mjs";
const generatedAt = "2026-07-20T18:00:00.000Z";
const live = normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "gelios-rest-v1",
sourceRevision: "gelios-rest-v1",
generatedAt,
complete: true,
zones: [{
id: 42,
name: "Polygon zone",
type: "polygon",
points: [
{ latitude: 55.74, longitude: 37.60 },
{ latitude: 55.74, longitude: 37.62 },
{ latitude: 55.76, longitude: 37.62 },
],
color: "#ff00ff",
}, {
id: 43,
name: "Circle zone",
type: "circle",
points: [{ latitude: 55.75, longitude: 37.61 }],
radius: 150,
}],
});
assert.equal(live.complete, true);
assert.equal(live.sourceKind, "live_api");
assert.equal(live.facts.length, 2);
assert.deepEqual(live.facts.map((fact) => fact.sourceId), ["gelios-zone-42", "gelios-zone-43"]);
assert.equal(live.facts.every((fact) => fact.observedAt === generatedAt), true);
assert.equal(live.facts.every((fact) => fact.geometry.type === "Polygon"), true);
assert.equal(validateDataProductPublish({
schemaVersion: "nodedc.data-product.publish/v1",
batch: { runId: "zones-live-1", sequence: 0, idempotencyKey: "zones-live-1.batch-0", mode: "replace", generationAt: generatedAt },
facts: live.facts,
}).ok, true);
const snapshotDigest = "a".repeat(64);
const snapshot = normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "mmap-snapshot-v1",
snapshotDigest,
generatedAt,
complete: true,
zones: [{
snapshotKey: "legacy-zone-a",
name: "Snapshot zone",
type: "polygon",
points: "55.74,37.60;55.74,37.62;55.76,37.62",
}],
}, { identityCrosswalk: { "legacy-zone-a": 42 } });
assert.equal(snapshot.sourceKind, "versioned_snapshot");
assert.equal(snapshot.facts[0].sourceId, live.facts[0].sourceId);
assert.match(snapshot.sourceRevision, new RegExp(`@sha256:${snapshotDigest}$`));
assert.throws(() => normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "mmap-snapshot-v1",
snapshotDigest,
generatedAt,
complete: true,
zones: [{ snapshotKey: "unknown", name: "Unknown", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
}), /zone_source_identity_crosswalk_required/);
assert.throws(() => normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "mmap-snapshot-v1",
snapshotDigest,
generatedAt,
complete: true,
zones: [{ id: 42, name: "Uncrossed", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
}), /zone_source_identity_crosswalk_required/);
assert.throws(() => normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "gelios-rest-v1",
generatedAt,
complete: true,
zones: [{ name: "No native id", type: "polygon", points: "55.74,37.60;55.74,37.62;55.76,37.62" }],
}), /zone_source_native_identity_required/);
assert.throws(() => normalizeZoneSourceGeneration({
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
adapterId: "gelios-rest-v1",
generatedAt,
complete: false,
zones: [],
}), /zone_source_generation_incomplete/);
console.log("external-provider zone source: ok");