feat(foundry): add map zones v2 consumer policy
This commit is contained in:
@@ -138,6 +138,94 @@ function activeReaderGrantGeneration(state) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
function validateConsumerContract(value) {
|
||||
if (value === undefined) return null;
|
||||
const keys = value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.keys(value).sort()
|
||||
: [];
|
||||
if (JSON.stringify(keys) !== JSON.stringify([
|
||||
"deliveryMode",
|
||||
"fieldProjection",
|
||||
"fieldTypes",
|
||||
"geometryTypes",
|
||||
"ontologyRevision",
|
||||
"patchMode",
|
||||
"semanticTypes",
|
||||
"snapshotMode",
|
||||
"subjectIdentity",
|
||||
])) throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
|
||||
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
|
||||
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
|
||||
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
|
||||
? value.fieldTypes
|
||||
: null;
|
||||
if (
|
||||
!identifier.test(String(value.ontologyRevision || ""))
|
||||
|| value.deliveryMode !== "snapshot+patch"
|
||||
|| semanticTypes.length === 0
|
||||
|| semanticTypes.length > 8
|
||||
|| new Set(semanticTypes).size !== semanticTypes.length
|
||||
|| semanticTypes.some((item) => !identifier.test(item))
|
||||
|| fieldProjection.length === 0
|
||||
|| fieldProjection.length > 32
|
||||
|| new Set(fieldProjection).size !== fieldProjection.length
|
||||
|| fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))
|
||||
|| !fieldTypes
|
||||
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
||||
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|
||||
|| JSON.stringify(geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"])
|
||||
|| value.subjectIdentity !== "semantic-type+source-id"
|
||||
|| value.snapshotMode !== "atomic-replace"
|
||||
|| value.patchMode !== "atomic"
|
||||
) throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
return {
|
||||
ontologyRevision: value.ontologyRevision,
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: [...semanticTypes],
|
||||
fieldProjection: [...fieldProjection],
|
||||
fieldTypes: { ...fieldTypes },
|
||||
geometryTypes: [...geometryTypes],
|
||||
subjectIdentity: "semantic-type+source-id",
|
||||
snapshotMode: "atomic-replace",
|
||||
patchMode: "atomic",
|
||||
};
|
||||
}
|
||||
|
||||
function assertConsumerContract(contract, product, binding) {
|
||||
if (!contract) return;
|
||||
const productFieldTypes = Object.fromEntries(contract.fieldProjection.map((field) => [
|
||||
field,
|
||||
product?.fieldContracts?.[field]?.type,
|
||||
]));
|
||||
if (
|
||||
product.ontologyRevision !== contract.ontologyRevision
|
||||
|| product.deliveryMode !== contract.deliveryMode
|
||||
|| JSON.stringify(product.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
||||
|| JSON.stringify(binding.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
||||
|| JSON.stringify(binding.fieldProjection) !== JSON.stringify(contract.fieldProjection)
|
||||
|| !Array.isArray(product.fields)
|
||||
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|
||||
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|
||||
) throw consumerError("data_product_consumer_contract_mismatch", 409);
|
||||
}
|
||||
|
||||
function assertEnvelopeProduct(envelope, product, errorCode) {
|
||||
if (envelope?.dataProduct?.id !== product.id || envelope?.dataProduct?.version !== product.version) {
|
||||
throw consumerError(errorCode, 502);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFactContract(fact, policy) {
|
||||
const contract = policy.consumerContract;
|
||||
if (!contract) return;
|
||||
if (
|
||||
!contract.semanticTypes.includes(fact.semanticType)
|
||||
|| !fact.geometry
|
||||
|| !contract.geometryTypes.includes(fact.geometry.type)
|
||||
) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||
}
|
||||
|
||||
function validatePolicy(policy, product) {
|
||||
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
|
||||
throw consumerError("data_product_consumer_policy_not_found", 409);
|
||||
@@ -169,6 +257,7 @@ function validatePolicy(policy, product) {
|
||||
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
const consumerContract = validateConsumerContract(policy.consumerContract);
|
||||
return {
|
||||
id: String(policy.id || ""),
|
||||
version: String(policy.version || ""),
|
||||
@@ -178,6 +267,7 @@ function validatePolicy(policy, product) {
|
||||
staleAfterMs: policy.staleAfterMs,
|
||||
terminalStatuses: [...terminalStatuses],
|
||||
...(statusContract ? { statusContract } : {}),
|
||||
...(consumerContract ? { consumerContract } : {}),
|
||||
removeMode: policy.removeMode,
|
||||
};
|
||||
}
|
||||
@@ -347,6 +437,7 @@ export function createFoundryDataProductConsumerManager({
|
||||
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
|
||||
}
|
||||
const policy = validatePolicy(resolvePolicy(product), product);
|
||||
assertConsumerContract(policy.consumerContract, product, target.binding);
|
||||
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
|
||||
throw consumerError("data_product_consumer_status_field_not_projected", 409);
|
||||
}
|
||||
@@ -473,9 +564,11 @@ export function createFoundryDataProductConsumerManager({
|
||||
await writeState(record);
|
||||
try {
|
||||
const snapshot = await fetchSnapshot(record);
|
||||
assertEnvelopeProduct(snapshot, record.state.product, "data_product_snapshot_product_mismatch");
|
||||
const previousKeys = new Set(Object.keys(record.state.subjects || {}));
|
||||
const subjects = {};
|
||||
for (const fact of snapshot.facts) {
|
||||
assertFactContract(fact, record.state.policy);
|
||||
const key = factKey(fact);
|
||||
subjects[key] = {
|
||||
fact,
|
||||
@@ -645,6 +738,7 @@ export function createFoundryDataProductConsumerManager({
|
||||
const emittedOperations = [];
|
||||
for (const operation of patch.operations) {
|
||||
if (operation.op === "upsert") {
|
||||
assertFactContract(operation.fact, record.state.policy);
|
||||
const key = factKey(operation.fact);
|
||||
const subject = {
|
||||
fact: operation.fact,
|
||||
@@ -735,6 +829,7 @@ export function createFoundryDataProductConsumerManager({
|
||||
try { payload = JSON.parse(event.data); } catch { payload = null; }
|
||||
const patch = sanitizePatch(payload, record.target.binding);
|
||||
if (!patch) throw consumerError("data_product_patch_contract_invalid", 502);
|
||||
assertEnvelopeProduct(patch, record.state.product, "data_product_patch_product_mismatch");
|
||||
await applyPatch(record, patch);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user