feat(platform): complete the Gelios external data loop
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "fleet.positions.current.v2",
|
||||
"version": "2.0.0",
|
||||
"ontologyRevision": "ontology.map.moving_object.v2",
|
||||
"deliveryMode": "snapshot+patch",
|
||||
"semanticTypes": [
|
||||
"map.moving_object"
|
||||
],
|
||||
"fields": [
|
||||
"availability_state",
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"freshness_state",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"motion_state",
|
||||
"object_kind",
|
||||
"operational_status",
|
||||
"position_source",
|
||||
"position_state",
|
||||
"position_valid",
|
||||
"quality_flags",
|
||||
"satellite_count",
|
||||
"speed_kph",
|
||||
"state_policy_version"
|
||||
],
|
||||
"history": {
|
||||
"mode": "sampled",
|
||||
"intervalMs": 60000,
|
||||
"strategy": "latest-per-entity-per-bucket",
|
||||
"retentionDays": 90
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "fleet.positions.current.v3",
|
||||
"version": "3.0.0",
|
||||
"ontologyRevision": "ontology.map.moving_object.v3",
|
||||
"deliveryMode": "snapshot+patch",
|
||||
"semanticTypes": [
|
||||
"map.moving_object"
|
||||
],
|
||||
"fields": [
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"movement_state",
|
||||
"object_kind",
|
||||
"position_source",
|
||||
"satellite_count",
|
||||
"signal_state",
|
||||
"speed_kph"
|
||||
],
|
||||
"history": {
|
||||
"mode": "sampled",
|
||||
"intervalMs": 60000,
|
||||
"strategy": "latest-per-entity-per-bucket",
|
||||
"retentionDays": 90
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"id": "fleet.positions.current.v4",
|
||||
"version": "4.0.0",
|
||||
"ontologyRevision": "ontology.map.moving_object.v3",
|
||||
"deliveryMode": "snapshot+patch",
|
||||
"semanticTypes": [
|
||||
"map.moving_object"
|
||||
],
|
||||
"fields": [
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"movement_state",
|
||||
"object_kind",
|
||||
"position_source",
|
||||
"satellite_count",
|
||||
"signal_state",
|
||||
"speed_kph"
|
||||
],
|
||||
"fieldContracts": {
|
||||
"course_degrees": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"minimum": 0,
|
||||
"maximum": 360
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"elevation_meters": {
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
"geometry": {
|
||||
"type": "point",
|
||||
"required": false
|
||||
},
|
||||
"hdop": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"minimum": 0
|
||||
},
|
||||
"horizontal_accuracy_meters": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"minimum": 0
|
||||
},
|
||||
"movement_state": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"enum": [
|
||||
"moving",
|
||||
"stopped"
|
||||
]
|
||||
},
|
||||
"object_kind": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"position_source": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"satellite_count": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"minimum": 0
|
||||
},
|
||||
"signal_state": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"enum": [
|
||||
"active",
|
||||
"inactive"
|
||||
]
|
||||
},
|
||||
"speed_kph": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"mode": "sampled",
|
||||
"intervalMs": 60000,
|
||||
"strategy": "latest-per-entity-per-bucket",
|
||||
"retentionDays": 90
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "node src/server.mjs",
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/reader-source-scope.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs",
|
||||
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/definitions.test.mjs",
|
||||
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/data-product-delivery.test.mjs && node test/definitions.test.mjs",
|
||||
"test:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
|
||||
const result = await db.query(
|
||||
`select id, version, ontology_revision as "ontologyRevision",
|
||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||
fields, history_policy as "historyPolicy", active,
|
||||
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from external_data_plane_products
|
||||
where id = $1 ${activeOnly ? "and active = true" : ""}`,
|
||||
@@ -25,8 +25,8 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
|
||||
export async function persistDataProductDefinition(db, definition) {
|
||||
const result = await db.query(
|
||||
`insert into external_data_plane_products (
|
||||
id, version, ontology_revision, delivery_mode, semantic_types, fields, history_policy
|
||||
) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb)
|
||||
id, version, ontology_revision, delivery_mode, semantic_types, fields, field_contracts, history_policy
|
||||
) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb)
|
||||
on conflict (id) do update set
|
||||
active = true,
|
||||
updated_at = now()
|
||||
@@ -35,10 +35,11 @@ export async function persistDataProductDefinition(db, definition) {
|
||||
and external_data_plane_products.delivery_mode = excluded.delivery_mode
|
||||
and external_data_plane_products.semantic_types = excluded.semantic_types
|
||||
and external_data_plane_products.fields = excluded.fields
|
||||
and external_data_plane_products.field_contracts = excluded.field_contracts
|
||||
and external_data_plane_products.history_policy = excluded.history_policy
|
||||
returning id, version, ontology_revision as "ontologyRevision",
|
||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||
fields, history_policy as "historyPolicy", active,
|
||||
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||
created_at as "createdAt", updated_at as "updatedAt"`,
|
||||
[
|
||||
definition.id,
|
||||
@@ -47,6 +48,7 @@ export async function persistDataProductDefinition(db, definition) {
|
||||
definition.deliveryMode,
|
||||
JSON.stringify(definition.semanticTypes),
|
||||
JSON.stringify(definition.fields),
|
||||
JSON.stringify(definition.fieldContracts || {}),
|
||||
JSON.stringify(definition.history),
|
||||
],
|
||||
);
|
||||
@@ -658,6 +660,8 @@ function publishFingerprint(batch) {
|
||||
export function assertPublishMatchesDefinition(batch, definition) {
|
||||
const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []);
|
||||
const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []);
|
||||
const fieldContracts = definition?.fieldContracts && typeof definition.fieldContracts === "object"
|
||||
&& !Array.isArray(definition.fieldContracts) ? definition.fieldContracts : {};
|
||||
const entityKeys = new Set();
|
||||
for (const fact of batch?.facts || []) {
|
||||
if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422);
|
||||
@@ -672,9 +676,55 @@ export function assertPublishMatchesDefinition(batch, definition) {
|
||||
if (fact.geometry !== undefined && !geometryDeclared(fields)) {
|
||||
throw deliveryError("data_product_geometry_forbidden", 422);
|
||||
}
|
||||
for (const [field, contract] of Object.entries(fieldContracts)) {
|
||||
const resolved = resolveContractField(fact, field);
|
||||
if (!resolved.present) {
|
||||
if (contract.required === true) throw deliveryError("data_product_field_required", 422);
|
||||
continue;
|
||||
}
|
||||
assertFieldContractValue(resolved.value, contract);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContractField(fact, field) {
|
||||
if (field === "geometry") return { present: fact.geometry !== undefined, value: fact.geometry };
|
||||
if (field === "source_id") return { present: fact.sourceId !== undefined, value: fact.sourceId };
|
||||
if (field === "semantic_type") return { present: fact.semanticType !== undefined, value: fact.semanticType };
|
||||
if (field === "observed_at") return { present: fact.observedAt !== undefined, value: fact.observedAt };
|
||||
const attribute = field.startsWith("attributes.") ? field.slice("attributes.".length) : field;
|
||||
const attributes = fact.attributes || {};
|
||||
return { present: Object.hasOwn(attributes, attribute), value: attributes[attribute] };
|
||||
}
|
||||
|
||||
function assertFieldContractValue(value, contract) {
|
||||
if (!fieldContractValueMatchesType(value, contract.type)) {
|
||||
throw deliveryError("data_product_field_type_invalid", 422);
|
||||
}
|
||||
if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) {
|
||||
throw deliveryError("data_product_field_value_forbidden", 422);
|
||||
}
|
||||
if (typeof value === "number" && (
|
||||
(Number.isFinite(contract.minimum) && value < contract.minimum)
|
||||
|| (Number.isFinite(contract.maximum) && value > contract.maximum)
|
||||
)) throw deliveryError("data_product_field_value_out_of_range", 422);
|
||||
}
|
||||
|
||||
function fieldContractValueMatchesType(value, type) {
|
||||
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
if (type === "point") {
|
||||
return value?.type === "Point"
|
||||
&& Array.isArray(value.coordinates)
|
||||
&& value.coordinates.length === 2
|
||||
&& value.coordinates.every(Number.isFinite)
|
||||
&& value.coordinates[0] >= -180
|
||||
&& value.coordinates[0] <= 180
|
||||
&& value.coordinates[1] >= -90
|
||||
&& value.coordinates[1] <= 90;
|
||||
}
|
||||
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||
}
|
||||
|
||||
function geometryDeclared(fields) {
|
||||
return fields.has("geometry")
|
||||
|| fields.has("coordinates")
|
||||
|
||||
@@ -2,10 +2,12 @@ const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
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 DEFINITION_KEYS = new Set([
|
||||
"id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "history",
|
||||
"id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history",
|
||||
]);
|
||||
const HISTORY_KEYS = new Set(["mode", "intervalMs", "retentionDays", "strategy"]);
|
||||
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
|
||||
|
||||
export function normalizeDataProductDefinition(value) {
|
||||
if (!isPlainObject(value) || !hasOnlyKeys(value, DEFINITION_KEYS)) {
|
||||
@@ -30,8 +32,58 @@ export function normalizeDataProductDefinition(value) {
|
||||
}
|
||||
if (!semanticTypes.length || !fields.length) throw policyError("data_product_definition_shape_invalid");
|
||||
|
||||
const fieldContracts = normalizeFieldContracts(value.fieldContracts, fields);
|
||||
const history = normalizeHistoryPolicy(value.history);
|
||||
return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, history });
|
||||
return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, fieldContracts, history });
|
||||
}
|
||||
|
||||
export function normalizeFieldContracts(value, fields) {
|
||||
if (value === undefined) return Object.freeze({});
|
||||
if (!isPlainObject(value)) throw policyError("data_product_field_contracts_invalid");
|
||||
const names = Object.keys(value);
|
||||
if (names.length === 0) return Object.freeze({});
|
||||
if (names.length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) {
|
||||
throw policyError("data_product_field_contracts_must_match_fields");
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
for (const field of fields) normalized[field] = normalizeFieldContract(value[field]);
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function normalizeFieldContract(value) {
|
||||
if (!isPlainObject(value) || !hasOnlyKeys(value, FIELD_CONTRACT_KEYS)) {
|
||||
throw policyError("data_product_field_contract_invalid");
|
||||
}
|
||||
const type = string(value.type);
|
||||
if (!FIELD_CONTRACT_TYPES.has(type) || typeof value.required !== "boolean") {
|
||||
throw policyError("data_product_field_contract_shape_invalid");
|
||||
}
|
||||
const contract = { type, required: value.required };
|
||||
if (value.enum !== undefined) {
|
||||
if (!Array.isArray(value.enum) || value.enum.length === 0
|
||||
|| new Set(value.enum.map(stableLiteral)).size !== value.enum.length
|
||||
|| new Set(["point", "string_array"]).has(type)
|
||||
|| value.enum.some((item) => !fieldContractValueMatchesType(item, type))) {
|
||||
throw policyError("data_product_field_contract_enum_invalid");
|
||||
}
|
||||
contract.enum = Object.freeze([...value.enum].sort((left, right) => stableLiteral(left).localeCompare(stableLiteral(right))));
|
||||
}
|
||||
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||
if (type !== "number"
|
||||
|| (value.minimum !== undefined && !Number.isFinite(value.minimum))
|
||||
|| (value.maximum !== undefined && !Number.isFinite(value.maximum))
|
||||
|| (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum)) {
|
||||
throw policyError("data_product_field_contract_range_invalid");
|
||||
}
|
||||
if (value.minimum !== undefined) contract.minimum = value.minimum;
|
||||
if (value.maximum !== undefined) contract.maximum = value.maximum;
|
||||
if (contract.enum?.some((item) => (
|
||||
(contract.minimum !== undefined && item < contract.minimum)
|
||||
|| (contract.maximum !== undefined && item > contract.maximum)
|
||||
))) throw policyError("data_product_field_contract_enum_out_of_range");
|
||||
}
|
||||
return Object.freeze(contract);
|
||||
}
|
||||
|
||||
export function normalizeHistoryPolicy(value = { mode: "none" }) {
|
||||
@@ -62,6 +114,7 @@ export function safeDataProductDefinition(row) {
|
||||
deliveryMode: row.deliveryMode,
|
||||
semanticTypes: array(row.semanticTypes),
|
||||
fields: array(row.fields),
|
||||
fieldContracts: isPlainObject(row.fieldContracts) ? row.fieldContracts : {},
|
||||
history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {},
|
||||
active: row.active === true,
|
||||
createdAt: iso(row.createdAt),
|
||||
@@ -116,3 +169,11 @@ function isPlainObject(value) {
|
||||
function hasOnlyKeys(value, allowed) {
|
||||
return Object.keys(value).every((key) => allowed.has(key));
|
||||
}
|
||||
|
||||
function fieldContractValueMatchesType(value, type) {
|
||||
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||
}
|
||||
|
||||
function stableLiteral(value) {
|
||||
return `${typeof value}:${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,33 @@ export async function migrate(pool) {
|
||||
delivery_mode text not null,
|
||||
semantic_types jsonb not null,
|
||||
fields jsonb not null,
|
||||
field_contracts jsonb not null default '{}'::jsonb,
|
||||
history_policy jsonb not null,
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
check (jsonb_typeof(semantic_types) = 'array' and jsonb_array_length(semantic_types) > 0),
|
||||
check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0)
|
||||
check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0),
|
||||
constraint external_data_plane_products_field_contracts_object_ck
|
||||
check (jsonb_typeof(field_contracts) = 'object')
|
||||
)
|
||||
`);
|
||||
await pool.query("alter table external_data_plane_products add column if not exists field_contracts jsonb not null default '{}'::jsonb");
|
||||
await pool.query(`
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conrelid = 'external_data_plane_products'::regclass
|
||||
and conname = 'external_data_plane_products_field_contracts_object_ck'
|
||||
) then
|
||||
alter table external_data_plane_products
|
||||
add constraint external_data_plane_products_field_contracts_object_ck
|
||||
check (jsonb_typeof(field_contracts) = 'object');
|
||||
end if;
|
||||
end
|
||||
$$
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists external_data_plane_batches (
|
||||
|
||||
@@ -1179,7 +1179,7 @@ async function listGrantedProducts(binding) {
|
||||
const result = await pool.query(
|
||||
`select id, version, ontology_revision as "ontologyRevision",
|
||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||
fields, history_policy as "historyPolicy", active,
|
||||
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from external_data_plane_products
|
||||
where active = true and id = any($1::text[])
|
||||
|
||||
@@ -38,6 +38,12 @@ try {
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fields: ["source_id", "observed_at", "geometry", "status"],
|
||||
fieldContracts: {
|
||||
source_id: { type: "string", required: true },
|
||||
observed_at: { type: "string", required: true },
|
||||
geometry: { type: "point", required: true },
|
||||
status: { type: "string", required: true },
|
||||
},
|
||||
history: {
|
||||
mode: "sampled",
|
||||
intervalMs: 60_000,
|
||||
@@ -47,6 +53,7 @@ try {
|
||||
});
|
||||
await persistDataProductDefinition(pool, definition);
|
||||
const storedDefinition = await loadDataProductDefinition(pool, definition.id);
|
||||
assert.deepEqual(storedDefinition.fieldContracts, definition.fieldContracts);
|
||||
const binding = {
|
||||
id: "reader-binding-test",
|
||||
bindingKey: "managed-reader-binding-test",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { assertPublishMatchesDefinition } from "../src/data-product-delivery.mjs";
|
||||
import { normalizeDataProductDefinition } from "../src/data-product-policy.mjs";
|
||||
|
||||
const definition = normalizeDataProductDefinition({
|
||||
id: "test.positions.current.v2",
|
||||
version: "2.0.0",
|
||||
ontologyRevision: "ontology.test.positions.v1",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fields: ["display_name", "geometry", "movement_state", "signal_state", "speed_kph"],
|
||||
fieldContracts: {
|
||||
display_name: { type: "string", required: true },
|
||||
geometry: { type: "point", required: false },
|
||||
movement_state: { type: "string", required: true, enum: ["moving", "stopped"] },
|
||||
signal_state: { type: "string", required: true, enum: ["active", "inactive"] },
|
||||
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||
},
|
||||
history: { mode: "none", retentionDays: 1 },
|
||||
});
|
||||
|
||||
const fact = {
|
||||
sourceId: "unit-1",
|
||||
semanticType: "map.moving_object",
|
||||
observedAt: "2026-07-20T10:00:00.000Z",
|
||||
geometry: { type: "Point", coordinates: [37.6173, 55.7558] },
|
||||
attributes: {
|
||||
display_name: "Unit 1",
|
||||
movement_state: "moving",
|
||||
signal_state: "active",
|
||||
speed_kph: 12,
|
||||
},
|
||||
};
|
||||
const batch = { facts: [fact] };
|
||||
assert.doesNotThrow(() => assertPublishMatchesDefinition(batch, definition));
|
||||
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{ ...fact, attributes: { ...fact.attributes, movement_state: "teleporting" } }],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden",
|
||||
);
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{ ...fact, attributes: { ...fact.attributes, signal_state: "invented" } }],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden",
|
||||
);
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{
|
||||
...fact,
|
||||
attributes: Object.fromEntries(Object.entries(fact.attributes).filter(([field]) => field !== "signal_state")),
|
||||
}],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_required",
|
||||
);
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: "12" } }],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
|
||||
);
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_value_out_of_range",
|
||||
);
|
||||
assert.throws(
|
||||
() => assertPublishMatchesDefinition({
|
||||
facts: [{ ...fact, geometry: { type: "Point", coordinates: [181, 55.7558] } }],
|
||||
}, definition),
|
||||
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
|
||||
);
|
||||
|
||||
const legacyDefinition = normalizeDataProductDefinition({
|
||||
id: "test.positions.current.v1",
|
||||
version: "1.0.0",
|
||||
ontologyRevision: "ontology.test.positions.v1",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
fields: ["movement_state", "signal_state"],
|
||||
history: { mode: "none", retentionDays: 1 },
|
||||
});
|
||||
assert.doesNotThrow(() => assertPublishMatchesDefinition({
|
||||
facts: [{
|
||||
sourceId: "unit-legacy",
|
||||
semanticType: "map.moving_object",
|
||||
attributes: { movement_state: "teleporting", signal_state: "invented" },
|
||||
}],
|
||||
}, legacyDefinition));
|
||||
|
||||
console.log("external-data-plane data product delivery policy: ok");
|
||||
@@ -24,6 +24,8 @@ assert.deepEqual(input.semanticTypes, ["vehicle.trike", "map.moving_object"]);
|
||||
assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]);
|
||||
assert.equal(Object.isFrozen(definition.semanticTypes), true);
|
||||
assert.equal(Object.isFrozen(definition.fields), true);
|
||||
assert.deepEqual(definition.fieldContracts, {});
|
||||
assert.equal(Object.isFrozen(definition.fieldContracts), true);
|
||||
|
||||
const reordered = normalizeDataProductDefinition({
|
||||
...input,
|
||||
@@ -78,4 +80,49 @@ assert.throws(
|
||||
/data_product_definition_fields_duplicate/,
|
||||
);
|
||||
|
||||
const strict = normalizeDataProductDefinition({
|
||||
...input,
|
||||
fields: ["geometry", "movement_state", "signal_state", "speed_kph"],
|
||||
fieldContracts: {
|
||||
geometry: { type: "point", required: false },
|
||||
movement_state: { type: "string", required: true, enum: ["stopped", "moving"] },
|
||||
signal_state: { type: "string", required: true, enum: ["inactive", "active"] },
|
||||
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(strict.fieldContracts.movement_state.enum, ["moving", "stopped"]);
|
||||
assert.equal(Object.isFrozen(strict.fieldContracts), true);
|
||||
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true);
|
||||
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true);
|
||||
|
||||
assert.throws(
|
||||
() => normalizeDataProductDefinition({
|
||||
...input,
|
||||
fieldContracts: { status: { type: "string", required: true } },
|
||||
}),
|
||||
/data_product_field_contracts_must_match_fields/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDataProductDefinition({
|
||||
...input,
|
||||
fieldContracts: Object.fromEntries(input.fields.map((field) => [
|
||||
field,
|
||||
field === "status"
|
||||
? { type: "string", required: true, enum: ["active", 1] }
|
||||
: { type: field === "geometry" ? "point" : "string", required: false },
|
||||
])),
|
||||
}),
|
||||
/data_product_field_contract_enum_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDataProductDefinition({
|
||||
...strict,
|
||||
fieldContracts: {
|
||||
...strict.fieldContracts,
|
||||
speed_kph: { type: "number", required: false, minimum: 10, maximum: 1 },
|
||||
},
|
||||
}),
|
||||
/data_product_field_contract_range_invalid/,
|
||||
);
|
||||
|
||||
console.log("external-data-plane data product policy: ok");
|
||||
|
||||
@@ -6,7 +6,12 @@ import { join } from "node:path";
|
||||
import { loadDataProductDefinitions } from "../src/definitions.mjs";
|
||||
|
||||
const bundled = await loadDataProductDefinitions();
|
||||
assert.deepEqual(bundled.map((definition) => definition.id), ["fleet.positions.current.v1"]);
|
||||
assert.deepEqual(bundled.map((definition) => definition.id), [
|
||||
"fleet.positions.current.v1",
|
||||
"fleet.positions.current.v2",
|
||||
"fleet.positions.current.v3",
|
||||
"fleet.positions.current.v4",
|
||||
]);
|
||||
assert.deepEqual(bundled[0].semanticTypes, ["map.moving_object"]);
|
||||
assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1");
|
||||
assert.equal(bundled[0].history.mode, "sampled");
|
||||
@@ -15,6 +20,32 @@ assert.equal(bundled[0].history.retentionDays, 90);
|
||||
assert.equal(bundled[0].fields.includes("geometry"), true);
|
||||
assert.equal(bundled[0].fields.includes("providerUnitId"), false);
|
||||
assert.equal(bundled[0].fields.includes("provider_unit_id"), false);
|
||||
assert.equal(bundled[1].version, "2.0.0");
|
||||
assert.equal(bundled[1].ontologyRevision, "ontology.map.moving_object.v2");
|
||||
assert.deepEqual(
|
||||
bundled[1].fields.filter((field) => field.endsWith("_state")),
|
||||
["availability_state", "freshness_state", "motion_state", "position_state"],
|
||||
);
|
||||
assert.equal(bundled[2].version, "3.0.0");
|
||||
assert.equal(bundled[2].ontologyRevision, "ontology.map.moving_object.v3");
|
||||
assert.deepEqual(
|
||||
bundled[2].fields.filter((field) => field.endsWith("_state")),
|
||||
["movement_state", "signal_state"],
|
||||
);
|
||||
assert.equal(bundled[2].fields.includes("operational_status"), false);
|
||||
assert.equal(bundled[3].version, "4.0.0");
|
||||
assert.equal(bundled[3].ontologyRevision, "ontology.map.moving_object.v3");
|
||||
assert.deepEqual(bundled[3].fieldContracts.signal_state, {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["active", "inactive"],
|
||||
});
|
||||
assert.deepEqual(bundled[3].fieldContracts.movement_state, {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["moving", "stopped"],
|
||||
});
|
||||
assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length);
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user