186 lines
7.9 KiB
JavaScript
186 lines
7.9 KiB
JavaScript
import { isBoundedGeoJsonGeometry, isBoundedTelemetryReadings } from "@nodedc/external-provider-contract/data-plane";
|
|
|
|
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", "telemetry_readings", "point", "geometry"]);
|
|
const DEFINITION_KEYS = new Set([
|
|
"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)) {
|
|
throw policyError("data_product_definition_invalid");
|
|
}
|
|
const id = identifier(value.id);
|
|
const version = string(value.version);
|
|
const ontologyRevision = identifier(value.ontologyRevision);
|
|
const deliveryMode = string(value.deliveryMode);
|
|
const semanticTypes = identifierSet(
|
|
value.semanticTypes,
|
|
"data_product_definition_semantic_types_invalid",
|
|
"data_product_definition_semantic_types_duplicate",
|
|
);
|
|
const fields = identifierSet(
|
|
value.fields,
|
|
"data_product_definition_fields_invalid",
|
|
"data_product_definition_fields_duplicate",
|
|
);
|
|
if (!id || !SEMVER.test(version) || !ontologyRevision || !DELIVERY_MODES.has(deliveryMode)) {
|
|
throw policyError("data_product_definition_identity_invalid");
|
|
}
|
|
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, 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", "geometry", "string_array", "telemetry_readings"]).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" }) {
|
|
if (!isPlainObject(value) || !hasOnlyKeys(value, HISTORY_KEYS)) throw policyError("history_policy_invalid");
|
|
const mode = string(value.mode);
|
|
if (!HISTORY_MODES.has(mode)) throw policyError("history_policy_mode_invalid");
|
|
const retentionDays = integer(value.retentionDays, mode === "none" ? 1 : 90, 1, 3650);
|
|
if (mode === "none") {
|
|
if (value.intervalMs !== undefined || value.strategy !== undefined) throw policyError("history_policy_none_has_sampling_fields");
|
|
return Object.freeze({ mode, retentionDays });
|
|
}
|
|
if (mode === "all") {
|
|
if (value.intervalMs !== undefined || value.strategy !== undefined) throw policyError("history_policy_all_has_sampling_fields");
|
|
return Object.freeze({ mode, retentionDays });
|
|
}
|
|
|
|
const intervalMs = integer(value.intervalMs, 60_000, 1_000, 24 * 60 * 60 * 1000);
|
|
const strategy = string(value.strategy || "latest-per-entity-per-bucket");
|
|
if (strategy !== "latest-per-entity-per-bucket") throw policyError("history_policy_strategy_invalid");
|
|
return Object.freeze({ mode, intervalMs, strategy, retentionDays });
|
|
}
|
|
|
|
export function safeDataProductDefinition(row) {
|
|
return {
|
|
id: row.id,
|
|
version: row.version,
|
|
ontologyRevision: row.ontologyRevision,
|
|
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),
|
|
updatedAt: iso(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function policyError(code) {
|
|
return Object.assign(new Error(code), { status: 400, code });
|
|
}
|
|
|
|
function identifier(value) {
|
|
const normalized = string(value);
|
|
return IDENTIFIER.test(normalized) ? normalized : "";
|
|
}
|
|
|
|
function identifierSet(value, invalidCode, duplicateCode) {
|
|
if (!Array.isArray(value)) throw policyError(invalidCode);
|
|
|
|
const normalized = value.map((entry) => {
|
|
const result = identifier(entry);
|
|
if (!result) throw policyError(invalidCode);
|
|
return result;
|
|
});
|
|
if (new Set(normalized).size !== normalized.length) throw policyError(duplicateCode);
|
|
|
|
return Object.freeze(normalized.sort());
|
|
}
|
|
|
|
function integer(value, fallback, min, max) {
|
|
const number = value === undefined ? fallback : Number(value);
|
|
if (!Number.isInteger(number) || number < min || number > max) throw policyError("history_policy_number_invalid");
|
|
return number;
|
|
}
|
|
|
|
function string(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function array(value) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function iso(value) {
|
|
return value ? new Date(value).toISOString() : undefined;
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function hasOnlyKeys(value, allowed) {
|
|
return Object.keys(value).every((key) => allowed.has(key));
|
|
}
|
|
|
|
function fieldContractValueMatchesType(value, type) {
|
|
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
if (type === "telemetry_readings") return isBoundedTelemetryReadings(value);
|
|
if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"]));
|
|
if (type === "geometry") return isBoundedGeoJsonGeometry(value);
|
|
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
|
}
|
|
|
|
function stableLiteral(value) {
|
|
return `${typeof value}:${JSON.stringify(value)}`;
|
|
}
|