feat(platform): add replaceable geozone data layer
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user