feat(platform): add replaceable geozone data layer

This commit is contained in:
Codex
2026-07-20 21:48:27 +03:00
parent 8a7465cf0e
commit 3446f3edc2
31 changed files with 1342 additions and 128 deletions
@@ -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)) {