feat(foundry): support typed non-geometric subject aspects

This commit is contained in:
Codex
2026-07-23 09:43:51 +03:00
parent dc82ae4c07
commit 03aa9e3e7e
4 changed files with 376 additions and 7 deletions
+19 -6
View File
@@ -157,6 +157,7 @@ function validateConsumerContract(value) {
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
const supportedGeometryTypes = new Set(["Point", "LineString", "Polygon", "MultiPolygon"]);
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
? value.fieldTypes
: null;
@@ -174,7 +175,9 @@ function validateConsumerContract(value) {
|| !fieldTypes
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|| JSON.stringify(geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"])
|| geometryTypes.length > supportedGeometryTypes.size
|| new Set(geometryTypes).size !== geometryTypes.length
|| geometryTypes.some((type) => !supportedGeometryTypes.has(type))
|| value.subjectIdentity !== "semantic-type+source-id"
|| value.snapshotMode !== "atomic-replace"
|| value.patchMode !== "atomic"
@@ -198,6 +201,8 @@ function assertConsumerContract(contract, product, binding) {
field,
product?.fieldContracts?.[field]?.type,
]));
const productHasGeometry = Array.isArray(product.fields) && product.fields.includes("geometry");
const contractHasGeometry = contract.geometryTypes.length > 0;
if (
product.ontologyRevision !== contract.ontologyRevision
|| product.deliveryMode !== contract.deliveryMode
@@ -207,6 +212,8 @@ function assertConsumerContract(contract, product, binding) {
|| !Array.isArray(product.fields)
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|| productHasGeometry !== contractHasGeometry
|| (contractHasGeometry && product?.fieldContracts?.geometry?.type !== "geometry")
) throw consumerError("data_product_consumer_contract_mismatch", 409);
}
@@ -219,11 +226,17 @@ function assertEnvelopeProduct(envelope, product, errorCode) {
function assertFactContract(fact, policy) {
const contract = policy.consumerContract;
if (!contract) return;
if (
!contract.semanticTypes.includes(fact.semanticType)
|| !fact.geometry
|| !contract.geometryTypes.includes(fact.geometry.type)
) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
if (!contract.semanticTypes.includes(fact.semanticType)) {
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
}
const hasGeometry = fact.geometry !== undefined && fact.geometry !== null;
if (contract.geometryTypes.length === 0) {
if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
return;
}
if (!hasGeometry || !contract.geometryTypes.includes(fact.geometry.type)) {
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
}
}
function validatePolicy(policy, product) {
@@ -667,6 +667,7 @@ const zoneV2Product = {
schedule_timezone: { type: "string", required: false },
applies_to_couriers: { type: "boolean", required: false },
applies_to_kicksharing: { type: "boolean", required: false },
geometry: { type: "geometry", required: true },
},
active: true,
};
@@ -862,3 +863,219 @@ test("zone v2 consumer fails closed on projection, product, envelope and geometr
}
}
});
const unitProfileV1Projection = [
"corrected_engine_hours_factor",
"corrected_mileage_factor",
"display_name",
"filter_by_satellite_count_enabled",
"filter_by_satellite_count_value",
"filter_emissions",
"hardware_manufacturer_name",
"hardware_port",
"hardware_type_class",
"hardware_type_name",
"limit_acceleration",
"lost_connection_enabled",
"lost_connection_time_value",
"maximum_permissible_speed",
"maximum_valid_height",
"maximum_valid_speed",
"mileage_by_ignition",
"minimum_movement_speed",
"minimum_movement_time",
"minimum_parking_time",
"minimum_stop_time",
"minimum_trip_distance",
"minimum_valid_height",
"speed_parameter",
"trip_detection_type",
"unit_type_class",
"unit_type_name",
"use_odometer",
];
const unitProfileV1FieldTypes = {
corrected_engine_hours_factor: "number",
corrected_mileage_factor: "number",
display_name: "string",
filter_by_satellite_count_enabled: "boolean",
filter_by_satellite_count_value: "number",
filter_emissions: "number",
hardware_manufacturer_name: "string",
hardware_port: "number",
hardware_type_class: "string",
hardware_type_name: "string",
limit_acceleration: "number",
lost_connection_enabled: "boolean",
lost_connection_time_value: "number",
maximum_permissible_speed: "number",
maximum_valid_height: "number",
maximum_valid_speed: "number",
mileage_by_ignition: "boolean",
minimum_movement_speed: "number",
minimum_movement_time: "number",
minimum_parking_time: "number",
minimum_stop_time: "number",
minimum_trip_distance: "number",
minimum_valid_height: "number",
speed_parameter: "string",
trip_detection_type: "string",
unit_type_class: "string",
unit_type_name: "string",
use_odometer: "boolean",
};
const unitProfileV1Policy = {
id: "map-moving-object-unit-profile-current-v1",
version: "1.0.0",
dataProductId: "fleet.units.profile.current.v1",
productVersion: "1.0.0",
freshness: "none",
staleAfterMs: null,
terminalStatuses: [],
consumerContract: {
ontologyRevision: "ontology.map.moving_object.v3",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: unitProfileV1Projection,
fieldTypes: unitProfileV1FieldTypes,
geometryTypes: [],
subjectIdentity: "semantic-type+source-id",
snapshotMode: "atomic-replace",
patchMode: "atomic",
},
removeMode: "canonical-tombstone-or-snapshot-rebase",
};
const unitProfileV1Product = {
id: "fleet.units.profile.current.v1",
version: "1.0.0",
ontologyRevision: "ontology.map.moving_object.v3",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fields: [...unitProfileV1Projection],
fieldContracts: Object.fromEntries(unitProfileV1Projection.map((field) => [
field,
{ type: unitProfileV1FieldTypes[field], required: field === "display_name" },
])),
active: true,
};
function unitProfileV1Target(fieldProjection = unitProfileV1Projection) {
return {
application: { id: "66666666-6666-4666-8666-666666666666" },
page: { id: "map" },
binding: {
id: "trike-unit-profile",
dataProductId: unitProfileV1Product.id,
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: [...fieldProjection],
},
};
}
function unitProfileV1Fact({ geometry } = {}) {
return {
sourceId: "gelios-unit-501",
semanticType: "map.moving_object",
observedAt: "2026-07-23T06:28:52.000Z",
receivedAt: "2026-07-23T06:28:53.000Z",
attributes: {
display_name: "501 B2",
hardware_manufacturer_name: "Gelios",
hardware_type_name: "Trike tracker",
maximum_permissible_speed: 25,
minimum_movement_speed: 3,
use_odometer: true,
},
...(geometry === undefined ? {} : { geometry }),
};
}
function unitProfileV1Snapshot(facts = [unitProfileV1Fact()]) {
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: unitProfileV1Product.id, version: unitProfileV1Product.version },
generatedAt: "2026-07-23T06:28:54.000Z",
cursor: "107",
facts,
};
}
test("non-geometric unit profile aspect is strictly typed and joins by stable subject identity", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-unit-profile-v1-"));
const currentTarget = unitProfileV1Target();
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(currentTarget),
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => unitProfileV1Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async () => Response.json(unitProfileV1Snapshot()),
});
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
try {
const plan = await manager.plan(input);
assert.deepEqual(plan.configuration.policy.consumerContract.geometryTypes, []);
assert.deepEqual(plan.configuration.policy.consumerContract.fieldProjection, unitProfileV1Projection);
const applied = await manager.apply({ ...input, planId: plan.planId });
assert.equal(applied.consumer.subjectCount, 1);
assert.equal(applied.consumer.subjects[0].subjectId, "gelios-unit-501");
assert.equal(applied.consumer.subjects[0].status, "active");
const persisted = await manager.snapshot(currentTarget);
assert.equal(persisted.facts[0].geometry, undefined);
assert.equal(persisted.facts[0].attributes.hardware_type_name, "Trike tracker");
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("non-geometric unit profile aspect fails closed on projection drift and injected geometry", async () => {
const variants = [
{
name: "projection",
target: unitProfileV1Target(unitProfileV1Projection.slice(1)),
snapshot: unitProfileV1Snapshot(),
planError: /data_product_consumer_contract_mismatch/,
},
{
name: "geometry",
target: unitProfileV1Target(),
snapshot: unitProfileV1Snapshot([unitProfileV1Fact({ geometry: { type: "Point", coordinates: [37.61, 55.75] } })]),
applyError: /data_product_consumer_fact_contract_invalid/,
},
];
for (const variant of variants) {
const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-unit-profile-v1-${variant.name}-`));
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(variant.target),
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => unitProfileV1Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async () => Response.json(variant.snapshot),
});
const input = { applicationId: variant.target.application.id, pageId: variant.target.page.id, bindingId: variant.target.binding.id };
try {
if (variant.planError) {
await assert.rejects(manager.plan(input), variant.planError);
} else {
const plan = await manager.plan(input);
await assert.rejects(manager.apply({ ...input, planId: plan.planId }), variant.applyError);
}
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
}
});