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
@@ -20,7 +20,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
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 FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]);
const L2_STEP_KINDS = new Set([
"collection_trigger",
"provider_request",
@@ -84,6 +84,10 @@ const CAPABILITY_KEYS = new Set([
]);
const REQUEST_KEYS = new Set(["method", "baseUrl", "path", "query", "response"]);
const RESPONSE_KEYS = new Set(["collectionPaths", "pagination"]);
const OFFSET_PAGINATION_KEYS = new Set([
"mode", "limitParameter", "offsetParameter", "pageSize", "itemsPath", "totalPath",
"maxPages", "maxItems", "maxResponseBytes",
]);
const ENTITY_SCOPE_KEYS = new Set(["mode", "refresh", "businessEntityFilter"]);
const FIELD_POLICY_KEYS = new Set([
"id",
@@ -137,7 +141,9 @@ const EXPRESSION_KEYS = new Set([
"strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive",
"omitIfMissing", "omitIfInvalid", "minimum", "maximum",
]);
const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
const GEOMETRY_KEYS = new Set([
"type", "longitude", "latitude", "strategy", "paths", "allowedTypes", "omitIfInvalid",
]);
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
const TEMPLATE_KEYS = new Set([
"schemaVersion",
@@ -496,12 +502,50 @@ function validateCapability(value, path, errors) {
} else {
rejectUnknownKeys(value.request.response, RESPONSE_KEYS, `${path}.request.response`, errors);
requiredCollectionPathArray(value.request.response.collectionPaths, `${path}.request.response.collectionPaths`, errors);
if (value.request.response.pagination !== "single_bounded_response") errors.push(`${path}.request.response.pagination_invalid`);
validateResponsePagination(value.request.response.pagination, value.request.query, `${path}.request.response.pagination`, errors);
}
}
validateCredentialVisibleScope(value.entityScope, `${path}.entityScope`, errors);
}
function validateResponsePagination(value, query, path, errors) {
if (value === "single_bounded_response") return;
if (!isPlainObject(value)) {
errors.push(`${path}_invalid`);
return;
}
rejectUnknownKeys(value, OFFSET_PAGINATION_KEYS, path, errors);
if (value.mode !== "offset") errors.push(`${path}.mode_must_be_offset`);
for (const field of ["limitParameter", "offsetParameter"]) {
if (typeof value[field] !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value[field])) {
errors.push(`${path}.${field}_invalid`);
}
}
if (!Number.isInteger(value.pageSize) || value.pageSize < 1 || value.pageSize > 500) {
errors.push(`${path}.pageSize_invalid`);
}
if (!Number.isInteger(value.maxPages) || value.maxPages < 1 || value.maxPages > 1000) {
errors.push(`${path}.maxPages_invalid`);
}
if (!Number.isInteger(value.maxItems) || value.maxItems < 1 || value.maxItems > 5000) {
errors.push(`${path}.maxItems_invalid`);
}
if (!Number.isInteger(value.maxResponseBytes) || value.maxResponseBytes < 1024 || value.maxResponseBytes > 32 * 1024 * 1024) {
errors.push(`${path}.maxResponseBytes_invalid`);
}
for (const field of ["itemsPath", "totalPath"]) {
if (!isCanonicalSourcePath(value[field])) errors.push(`${path}.${field}_invalid`);
}
if (isPlainObject(query)) {
if (query[value.limitParameter] !== value.pageSize) errors.push(`${path}.pageSize_must_match_static_query_limit`);
if (query[value.offsetParameter] !== 0) errors.push(`${path}.offset_query_must_start_at_zero`);
}
if (Number.isInteger(value.maxPages) && Number.isInteger(value.pageSize)
&& Number.isInteger(value.maxItems) && value.maxPages * value.pageSize < value.maxItems) {
errors.push(`${path}.maxPages_cannot_cover_maxItems`);
}
}
function validateFieldPolicy(value, path, errors) {
rejectUnknownKeys(value, FIELD_POLICY_KEYS, path, errors);
requiredIdentifier(value.id, `${path}.id`, errors);
@@ -619,10 +663,26 @@ function validateMappingContract(value, path, errors) {
errors.push(`${path}.fact.geometry_must_be_object`);
} else if (isPlainObject(value.fact.geometry)) {
rejectUnknownKeys(value.fact.geometry, GEOMETRY_KEYS, `${path}.fact.geometry`, errors);
if (value.fact.geometry.type !== "Point") errors.push(`${path}.fact.geometry.type_must_be_Point`);
validateExpression(value.fact.geometry.longitude, `${path}.fact.geometry.longitude`, errors);
validateExpression(value.fact.geometry.latitude, `${path}.fact.geometry.latitude`, errors);
if (value.fact.geometry.omitIfInvalid !== true) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_true`);
if (value.fact.geometry.type === "Point") {
validateExpression(value.fact.geometry.longitude, `${path}.fact.geometry.longitude`, errors);
validateExpression(value.fact.geometry.latitude, `${path}.fact.geometry.latitude`, errors);
if (value.fact.geometry.omitIfInvalid !== true) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_true`);
if (value.fact.geometry.strategy !== undefined || value.fact.geometry.paths !== undefined || value.fact.geometry.allowedTypes !== undefined) {
errors.push(`${path}.fact.geometry.point_shape_invalid`);
}
} else if (value.fact.geometry.type === "GeoJSON") {
if (value.fact.geometry.strategy !== "gelios_geozone_v1") errors.push(`${path}.fact.geometry.strategy_invalid`);
requiredSourcePathArray(value.fact.geometry.paths, `${path}.fact.geometry.paths`, errors);
if (!sameSet(value.fact.geometry.allowedTypes, ["Polygon", "MultiPolygon"])) {
errors.push(`${path}.fact.geometry.allowedTypes_must_be_polygon_or_multipolygon`);
}
if (value.fact.geometry.omitIfInvalid !== false) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_false`);
if (value.fact.geometry.longitude !== undefined || value.fact.geometry.latitude !== undefined) {
errors.push(`${path}.fact.geometry.geojson_shape_invalid`);
}
} else {
errors.push(`${path}.fact.geometry.type_invalid`);
}
}
if (value.fact.attributes !== undefined && !isPlainObject(value.fact.attributes)) {
errors.push(`${path}.fact.attributes_must_be_object`);
@@ -773,7 +833,13 @@ function validateMappingAgainstProduct(mapping, product, errors) {
for (const [field, contract] of Object.entries(contracts)) {
if (!isPlainObject(contract)) continue;
if (field === "geometry") {
if (contract.type !== "point") errors.push(`${path}.fact.geometry_contract_must_be_point`);
if (contract.type === "point" && mapping.fact?.geometry?.type !== "Point") {
errors.push(`${path}.fact.geometry_contract_must_be_point`);
}
if (contract.type === "geometry" && mapping.fact?.geometry?.type !== "GeoJSON") {
errors.push(`${path}.fact.geometry_contract_must_be_geojson`);
}
if (!new Set(["point", "geometry"]).has(contract.type)) errors.push(`${path}.fact.geometry_contract_invalid`);
if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) {
errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`);
}
@@ -805,7 +871,7 @@ function validateFieldContract(value, path, errors) {
if (value.enum !== undefined) {
if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) {
errors.push(`${path}.enum_must_be_nonempty_unique_array`);
} else if (new Set(["point", "string_array"]).has(value.type)
} else if (new Set(["point", "geometry", "string_array"]).has(value.type)
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
errors.push(`${path}.enum_value_type_invalid`);
}
@@ -880,6 +946,11 @@ function fieldContractValueMatchesType(value, type) {
&& value.coordinates.length === 2
&& value.coordinates.every(Number.isFinite);
}
if (type === "geometry") {
return isPlainObject(value)
&& new Set(["Point", "LineString", "Polygon", "MultiPolygon"]).has(value.type)
&& Array.isArray(value.coordinates);
}
return typeof value === type && (type !== "number" || Number.isFinite(value));
}