feat(platform): add replaceable geozone data layer
This commit is contained in:
@@ -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");
|
||||
@@ -25,7 +25,7 @@ export class NdcDataProductPublish implements INodeType {
|
||||
name: 'ndcDataProductPublish',
|
||||
icon: NDC_NODE_ICON,
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
version: [1, 2],
|
||||
subtitle: '={{$parameter["dataProductId"]}}',
|
||||
description: 'Publish canonical facts through a scoped NDC Data Product grant',
|
||||
defaults: { name: 'NDC Data Product Publish' },
|
||||
@@ -41,6 +41,19 @@ export class NdcDataProductPublish implements INodeType {
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Publish Mode',
|
||||
name: 'publishMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Create or Update', value: 'upsert' },
|
||||
{ name: 'Replace Complete Snapshot', value: 'replace' },
|
||||
],
|
||||
default: 'upsert',
|
||||
required: true,
|
||||
displayOptions: { show: { '@version': [2] } },
|
||||
description: 'Replace commits one complete generation atomically and removes keys absent from it',
|
||||
},
|
||||
{
|
||||
displayName: 'Batch Sequence',
|
||||
name: 'sequence',
|
||||
@@ -71,6 +84,9 @@ export class NdcDataProductPublish implements INodeType {
|
||||
const inputItems = this.getInputData();
|
||||
const dataProductId = this.getNodeParameter('dataProductId', 0) as string;
|
||||
const sequence = this.getNodeParameter('sequence', 0, 0) as number;
|
||||
const publishMode = this.getNode().typeVersion >= 2
|
||||
? this.getNodeParameter('publishMode', 0, 'upsert') as 'upsert' | 'replace'
|
||||
: 'upsert';
|
||||
let body;
|
||||
let encodedProductId;
|
||||
try {
|
||||
@@ -81,6 +97,7 @@ export class NdcDataProductPublish implements INodeType {
|
||||
this.getNode().id,
|
||||
dataProductId,
|
||||
sequence,
|
||||
publishMode,
|
||||
);
|
||||
encodedProductId = encodeIdentifierPath(dataProductId, 'dataProductId');
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,18 +18,23 @@ export interface NdcFact extends IDataObject {
|
||||
semanticType: string;
|
||||
observedAt: string;
|
||||
attributes?: IDataObject;
|
||||
geometry?: {
|
||||
type: 'Point';
|
||||
coordinates: [number, number];
|
||||
};
|
||||
geometry?: NdcGeometry;
|
||||
}
|
||||
|
||||
export type NdcGeometry =
|
||||
| { type: 'Point'; coordinates: [number, number] }
|
||||
| { type: 'LineString'; coordinates: [number, number][] }
|
||||
| { type: 'Polygon'; coordinates: [number, number][][] }
|
||||
| { type: 'MultiPolygon'; coordinates: [number, number][][][] };
|
||||
|
||||
export interface NdcPublishPayload extends IDataObject {
|
||||
schemaVersion: typeof DATA_PRODUCT_PUBLISH_SCHEMA_VERSION;
|
||||
batch: {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
idempotencyKey: string;
|
||||
mode?: 'replace';
|
||||
generationAt?: string;
|
||||
};
|
||||
facts: NdcFact[];
|
||||
}
|
||||
@@ -62,12 +67,16 @@ export function encodeIdentifierPath(value: unknown, field: string): string {
|
||||
return encodeURIComponent(requireIdentifier(value, field));
|
||||
}
|
||||
|
||||
export function factsFromItems(items: INodeExecutionData[]): NdcFact[] {
|
||||
if (!items.length) throw new Error('facts_required');
|
||||
if (items.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
|
||||
export function factsFromItems(items: INodeExecutionData[], allowEmpty = false): NdcFact[] {
|
||||
const batchFacts = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
||||
? items[0].json.facts
|
||||
: null;
|
||||
const sources = batchFacts ?? items.map((item) => (isObject(item.json.fact) ? item.json.fact : item.json));
|
||||
if (!sources.length && !allowEmpty) throw new Error('facts_required');
|
||||
if (sources.length > MAX_FACTS) throw new Error('facts_limit_exceeded');
|
||||
const entityKeys = new Set<string>();
|
||||
return items.map((item, index) => {
|
||||
const source = isObject(item.json.fact) ? item.json.fact : item.json;
|
||||
return sources.map((source, index) => {
|
||||
if (!isObject(source)) throw new Error(`facts_${index}_invalid`);
|
||||
const fact: NdcFact = {
|
||||
sourceId: requireIdentifier(source.sourceId, `facts_${index}_sourceId`),
|
||||
semanticType: requireIdentifier(source.semanticType, `facts_${index}_semanticType`),
|
||||
@@ -84,7 +93,7 @@ export function factsFromItems(items: INodeExecutionData[]): NdcFact[] {
|
||||
if (containsSecretLikeMaterial(source.attributes)) throw new Error(`facts_${index}_attributes_secret_material_forbidden`);
|
||||
fact.attributes = source.attributes;
|
||||
}
|
||||
if (source.geometry !== undefined) fact.geometry = normalizePoint(source.geometry, index);
|
||||
if (source.geometry !== undefined) fact.geometry = normalizeGeometry(source.geometry, index);
|
||||
return fact;
|
||||
});
|
||||
}
|
||||
@@ -96,6 +105,7 @@ export function buildPublishPayload(
|
||||
nodeId: string,
|
||||
dataProductId: string,
|
||||
sequence: number,
|
||||
publishMode: 'upsert' | 'replace' = 'upsert',
|
||||
): NdcPublishPayload {
|
||||
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 2_147_483_647) throw new Error('batch_sequence_invalid');
|
||||
requireIdentifier(dataProductId, 'dataProductId');
|
||||
@@ -103,10 +113,22 @@ export function buildPublishPayload(
|
||||
const idempotencyKey = `publish-${sha256(
|
||||
`${workflowId}|${executionId}|${nodeId}|${dataProductId}|${sequence}`,
|
||||
)}`;
|
||||
const facts = factsFromItems(items, publishMode === 'replace');
|
||||
const explicitGenerationAt = items.length === 1 && Array.isArray(items[0]?.json?.facts)
|
||||
? items[0].json.generationAt
|
||||
: undefined;
|
||||
const generationAt = publishMode === 'replace'
|
||||
? replacementGenerationAt(facts, explicitGenerationAt)
|
||||
: undefined;
|
||||
return {
|
||||
schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION,
|
||||
batch: { runId, sequence, idempotencyKey },
|
||||
facts: factsFromItems(items),
|
||||
batch: {
|
||||
runId,
|
||||
sequence,
|
||||
idempotencyKey,
|
||||
...(generationAt ? { mode: 'replace' as const, generationAt } : {}),
|
||||
},
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,18 +276,60 @@ function requireFoundrySlotId(value: unknown): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePoint(value: unknown, index: number): NdcFact['geometry'] {
|
||||
if (!isObject(value) || value.type !== 'Point' || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) {
|
||||
function normalizeGeometry(value: unknown, index: number): NdcGeometry {
|
||||
if (!isObject(value) || !['Point', 'LineString', 'Polygon', 'MultiPolygon'].includes(String(value.type))
|
||||
|| !Array.isArray(value.coordinates)) {
|
||||
throw new Error(`facts_${index}_geometry_invalid`);
|
||||
}
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
if (![longitude, latitude].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate))) {
|
||||
throw new Error(`facts_${index}_geometry_coordinates_invalid`);
|
||||
if (Buffer.byteLength(JSON.stringify(value)) > 192 * 1024) throw new Error(`facts_${index}_geometry_size_exceeded`);
|
||||
let vertices = 0;
|
||||
const position = (candidate: unknown, path: string): [number, number] => {
|
||||
if (!Array.isArray(candidate) || candidate.length !== 2 || !candidate.every(Number.isFinite)) {
|
||||
throw new Error(`${path}_position_invalid`);
|
||||
}
|
||||
const [longitude, latitude] = candidate as [number, number];
|
||||
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||
throw new Error(`${path}_position_out_of_range`);
|
||||
}
|
||||
vertices += 1;
|
||||
if (vertices > 10_000) throw new Error(`facts_${index}_geometry_vertex_limit_exceeded`);
|
||||
return [longitude, latitude];
|
||||
};
|
||||
const line = (candidate: unknown, path: string, minimum: number): [number, number][] => {
|
||||
if (!Array.isArray(candidate) || candidate.length < minimum) throw new Error(`${path}_line_invalid`);
|
||||
return candidate.map((entry, positionIndex) => position(entry, `${path}_${positionIndex}`));
|
||||
};
|
||||
const ring = (candidate: unknown, path: string): [number, number][] => {
|
||||
const result = line(candidate, path, 4);
|
||||
const first = result[0];
|
||||
const last = result.at(-1);
|
||||
if (!last || first[0] !== last[0] || first[1] !== last[1]) throw new Error(`${path}_ring_not_closed`);
|
||||
return result;
|
||||
};
|
||||
const polygon = (candidate: unknown, path: string): [number, number][][] => {
|
||||
if (!Array.isArray(candidate) || !candidate.length) throw new Error(`${path}_polygon_invalid`);
|
||||
return candidate.map((entry, ringIndex) => ring(entry, `${path}_${ringIndex}`));
|
||||
};
|
||||
if (value.type === 'Point') return { type: 'Point', coordinates: position(value.coordinates, `facts_${index}_geometry`) };
|
||||
if (value.type === 'LineString') return { type: 'LineString', coordinates: line(value.coordinates, `facts_${index}_geometry`, 2) };
|
||||
if (value.type === 'Polygon') return { type: 'Polygon', coordinates: polygon(value.coordinates, `facts_${index}_geometry`) };
|
||||
const coordinates = value.coordinates.map((entry, polygonIndex) => polygon(entry, `facts_${index}_geometry_${polygonIndex}`));
|
||||
if (!coordinates.length) throw new Error(`facts_${index}_geometry_multipolygon_invalid`);
|
||||
return { type: 'MultiPolygon', coordinates };
|
||||
}
|
||||
|
||||
function replacementGenerationAt(facts: NdcFact[], explicit: unknown): string {
|
||||
const declared = explicit === undefined ? undefined : requireIsoTimestamp(explicit, 'replace_generationAt');
|
||||
const timestamps = [...new Set(facts.map((fact) => new Date(fact.observedAt).toISOString()))];
|
||||
if (!timestamps.length) {
|
||||
if (!declared) throw new Error('replace_generationAt_required_for_empty_generation');
|
||||
return new Date(declared).toISOString();
|
||||
}
|
||||
if ((longitude as number) < -180 || (longitude as number) > 180 || (latitude as number) < -90 || (latitude as number) > 90) {
|
||||
throw new Error(`facts_${index}_geometry_coordinates_out_of_range`);
|
||||
if (timestamps.length !== 1) throw new Error('replace_generation_requires_one_observedAt');
|
||||
if (declared && new Date(declared).toISOString() !== timestamps[0]) {
|
||||
throw new Error('replace_generationAt_observedAt_mismatch');
|
||||
}
|
||||
return { type: 'Point', coordinates: [longitude as number, latitude as number] };
|
||||
return timestamps[0];
|
||||
}
|
||||
|
||||
function requireIsoTimestamp(value: unknown, field: string): string {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@n8n/node-cli": "0.39.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-nodes-ndc",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -146,6 +146,64 @@ async function main() {
|
||||
publish.batch.idempotencyKey,
|
||||
contracts.buildPublishPayload(items, 'execution-42', 'workflow-7', 'node-3', 'fleet.positions.current.v2', 0).batch.idempotencyKey,
|
||||
);
|
||||
const zoneItems = [{
|
||||
json: {
|
||||
sourceId: 'gelios-zone-42',
|
||||
semanticType: 'map.zone',
|
||||
observedAt: '2026-07-15T12:10:00.000Z',
|
||||
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 = contracts.buildPublishPayload(
|
||||
zoneItems,
|
||||
'execution-43',
|
||||
'workflow-7',
|
||||
'node-zones',
|
||||
'map.zones.current.v1',
|
||||
0,
|
||||
'replace',
|
||||
);
|
||||
assert.equal(externalContract.validateDataProductPublish(replacement).ok, true);
|
||||
assert.equal(replacement.batch.mode, 'replace');
|
||||
assert.equal(replacement.batch.generationAt, zoneItems[0].json.observedAt);
|
||||
const replacementBatch = contracts.buildPublishPayload(
|
||||
[{ json: { generationAt: zoneItems[0].json.observedAt, facts: zoneItems.map((item) => item.json) } }],
|
||||
'execution-44',
|
||||
'workflow-7',
|
||||
'node-zones',
|
||||
'map.zones.current.v1',
|
||||
0,
|
||||
'replace',
|
||||
);
|
||||
assert.deepEqual(replacementBatch.facts, replacement.facts);
|
||||
assert.equal(replacementBatch.batch.generationAt, zoneItems[0].json.observedAt);
|
||||
const emptyReplacement = contracts.buildPublishPayload(
|
||||
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: [] } }],
|
||||
'execution-45',
|
||||
'workflow-7',
|
||||
'node-zones',
|
||||
'map.zones.current.v1',
|
||||
0,
|
||||
'replace',
|
||||
);
|
||||
assert.deepEqual(emptyReplacement.facts, []);
|
||||
assert.equal(externalContract.validateDataProductPublish(emptyReplacement).ok, true);
|
||||
assert.throws(
|
||||
() => contracts.buildPublishPayload(
|
||||
[{ json: { generationAt: '2026-07-15T13:00:00.000Z', facts: zoneItems.map((item) => item.json) } }],
|
||||
'execution-46',
|
||||
'workflow-7',
|
||||
'node-zones',
|
||||
'map.zones.current.v1',
|
||||
0,
|
||||
'replace',
|
||||
),
|
||||
/replace_generationAt_observedAt_mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => contracts.buildPublishPayload(
|
||||
[{ json: { ...items[0].json, attributes: { accessToken: 'forbidden' } } }],
|
||||
@@ -303,6 +361,29 @@ async function assertNodeHttpContracts(nodes, externalContract, constants) {
|
||||
assert.equal(externalContract.validateDataProductPublish(publishCalls[0].options.body).ok, true);
|
||||
assert.equal(publishResult[0][0].json.publishedFactCount, 1);
|
||||
|
||||
const replacementCalls = [];
|
||||
const replacementContext = executionContext({
|
||||
typeVersion: 2,
|
||||
parameters: { dataProductId: 'map.zones.current.v1', publishMode: 'replace', sequence: 0 },
|
||||
input: [{
|
||||
json: {
|
||||
generationAt: '2026-07-15T12:10:00.000Z',
|
||||
facts: [{
|
||||
sourceId: 'gelios-zone-42',
|
||||
semanticType: 'map.zone',
|
||||
observedAt: '2026-07-15T12:10:00.000Z',
|
||||
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]]] },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
response: { ok: true, publishedFactCount: 1, currentRemovedCount: 0 },
|
||||
calls: replacementCalls,
|
||||
});
|
||||
await nodes.get('NdcDataProductPublish').execute.call(replacementContext);
|
||||
assert.equal(replacementCalls[0].options.body.batch.mode, 'replace');
|
||||
assert.equal(externalContract.validateDataProductPublish(replacementCalls[0].options.body).ok, true);
|
||||
|
||||
const readCalls = [];
|
||||
const readContext = executionContext({
|
||||
parameters: { dataProductId: 'fleet.positions.current.v1', pageSize: 1000 },
|
||||
@@ -411,7 +492,7 @@ async function assertNodeHttpContracts(nodes, externalContract, constants) {
|
||||
}
|
||||
}
|
||||
|
||||
function executionContext({ parameters, input = [{ json: {} }], response, calls }) {
|
||||
function executionContext({ parameters, input = [{ json: {} }], response, calls, typeVersion = 1 }) {
|
||||
return {
|
||||
getNodeParameter(name, _index, defaultValue) {
|
||||
return Object.prototype.hasOwnProperty.call(parameters, name) ? parameters[name] : defaultValue;
|
||||
@@ -419,7 +500,7 @@ function executionContext({ parameters, input = [{ json: {} }], response, calls
|
||||
getInputData() { return input; },
|
||||
getExecutionId() { return 'execution-42'; },
|
||||
getWorkflow() { return { id: 'workflow-7' }; },
|
||||
getNode() { return { id: 'node-3' }; },
|
||||
getNode() { return { id: 'node-3', typeVersion }; },
|
||||
helpers: {
|
||||
async httpRequestWithAuthentication(credentialName, options) {
|
||||
calls.push({ credentialName, options });
|
||||
|
||||
Reference in New Issue
Block a user