|
|
@@ -6,6 +6,7 @@ import copy
|
|
|
import hashlib
|
|
|
import json
|
|
|
import re
|
|
|
+import secrets
|
|
|
from typing import Any
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
@@ -115,6 +116,98 @@ class DataRuleRepository:
|
|
|
def __init__(self, session):
|
|
|
self.session = session
|
|
|
|
|
|
+ def reserve_dataflow_draft(
|
|
|
+ self, *, actor_uid: str, lifetime_seconds: int = 600
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ if (
|
|
|
+ isinstance(lifetime_seconds, bool)
|
|
|
+ or not isinstance(lifetime_seconds, int)
|
|
|
+ or lifetime_seconds < 60
|
|
|
+ or lifetime_seconds > 1800
|
|
|
+ ):
|
|
|
+ raise ValueError("draft reservation lifetime is invalid")
|
|
|
+ reservation_id = new_governance_uid()
|
|
|
+ dataflow_uid = new_governance_uid()
|
|
|
+ nonce = secrets.token_urlsafe(32)
|
|
|
+ nonce_hash = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_draft_reservations "
|
|
|
+ "(id, dataflow_uid, actor_uid, nonce_hash, expires_at) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
|
|
|
+ "CAST(:actor_uid AS uuid), :nonce_hash, "
|
|
|
+ "CURRENT_TIMESTAMP + (:lifetime_seconds * INTERVAL '1 second')) "
|
|
|
+ "RETURNING expires_at /* reserve_dataflow_draft */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "actor_uid": actor,
|
|
|
+ "nonce_hash": nonce_hash,
|
|
|
+ "lifetime_seconds": lifetime_seconds,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise RuntimeError("dataflow draft reservation was not persisted")
|
|
|
+ expires_at = row["expires_at"]
|
|
|
+ return {
|
|
|
+ "reservation_id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "nonce": nonce,
|
|
|
+ "expires_at": (
|
|
|
+ expires_at.isoformat()
|
|
|
+ if hasattr(expires_at, "isoformat")
|
|
|
+ else str(expires_at)
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+ def consume_dataflow_draft(
|
|
|
+ self, receipt: dict[str, Any], *, actor_uid: str
|
|
|
+ ) -> str:
|
|
|
+ if not isinstance(receipt, dict) or set(receipt) != {
|
|
|
+ "reservation_id",
|
|
|
+ "dataflow_uid",
|
|
|
+ "nonce",
|
|
|
+ }:
|
|
|
+ raise ValueError("dataflow draft receipt is invalid")
|
|
|
+ reservation_id = _uid(
|
|
|
+ receipt.get("reservation_id"), "reservation_id"
|
|
|
+ )
|
|
|
+ dataflow_uid = _uid(receipt.get("dataflow_uid"), "dataflow_uid")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ nonce = _text(receipt.get("nonce"), "draft nonce", 200)
|
|
|
+ nonce_hash = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
|
|
|
+ consumed = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_draft_reservations "
|
|
|
+ "SET consumed_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND actor_uid = CAST(:actor_uid AS uuid) "
|
|
|
+ "AND nonce_hash = :nonce_hash "
|
|
|
+ "AND consumed_at IS NULL "
|
|
|
+ "AND expires_at > CURRENT_TIMESTAMP "
|
|
|
+ "RETURNING dataflow_uid::text "
|
|
|
+ "/* consume_dataflow_draft */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "actor_uid": actor,
|
|
|
+ "nonce_hash": nonce_hash,
|
|
|
+ },
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if consumed is None or str(consumed) != dataflow_uid:
|
|
|
+ raise ValueError(
|
|
|
+ "dataflow draft receipt is invalid, expired, or already used"
|
|
|
+ )
|
|
|
+ return dataflow_uid
|
|
|
+
|
|
|
@staticmethod
|
|
|
def candidate_hash(candidate: dict[str, Any]) -> str:
|
|
|
return _canonical_hash(validate_rule_candidate(candidate))
|
|
|
@@ -1571,6 +1664,9 @@ class DataRuleRepository:
|
|
|
asset_type: str | None = None,
|
|
|
limit: int = 50,
|
|
|
offset: int = 0,
|
|
|
+ input_schema_refs: list[str] | None = None,
|
|
|
+ output_schema_ref: str | None = None,
|
|
|
+ version_id: str | None = None,
|
|
|
) -> dict[str, Any]:
|
|
|
"""Return only immutable, trusted rule and standard versions."""
|
|
|
if not isinstance(query, str) or len(query) > 200:
|
|
|
@@ -1592,6 +1688,32 @@ class DataRuleRepository:
|
|
|
):
|
|
|
raise ValueError("catalog offset is invalid")
|
|
|
normalized_query = query.strip()
|
|
|
+ has_context = (
|
|
|
+ input_schema_refs is not None or output_schema_ref is not None
|
|
|
+ )
|
|
|
+ if has_context:
|
|
|
+ if (
|
|
|
+ not isinstance(input_schema_refs, list)
|
|
|
+ or not input_schema_refs
|
|
|
+ or len(input_schema_refs) > 50
|
|
|
+ or any(
|
|
|
+ not isinstance(item, str)
|
|
|
+ or not item.strip()
|
|
|
+ or len(item) > 500
|
|
|
+ for item in input_schema_refs
|
|
|
+ )
|
|
|
+ or not isinstance(output_schema_ref, str)
|
|
|
+ or not output_schema_ref.strip()
|
|
|
+ or len(output_schema_ref) > 500
|
|
|
+ ):
|
|
|
+ raise ValueError("catalog schema context is invalid")
|
|
|
+ input_schema_refs = [item.strip() for item in input_schema_refs]
|
|
|
+ output_schema_ref = output_schema_ref.strip()
|
|
|
+ exact_version = (
|
|
|
+ _uid(version_id, "version_id")
|
|
|
+ if version_id is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
rows = (
|
|
|
self.session.execute(
|
|
|
text(
|
|
|
@@ -1599,7 +1721,7 @@ class DataRuleRepository:
|
|
|
"SELECT 'rule'::text AS asset_type, rv.id::text AS "
|
|
|
"version_id, rv.rule_uid::text AS asset_uid, r.name, "
|
|
|
"rv.version_no, r.owner_uid::text AS owner_uid, "
|
|
|
- "rv.status, lp.schema_hashes AS schema_compatibility, "
|
|
|
+ "rv.status, rv.rule_spec AS schema_compatibility, "
|
|
|
"((SELECT COUNT(*) FROM "
|
|
|
"public.dataflow_component_bindings cb "
|
|
|
"WHERE cb.rule_version_id = rv.id) + "
|
|
|
@@ -1669,6 +1791,8 @@ class DataRuleRepository:
|
|
|
"SELECT * FROM catalog_source "
|
|
|
"WHERE (:asset_type IS NULL "
|
|
|
"OR asset_type = :asset_type) "
|
|
|
+ "AND (:version_id IS NULL "
|
|
|
+ "OR version_id = :version_id) "
|
|
|
"AND (:query = '' OR name ILIKE :pattern "
|
|
|
"OR asset_uid ILIKE :pattern "
|
|
|
"OR owner_uid ILIKE :pattern)"
|
|
|
@@ -1687,6 +1811,7 @@ class DataRuleRepository:
|
|
|
"query": normalized_query,
|
|
|
"pattern": f"%{normalized_query}%",
|
|
|
"asset_type": asset_type,
|
|
|
+ "version_id": exact_version,
|
|
|
"limit": limit,
|
|
|
"offset": offset,
|
|
|
},
|
|
|
@@ -1705,15 +1830,48 @@ class DataRuleRepository:
|
|
|
result["id"] = str(evidence_id)
|
|
|
return result
|
|
|
|
|
|
- def compatibility_summary(value: Any) -> dict[str, Any]:
|
|
|
+ def compatibility_summary(
|
|
|
+ value: Any, row_asset_type: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
binding = _object(value, "schema compatibility")
|
|
|
- status = binding.get("status")
|
|
|
- if status in {"unknown", "compatible", "incompatible"}:
|
|
|
- return binding
|
|
|
+ if not has_context:
|
|
|
+ return {
|
|
|
+ "status": "unknown",
|
|
|
+ "reason": "production_line_context_required",
|
|
|
+ "evidence": None,
|
|
|
+ }
|
|
|
+ if row_asset_type == "rule":
|
|
|
+ asset_input = binding.get("input_schema_ref")
|
|
|
+ asset_output = binding.get("output_schema_ref")
|
|
|
+ compatible = (
|
|
|
+ asset_input in input_schema_refs
|
|
|
+ and asset_output == output_schema_ref
|
|
|
+ )
|
|
|
+ evidence = {
|
|
|
+ "asset_input_schema_ref": asset_input,
|
|
|
+ "asset_output_schema_ref": asset_output,
|
|
|
+ "flow_input_schema_refs": input_schema_refs,
|
|
|
+ "flow_output_schema_ref": output_schema_ref,
|
|
|
+ }
|
|
|
+ else:
|
|
|
+ scope = binding.get("schema_ref")
|
|
|
+ compatible = scope in {
|
|
|
+ *input_schema_refs,
|
|
|
+ output_schema_ref,
|
|
|
+ }
|
|
|
+ evidence = {
|
|
|
+ "asset_scope_schema_ref": scope,
|
|
|
+ "flow_input_schema_refs": input_schema_refs,
|
|
|
+ "flow_output_schema_ref": output_schema_ref,
|
|
|
+ }
|
|
|
return {
|
|
|
- "status": "unknown",
|
|
|
- "reason": "production_line_context_required",
|
|
|
- "binding": binding,
|
|
|
+ "status": "compatible" if compatible else "incompatible",
|
|
|
+ "reason": (
|
|
|
+ "exact_schema_refs_match"
|
|
|
+ if compatible
|
|
|
+ else "exact_schema_refs_do_not_match"
|
|
|
+ ),
|
|
|
+ "evidence": evidence,
|
|
|
}
|
|
|
|
|
|
items = []
|
|
|
@@ -1734,7 +1892,8 @@ class DataRuleRepository:
|
|
|
),
|
|
|
"status": str(row["status"]),
|
|
|
"schema_compatibility": compatibility_summary(
|
|
|
- row["schema_compatibility"]
|
|
|
+ row["schema_compatibility"],
|
|
|
+ str(row["asset_type"]),
|
|
|
),
|
|
|
"impact_count": int(row["impact_count"]),
|
|
|
"backend": str(row["backend"]),
|
|
|
@@ -1752,6 +1911,26 @@ class DataRuleRepository:
|
|
|
"offset": offset,
|
|
|
}
|
|
|
|
|
|
+ def get_published_asset(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ asset_type: str,
|
|
|
+ version_id: str,
|
|
|
+ input_schema_refs: list[str] | None = None,
|
|
|
+ output_schema_ref: str | None = None,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ page = self.search_published_assets(
|
|
|
+ asset_type=asset_type,
|
|
|
+ version_id=version_id,
|
|
|
+ input_schema_refs=input_schema_refs,
|
|
|
+ output_schema_ref=output_schema_ref,
|
|
|
+ limit=1,
|
|
|
+ offset=0,
|
|
|
+ )
|
|
|
+ if len(page["items"]) != 1:
|
|
|
+ raise ValueError("published asset was not found")
|
|
|
+ return page["items"][0]
|
|
|
+
|
|
|
def require_published_rule_version(
|
|
|
self, rule_version_id: str
|
|
|
) -> dict[str, Any]:
|